Home/Journal/Teardown
TeardownHighgraphqlrate-limitingotp

Batching Past the Rate Limit

A GraphQL sign-in counted requests, not guesses, so aliased batching packed hundreds of OTP attempts into one request. Here is how, and how to close it.

Rahul Dharan··8 min read·a large property marketplaceRate-limit bypass via request batching (CWE-307/799)

The setup

The target was a large consumer property-listings marketplace: the kind of site where people browse homes to rent or buy, save searches, and contact owners through the platform. Accounts hold personal details, saved properties, and contact history, so account takeover is a real prize here, not a theoretical one.

The product ran on a GraphQL API. That is a common choice for a marketplace with a busy mobile app and web front end, because a single flexible endpoint serves many screens. It also changes the shape of the attack surface in ways that are easy to underestimate, and that is where we spent our attention.

Recon

Working black-box, from the open internet with no account, we mapped the GraphQL surface. GraphQL tends to concentrate a lot of behaviour behind one endpoint, so the first job was to understand what operations existed and which ones touched authentication.

The sign-in flow stood out. Rather than a dedicated REST login route, the marketplace had implemented sign-in as a GraphQL operation. A user supplied a phone number, the platform sent a one-time code, and a second operation checked the code the user typed back. Both steps were fields in the same GraphQL schema.

The hypothesis

Two facts sat next to each other. First, one-time codes are short. A numeric code has a small, finite space, and the only thing standing between an attacker and that space is a rate limit. Second, the check was a GraphQL field.

GraphQL has a feature that most REST APIs do not: aliases. A single request can ask for the same field many times over, each under a different alias, and the server resolves every one. So the hypothesis was straightforward. If the sign-in check is a GraphQL field, and the rate limiter counts HTTP requests, then aliasing lets a client pack many code guesses into one request, and the limiter counts that as a single event. The rate limit would be real but measuring the wrong thing.

If that held, a short code plus a request-counted limit equals a brute-forcible login.

What we tried

We started with the send-code step, to understand the shape of the flow. That call, it turned out, honoured a client-supplied parameter for how long the generated code should be. A client could ask for a shorter code. A shorter code is a smaller space to search, so this weakness compounded the next one: the attacker gets to choose an easier target before guessing even begins.

Then we tested the amplification directly. We built a single request that asked for the code-check field many times over, each under its own alias, each carrying a different candidate code. We sent it and watched how the server and the rate limiter responded. The server resolved every aliased field in the one request. The limiter registered one request. Dozens to hundreds of guesses had passed through what the platform counted as a single attempt.

Not everything worked, and the failures are worth stating plainly, because they are where the honesty of an engagement lives.

We proved the amplification primitive and the client-controlled length. We deliberately did not run a real account takeover. Completing one means brute-forcing the code sent to a phone number, and that requires a phone number whose owner has consented to the test. Proving that the ingredients exist, and that the limiter counts the wrong thing, is the finding. Walking into a stranger’s account is not something an engagement needs, or is entitled, to do.

What we found

The mechanism was a clean two-part failure.

The rate limiter counted requests, not attempts. Because sign-in was a GraphQL field, GraphQL aliases let a client submit many code guesses inside one HTTP request, and the limiter treated that request as one event. The intended limit, which looks correct if you imagine one guess per request, was defeated by putting the guesses inside a single request.

-> POST /graphql   (one HTTP request, one counted event)

   mutation {
     a: checkCode(phone: "<number>", code: "0000") { token }
     b: checkCode(phone: "<number>", code: "0001") { token }
     c: checkCode(phone: "<number>", code: "0002") { token }
     ... many more aliased guesses in the same request ...
   }

Each aliased field is a separate guess. The server resolves all of them. The rate limiter sees one request. The math that protects a short code assumes the attacker is throttled to a few guesses in the window; alias batching removes that assumption.

The companion weakness made it worse. The send-code call honoured a client-controlled length parameter, so an attacker could request a shorter code and shrink the space to search before batching through it. Together, a client that picks an easier target and then submits the whole space in a handful of counted requests can drive an OTP toward account takeover.

Why it happens

Nobody made a careless decision here. This is a normal consequence of two reasonable choices meeting.

The first choice was to serve authentication through GraphQL, alongside everything else the marketplace does. That keeps the API consistent and the front end simple. The second was to rate-limit at the HTTP layer, which is where most rate limiting naturally lives and where it is easy to reason about: so many requests per minute per client. Each choice is defensible. The gap is in the seam between them. A request-counted limiter silently assumes one meaningful action per request, and GraphQL aliasing breaks that assumption by design. The limit was not missing. It was measuring requests in a world where one request can carry hundreds of attempts.

The client-controlled code length is the same story in miniature: a parameter that was convenient during development, left trusting the client, that quietly hands the attacker a lever.

For developers

If you run authentication through GraphQL, or any batchable transport, these controls close this:

  1. Count attempts, not requests. Rate-limit on the sensitive action itself - each code check is one attempt - not on the HTTP request that carries it. The counter has to see inside the request.
  2. Cap or reject aliased and batched operations on sensitive fields. A sign-in or code-check field has no legitimate reason to appear many times in one request. Reject duplicates, or cap operation count and query complexity, on anything that touches authentication.
  3. Never trust a client-supplied code length. The server decides how long a one-time code is. Ignore any length the client sends, and generate to a fixed, sufficient length server-side.
  4. Lock the account after N failed codes, regardless of transport. A hard failure counter on the account, tripped after a fixed number of wrong codes and independent of how the guesses arrive, is the backstop that survives a clever transport trick. Expire the code quickly and invalidate it on lockout.

And one detection step: alert on a single request that resolves an unusually high number of the same authentication field. That pattern has no honest explanation.

The takeaway

A rate limit is only as good as the thing it counts. When authentication runs on a batchable transport like GraphQL, counting requests instead of attempts leaves the real limit - guesses against a short code - wide open, because one request can carry the whole search. The fix is not a bigger limit; it is limiting the right unit. This teardown is one instance of a pattern we see repeatedly, written up in full in The OTP That Never Dies.

Frequently asked

What is a GraphQL batching rate-limit bypass?
It is an attack where GraphQL aliases let a client pack many operations into one HTTP request. If the rate limiter counts requests instead of individual operations, dozens or hundreds of attempts pass as a single counted event, defeating the limit.
How does alias batching enable OTP brute force?
When a one-time-code check is a GraphQL field, aliasing lets a client submit many code guesses in one request. A request-counted limiter sees one request, so the attacker can try a large range of codes far faster than the limit intends, moving account takeover into reach.
How do you stop GraphQL batching attacks on authentication?
Count attempts rather than requests, cap or reject aliased and batched operations on sensitive fields, lock the account after a fixed number of failed codes regardless of transport, and never trust a client-supplied code length.

This is one finding from a harness that runs continuously. See how Greywatch finds, proves, and fixes them.

How it works →