GraphQL Batching & Aliasing Attacks: Rate-Limit Bypass, Brute Force & DoS
Rate limiting on a GraphQL API is almost always implemented in the wrong place. Most teams put it where they put it for REST: a counter per IP, per token, or per HTTP request. That assumption holds for REST because one request does one thing. GraphQL breaks the assumption — a single HTTP request can carry hundreds or thousands of independent operations, each of which the server faithfully executes. The result is that a defender's "10 requests per minute" becomes an attacker's "10,000 operations per minute," and the rate limiter never fires.
This article focuses narrowly on the two mechanics that make this possible — array batching and field aliasing — and on the only durable defense against them: query cost analysis. We will look at how each technique amplifies a single request into many, how to measure the amplification factor during an authorized test, and how to reason about the cost limits that actually stop them. Everything below assumes you have written authorization to test the target. Build and fire these queries interactively with the GraphQL Security Tester and the GraphQL Injection payload generator.
Why one HTTP request is the wrong unit
The GraphQL specification permits a server to accept a JSON array of operations in a single POST body. This is "request batching," and it exists so that a client can collapse a render's worth of queries into one round trip. Apollo Server, graphql-yoga, Hasura, and many homegrown servers enable it by default. Separately, the query language itself lets you request the same field multiple times under different names using aliases — a feature meant for asking for, say, two product variants in one query.
Both features are legitimate. Both also mean the unit a rate limiter must count is the operation or the resolved field, not the HTTP request. When the limiter counts requests, an attacker simply packs more work into each one. The amplification factor — operations executed divided by HTTP requests sent — is the single most useful number to report in a finding. A limiter that allows 5 requests/minute but executes batches of 1,000 has an effective ceiling of 5,000 operations/minute, a 1,000x bypass.
Array batching: many operations, one request
The simplest amplification is a JSON array. Instead of one object in the POST body, send a list. The server runs each element and returns an array of results in the same order.
POST /graphql HTTP/1.1
Host: target.example
Content-Type: application/json
[
{"query":"mutation{ login(email:\"[email protected]\", password:\"Spring2026!\"){ token } }"},
{"query":"mutation{ login(email:\"[email protected]\", password:\"Summer2026!\"){ token } }"},
{"query":"mutation{ login(email:\"[email protected]\", password:\"Autumn2026!\"){ token } }"}
]
The response is a parallel array. You walk it looking for the element whose token is non-null:
[
{"data":{"login":{"token":null}}},
{"data":{"login":{"token":"eyJhbGciOi..."}}},
{"data":{"login":{"token":null}}}
]
The second guess succeeded. Crucially, from the rate limiter's point of view this was one login. To detect whether a target accepts array batching, send a two-element probe where the second element is harmless, and confirm you get a two-element array back. If the server rejects arrays you will get a single error object instead.
Alias amplification: the same field, a thousand times
Some servers disable array batching but leave aliasing untouched — and aliasing is harder to switch off because it is core query syntax. With aliases you duplicate a single mutation or query inside one operation, giving each instance a unique name so the response can distinguish them:
{
a0: verifyOtp(phone:\"+15555550100\", code:\"0000\"){ ok }
a1: verifyOtp(phone:\"+15555550100\", code:\"0001\"){ ok }
a2: verifyOtp(phone:\"+15555550100\", code:\"0002\"){ ok }
# ... continue programmatically ...
a9999: verifyOtp(phone:\"+15555550100\", code:\"9999\"){ ok }
}
This is the canonical OTP / 2FA bypass. A four-digit code has 10,000 possibilities; with 10,000 aliases you cover the entire keyspace in a single HTTP request. Even chunked into batches of 1,000 aliases to stay under body-size limits, the whole space falls in ten requests — far below any per-request rate limit. The same pattern brute-forces coupon codes, password-reset tokens with small entropy, and gift-card PINs.
Generating ten thousand aliases by hand is impractical, so script it. A short Python snippet emits the query body:
codes = [f"{i:04d}" for i in range(10000)]
aliases = "\n".join(
f'a{i}: verifyOtp(phone:"+15555550100", code:"{c}"){{ ok }}'
for i, c in enumerate(codes)
)
query = "{\n" + aliases + "\n}"
# POST {"query": query} to /graphql
Note that aliasing and array batching stack. A request body that is an array of N operations, each containing M aliases, executes N×M resolutions. This is the worst case for a request-counting limiter and the easiest amplification to demonstrate to a client.
Turning amplification into denial of service
The same primitives that defeat rate limits also exhaust resources. Where brute force aims a thousand cheap operations at an endpoint, DoS aims a handful of expensive ones. Alias a costly resolver — a full-text search, a report export, an image transform, anything that hits the database hard or fans out to a downstream service — a few hundred times in one request:
{
q0: searchProducts(query:\"a\", first: 1000){ id title description reviews { body } }
q1: searchProducts(query:\"a\", first: 1000){ id title description reviews { body } }
# ... q499 ...
}
Each alias triggers a heavy search plus a nested fan-out into reviews. Five hundred aliases in one request can pin a worker, and several such requests can saturate a connection pool. This is distinct from the classic deeply nested query DoS (recursing through circular relationships like friends{ friends{ friends{ ... }}}) — aliasing achieves breadth-based exhaustion rather than depth-based, so depth limits alone do not stop it. A thorough test exercises both. For the broader methodology — introspection, field suggestion, injection through arguments — see the guide to testing GraphQL APIs.
Measuring amplification during a test
A finding is far more persuasive with numbers attached. During an authorized engagement, quantify the bypass rather than just asserting it:
- Establish the baseline. Send single, unbatched operations until you trigger the limiter (HTTP 429 or a GraphQL
RATE_LIMITEDerror). Record the threshold, e.g. 5 requests / 60s. - Probe batch acceptance. Confirm array batching and aliasing each return multiple results, and find the practical ceiling — usually capped by the JSON body-size limit, not by the GraphQL layer.
- Compute the factor. If 5 requests/minute is the cap but each request runs 1,000 operations, your effective rate is 5,000 ops/minute — a 1,000x amplification. State it plainly in the report.
- Confirm side effects, carefully. For brute force, prove that guesses are actually evaluated (a known-good credential returns a token among the batch). For DoS, measure latency degradation on a non-production or explicitly-scoped target — never push a live system to failure.
Always include a request/response pair showing the multi-element array and the rate-limiter baseline side by side. That contrast is what turns "GraphQL allows batching" into a concrete, reproducible vulnerability.
Defenses: count operations, not requests
The root cause is a mismatch between the rate-limiting unit and the work unit. Fixing it requires moving enforcement into the GraphQL layer, where the server can see how much work a request actually represents.
- Query cost / complexity analysis. Assign each field a static cost, multiply list fields by their requested size (
first/limitarguments), and reject any query whose total exceeds a budget before execution. Because aliases each contribute their own cost, a thousand aliased mutations blow the budget immediately. Libraries likegraphql-cost-analysis,graphql-query-complexity, and Apollo's operation limits implement this. - Disable array batching, or cap it. If you do not need request batching, turn it off. If you do, cap the array length (e.g. 10) and apply the rate limit to the sum of operations across the array, not to the request.
- Limit aliases and total fields. Reject queries above a maximum alias count or maximum total field count. This directly defeats alias amplification without touching legitimate small queries.
- Enforce depth limits. A maximum selection-set depth stops recursive nesting DoS. Pair it with cost analysis, since depth alone misses breadth-based aliasing attacks.
- Sensitive mutations need their own counters. Login, OTP verification, password reset, and coupon redemption should each carry a strict per-account, per-target rate limit enforced in the resolver — counting attempts on the target identity (the email, phone, or token being guessed), not on the requester. This way 10,000 aliased
verifyOtpcalls against one phone number are throttled regardless of how they were packaged. - Persisted (allow-listed) queries. In production, accept only a server-side allow-list of known query hashes. Arbitrary attacker-crafted batches and alias floods are rejected outright because they are not on the list — this is the strongest control for APIs with a fixed, first-party client.
One subtlety: timing-based account lockout and CAPTCHA after N failures are weak against batching because all N+ guesses arrive simultaneously in a single request, often before any lockout state is written. Per-operation cost limits and target-keyed resolver counters evaluated during execution are what actually hold. When you write up findings, recommend cost analysis as the primary fix and frame batching/alias caps as defense-in-depth.
Putting it together
GraphQL batching and aliasing are not exotic — they are default-on features that quietly invalidate request-based rate limiting. The testing loop is short: confirm batching and aliasing are accepted, establish the rate-limit baseline, compute the amplification factor, and demonstrate a concrete impact (an OTP brute-forced, a worker saturated). The remediation is equally clear: move enforcement into the GraphQL layer with query cost analysis, cap batch sizes and alias counts, and key sensitive-operation limits to the target identity rather than the request. Use the GraphQL Security Tester to script the alias and array payloads, keep your engagement within its authorized scope, and report the amplification number — it is the figure that makes the risk impossible to dismiss.
Put this into practice
Generate and test these payloads interactively — free, in your browser.
Level up your security testing
Install the CLI
npx payload-playgroundExplore All Tools
Encoding, hashing, JWT & more
Browse Cheat Sheets
Quick-reference payload guides