Your GraphQL login mutation is rate-limited per HTTP request. Why does that not stop credential stuffing?
answer
- The counter counts the wrong unit
- One body, many executions of one field
- Serial execution helps the attacker, not you
- Two multipliers compose: keys and members
- Move the counter under the resolver
basics
~20 sOne HTTP request can carry the same login mutation hundreds of times — aliased under different response keys, and again as members of a batched array. A per-request counter sees one attempt while the server performs thousands, so count attempts where the field executes.
solid answer
~50 sA selected field's response key is its alias, so one mutation operation can select `authenticate(email:, password:)` under 493 distinct aliases with 493 password variables, and the document is perfectly valid. Serial execution of a mutation's top-level fields does not help — it guarantees all 493 run and returns 493 separately labelled results, so the attacker reads off which password worked. Where the server also accepts a batched array, multiply again by the member count. Against a limit of five requests per minute per address that is tens of thousands of guesses per minute, while the dashboard shows five requests. The fix is the counting unit: increment an attempt counter inside the field's execution, once per resolution, keyed by target account and caller identity, before the credential is verified — and reject at validation any document selecting that field more than once or twice.
code
graphql · 5 linesmutation Stuff($p1: String!, $p2: String!, $p3: String!) {
t1: authenticate(email: "[email protected]", password: $p1) { token }
t2: authenticate(email: "[email protected]", password: $p2) { token }
t3: authenticate(email: "[email protected]", password: $p3) { token }
}go deeper
Recall that a client controls how many times a document selects a field, so one request is not one attempt. Know that the fix is to count attempts, not requests.
Explain the two multipliers precisely: aliases mint distinct response keys that execute separately, and a batched array multiplies again where the server accepts one. State that a mutation's top-level fields execute serially, and that this helps the attacker.
Show where you would move the counter and why — inside the field's execution, keyed by account and caller, incremented before verification — plus the pre-execution occurrence cap, per-member accounting on batched bodies, and the detection changes that make the campaign visible again.
Own the general rule that on one endpoint every per-request budget is divided by document width, and drive the accounting unit to be a resolved field across limits, quotas, audit and alerting so that no team's control silently measures transport instead of work.
## The counter is measuring the wrong thing An attempt counter that increments once per HTTP request is measuring transport, and GraphQL puts an arbitrary amount of work behind one unit of transport. There are two independent multipliers, and they compose. **Aliases.** A selected field's key in the response is its alias when it has one. Selections under different aliases are different response keys, so they are separately executed — including when they name the same field with the same arguments. A mutation may therefore select `authenticate(email:, password:)` under 493 distinct aliases with 493 different password variables. That document is valid; the rule requiring selections to be mergeable applies only to selections sharing a response key, which these deliberately do not. **Serial execution makes it worse, not better.** The top-level selection set of a *mutation* operation is executed serially, in the order written, each field completed before the next begins. Candidates sometimes reach for this as a defence — "they run one at a time, so only the first matters." The opposite is true: serial execution guarantees that every one of the 493 attempts runs to completion, in a defined order, and the response returns 493 separately labelled results so the attacker can read off exactly which password worked. Had they been query fields executing in parallel, the outcome would be the same, only unordered. **Batching.** On top of that, many servers accept a JSON array of request objects in one body — a convention, not part of any specification. Twelve members, each with 493 aliased attempts, is 5,916 password guesses in one POST. Against a per-IP limit of five requests per minute, the honest exposure is 5 × 5,916 = 29,580 guesses per minute from a single address, while the dashboard shows five login requests per minute and the access log shows five lines. ## Everything keyed to the request degrades the same way This is the generalisation worth stating out loud, because it is the reason interviewers ask: on a single-endpoint protocol, *any* control whose unit is the HTTP request is divided by whatever the document's width happens to be. - **Rate limiting** — as above. - **A CAPTCHA or proof-of-work token** presented once per request buys one token per thousands of guesses. It is also irrelevant to an attacker posting to the endpoint directly rather than through the sign-in screen. - **Account lockout** still works *if* it increments per attempt; keyed per request, it never trips. - **Alerting and metrics** flatten. A "failed logins per minute" panel fed by request counts stays flat through the whole campaign, and a panel dimensioned by client-supplied operation name is whatever the attacker typed. - **Audit logging** writes one line describing a request that contained hundreds of credential checks. ## Where the counter belongs Count where the attempt actually happens: **inside the field's execution**, once per resolution, keyed by the thing you are protecting — the target account identifier, and separately the source address or client identity — never by the request. That single move fixes aliases and batching at once, because both are just ways of causing more resolutions, and the counter sits underneath all of them. Since it is a shared counter, it needs to live somewhere the whole fleet can see, and it needs to be incremented *before* the password comparison, so that a slow verification cannot be used to run attempts concurrently past the threshold. Add a cheap pre-execution guard in front of it. Before executing, count how many times the sensitive field appears in the collected fields of the document and reject outright above a small number — one or two, for a login mutation. Static cost scoring gives you the same effect if `authenticate` carries a heavy weight and the caller has a per-caller budget. Both run on the document alone, which means the amplified document is rejected before a single password is verified, and the rejection costs a parse. And apply every one of these per array member, not per body, on any endpoint that accepts batched arrays — or refuse arrays on the endpoint that exposes authentication. ## The rest of the response surface Two details worth mentioning because they change the attacker's economics. First, a failed authentication that returns a field error still returns HTTP 200 with a partially populated `data` — so the attacker parses the `errors` array and the per-alias results, and any downstream tooling that classifies success by status code sees nothing. Second, uniform failure responses matter here more than usual: with 493 results in one response, a message that distinguishes "no such account" from "wrong password" hands over account enumeration at the same amplification factor as the guessing itself. ## The answer in one breath Per-request limits count requests; GraphQL lets one request contain hundreds of executions of the same field, via aliases and, where accepted, via array batching. Move the counter into the resolver, key it on the account and the caller, increment it before verifying the credential, and reject documents that select the sensitive field more than a couple of times before execution starts.
- Would you enforce the cap before execution or inside the resolver?Both, for different reasons. A pre-execution check counts how many times the sensitive field appears in the document and rejects an amplified one for the cost of a parse, before any password is verified. The in-resolver counter is the authoritative one, because it is the only place that sees attempts arriving across separate requests, separate documents and separate batch members.
- The sign-in screen already presents a CAPTCHA. Does that change the exposure?Barely. The attacker posts to the GraphQL endpoint directly and never renders the screen, and even where a token is required, one token bought per request covers however many attempts the document contains. A challenge is only meaningful when it is demanded per attempt, at the same place the attempt counter lives.
- What does this amplification do to your detection?It hides the campaign. Failed-login panels fed by request counts stay flat, access logs show a handful of lines, and any metric dimensioned by client-supplied operation name is whatever the attacker typed. Emit a counter incremented per resolution of the authentication field, and alert on failures per account and per caller rather than per request.
- Does uniform error wording still matter when hundreds of results come back at once?More than usual. With hundreds of labelled results in one response, any wording that distinguishes an unknown account from a wrong password hands over account enumeration at the same amplification factor as the guessing. Return one message for both, and keep timing between the two paths comparable.
saying these in an interview costs you the question
- Mutations in one document run in parallel so only one counts
- Per-IP request rate limiting is sufficient for login
- The schema exposes login once, so it runs once per request
- A nesting-depth limit blocks the amplified document
- Disabling introspection hides the authentication mutation
- HTTP 200 with errors means no attempt was made