← Blog
October 22, 202610 min readAppSecVulnerability ResearchBug bountyGraphQL

GraphQL security beyond introspection: batching, aliasing, and limits that hold

A GraphQL endpoint can require auth, hide its schema, and rate-limit requests, and still be brute-forced in a single HTTP request. A practitioner's walk through introspection and suggestion leaks, how aliasing and query batching slip past rate limits, resource exhaustion via nested queries, and the controls — trusted documents, field-level authorization, and cost analysis — that actually hold.

By The Xalgorix Team

A GraphQL endpoint can pass every box on a hardening checklist and still be trivially abusable. Authentication is required. Introspection is switched off in production. There is a rate limit on the login mutation. And yet, with one carefully built HTTP request, an attacker can put a hundred password guesses past that rate limit, or ask a question so expensive it ties up the database. The gap is that those checklist items describe configuration, and configuration is not proof. What decides whether the endpoint is actually safe is how the server behaves when someone sends it a deliberately hostile document — and that has to be tested, not assumed.

Why GraphQL moves the goalposts

In a REST API the server owns the shape of every response: one route, one roughly known cost. GraphQL inverts that. The client chooses which fields to ask for, how deeply to traverse the graph, and how many operations to pack into a request. As the official GraphQL guidance puts it, because the client picks the fields, the server often cannot know in advance how expensive a request will be (graphql.org/learn/security). That flexibility is the whole point of GraphQL, and it is also the root of its security model: a single endpoint accepts an enormous range of requests, and “is this request safe?” becomes a question the server has to answer at runtime, per document, rather than something you can settle once at the routing layer.

Three properties follow, and each is a place where a passing config and a real exploit diverge. The schema is discoverable even when you try to hide it. One request can carry many operations, so per-request controls undercount the work. And a small, well-formed query can demand a disproportionate amount of server work. Take them in turn.

Introspection is a disclosure problem, not the whole game

Introspection is GraphQL's built-in self-description: a single query returns every type, field and argument in the schema. It is wonderful for building clients and a gift for an attacker, because it hands over the full map of the attack surface for free.

# A compact introspection probe - type and field names only.
# The full introspection query also pulls arguments, types, enums and directives:
{ __schema { types { name fields { name } } } }

# Even with introspection OFF, a misspelled field can leak a real name:
{ user { passwrd } }
   -> Did you mean "password"?     # "suggestions" are enabled by default in some servers

The reflexive fix is to disable introspection in production. For a first-party API — one whose only clients are your own — that is reasonable, and the official guidance frames it that way, as an option for APIs serving first-party clients only. A genuinely public API that third parties build against is the opposite case: there introspection is a feature consumers depend on, so turning it off mostly inconveniences legitimate clients while buying obscurity, and the real work becomes reviewing and minimising what the schema exposes. Either way, treat it as reducing disclosure rather than a control — turning introspection off is a form of security through obscurity and is not sufficient on its own (graphql.org/learn/security). PortSwigger's Web Security Academy documents the ways around a half-measure: some servers answer a misspelled field with a “Did you mean…?” suggestion that leaks real names one typo at a time, and naive filters that block the literal string __schema can be slipped by inserting a newline or whitespace before the brace, or by delivering the introspection query over GET instead of POST (GraphQL API vulnerabilities). Disabling introspection raises the effort to map the schema; it does nothing about what happens once an attacker has mapped it anyway.

Aliasing and batching: one request, many attempts

This is where a clean-looking config fails hardest. Most rate limiting counts HTTP requests. GraphQL gives a client two ways to carry many operations inside a single request, so the thing being counted stops matching the thing you meant to limit.

The first is aliasing, a core part of the GraphQL specification that is always available — it exists so a client can request the same field more than once under different names. The same feature lets an attacker invoke a mutation hundreds of times in one document:

# One HTTP request, many password guesses - each one an alias:
mutation {
  a: login(username: "victim", password: "Winter2026!") { token }
  b: login(username: "victim", password: "Summer2026!") { token }
  c: login(username: "victim", password: "P@ssw0rd")    { token }
  # ...hundreds more aliases, all executed from a single request
}

The second is query batching, which many servers support: the body is a JSON array of independent operations, each with its own variables.

# Same idea via batching - an array of operations in one POST body:
POST /graphql
[
  { "query": "mutation Try($u:String!,$p:String!){ login(username:$u,password:$p){ token } }",
    "variables": { "u": "victim", "p": "guess1" } },
  { "query": "...same operation...", "variables": { "u": "victim", "p": "guess2" } }
]
# The rate limiter sees ONE request. The server executes every operation.

Checkmarx demonstrated exactly this against an API that allowed five requests per second: a normal credential-stuffing run tripped 429 Too Many Requests after five tries, while the same hundred login attempts packed into one batched request came back 200 OK with a hundred results, because the limiter never saw more than one request (Checkmarx research). PortSwigger teaches the aliased variant as a named technique, “bypassing rate limiting using aliases,” with a lab that defeats brute-force protection the same way (PortSwigger). The important nuance for a defender: disabling batching does not close this, because aliasing is a core language feature you cannot turn off without breaking legitimate clients.

1 HTTP request
passes the limiter
N invocations
aliases or batch
N executions
server runs each
N login attempts
throttle bypassed
Aliases and batching multiply the attempts inside a single HTTP request, so a request-counting limiter undercounts the work.

Whether your endpoint has this problem is not something you can read off the config. It depends on what layer the rate limit lives in and what it actually counts, which is why it has to be exercised rather than reviewed.

100 login mutations in one request
Counts requests
Sees one request, allows it. Throttle bypassed.
Counts attempts
Sees 100 invocations, blocks. Throttle holds.
Caps batch + aliases
Rejects the oversized document before it runs.
The same hostile request is harmless or effective depending on whether the limiter counts requests or the attempts inside them.

Resource exhaustion: depth, breadth, and cost

The same freedom that lets a client ask for exactly what it needs lets it ask for far too much. Where types reference each other — a post has an author, an author has posts — a short query can nest into a cycle whose cost grows geometrically with depth.

# A small query that explodes: each level multiplies the work.
query {
  posts {
    author {
      posts {
        author {
          posts { title }   # keep nesting - resolver work grows with depth
        }
      }
    }
  }
}

Aliasing compounds this too: repeating an expensive field under many aliases multiplies resolver work within a single operation, and a naive resolver that issues one database call per node turns a compact query into thousands of queries. The official guidance lists the layered answer directly: limit the maximum depth a single operation may reach, give nested lists a separate and smaller limit because they expand data exponentially, and — the control that generalises — weight types and fields to estimate a request's cost and reject anything over budget before execution begins (graphql.org/learn/security).

# Cost analysis: price the document, reject over-budget before running it.
posts(first: 100)       -> 100          (list multiplier)
  author                -> 1 each       -> 100
    posts(first: 50)    -> 50 each      -> 5000
estimated cost: 5200  >  budget: 1000   -> rejected, never executed

The same source rounds out the set: paginate any list that can return a large set and cap the page size, apply rate limits in the business-logic layer rather than only at the network edge, bound the number of aliases and top-level fields per operation, cap the number of operations in a batch, and set request timeouts. None of these is interesting on its own; the point is that depth limiting without cost analysis still falls to aliasing, and cost analysis without pagination still lets a single list field return the world.

The limits that actually hold

Ranked roughly by how much they move the needle, and drawn from the same official guidance:

  • Trusted documents (persisted queries). For a first-party API the strongest control is to stop accepting arbitrary queries at all. Trusted documents register each operation your own clients use at build time, keyed by a hash, and the server runs a request only if it matches a known document ID. Arbitrary attacker-authored queries — introspection, alias brute force, cyclic depth bombs — simply never execute. It only fits clients you ship yourself, not open third-party APIs.
  • Field-level authorization. Authentication at the endpoint is not authorization on the data. Every resolver that returns sensitive data has to check that this caller may see this object, or you get the GraphQL flavour of broken object-level authorization — a valid session reading another user's records through a field the UI never exposes.
  • Query cost analysis plus depth and breadth caps. A cost budget evaluated before execution is the most general resource control, because it prices the whole document rather than pattern-matching one shape of abuse, so a new nesting or aliasing trick does not simply slip past it. It is still an estimate built from the weights you assign, and cannot see data-dependent cost a resolver only discovers at runtime, so pair it with execution timeouts and business-layer limits rather than treating the budget as a complete guarantee.
  • Attempt-aware rate limiting. Count the sensitive action itself — how many times login actually runs, keyed to the target account or identity — not HTTP requests and not operations. That distinction is what catches both bypasses: a batch carries many operations, but an aliased mutation is a single operation that invokes the field many times, so only a counter on the underlying invocations throttles the aliased variant. Cap aliases and batch size as a backstop.
  • Disable introspection and suggestions for first-party APIs. Worth doing to cut disclosure where every client is your own; a public API that third parties build against will usually keep introspection on by design. Either way it is last on the list, precisely because it is the one most likely to be mistaken for a real defence.

Proof, not a passing config

Every weakness above shares a shape: the configuration looks right and the behaviour is wrong. An automated scan is genuinely useful here — it will tell you introspection responds, that the server accepts a batch, that a deep query is not rejected. But those are observations about configuration. None of them establishes the finding that matters, which is that a hundred guesses in one request actually defeat your login throttle and return a hundred distinct results. That claim is only settled by sending the request to a test account you control and watching the limiter fail to fire.

Config flag
batching allowed
Reproduce safely
test account
Observed bypass
100 results, 200 OK
Proof
throttle defeated
A scanner's flag is a lead; proof is a controlled, repeatable exploit against a test account.

This is the honest limit of automation, our own included: a tool can enumerate the conditions for an attack far faster than a human, but confirming that the attack works end to end — and scoping its impact without harming real users or data — is where judgement and a controlled, authorised test still do the deciding. That is the line Xalgorix draws between detecting a candidate and proving a vulnerability, and GraphQL is a near-perfect illustration of why the distinction is not pedantic. A green checklist is a hypothesis. A reproduced bypass against a test account is proof.

Exercise these techniques only against systems you own or are explicitly authorised to test. Alias and batch brute forcing, cost-bomb queries, and enumeration can degrade a live service and expose other users' data. On an authorised engagement, a small, controlled demonstration — a handful of aliased attempts against your own test account — is usually all the proof a report needs.

Ready to see it prove a bug?

Start scanning on Cloud →