Most GraphQL vulnerabilities aren't exotic. They survive because someone ran a REST checklist against an API that exposes exactly one route. The type system validates that a request matches the schema. Nothing more. Authentication, authorization, rate limits, query cost: all on you, and all invisible to route-level access control.
When I get handed a staging URL and until Friday, I time-box four hours. Here is where the time goes.
Your schema leaks even with introspection off
Disabling introspection in production is correct. Recording that as "schema protected" is not. GraphQL's field suggestion errors keep handing out names:
{ usr { id } }
A default server answers "Cannot query field 'usr' on type 'Query'. Did you mean 'user'?" Work through prefixes systematically (admin, internal, debug, token) and the schema rebuilds itself slowly. Tools like Clairvoyance automate it. So the pass criterion has two halves: introspection rejected, suggestion leakage assessed. I log introspection exposure as Medium with a note, because its real function is multiplying every other finding.
One HTTP request, a thousand operations
HTTP-layer rate limiters count requests. GraphQL packs operations.
Array batching first: send a JSON array of login mutations. If an array of results comes back, fail. Apollo Server 4 defaults allowBatchedHttpRequests to false, but set it explicitly. Defaults get edited by people who are not you.
Alias batching is legal GraphQL in a single operation, and it is worse:
mutation {
a1: verifyOtp(code: "0001") { ok }
a2: verifyOtp(code: "0002") { ok }
# ... through a1000
}
The arithmetic is the finding. Every four-digit OTP code fits in ten requests of a thousand aliases each. A limiter set to 100 requests per minute waves that through. Pass means per-operation controls: resolver-level rate limits on sensitive mutations, plus a cap on aliases per operation.
Same hour, check depth. Find a self-referential field and nest it five levels. With 100 friends per user, that touches 100^5 records. Pass is a validation error before execution. "The gateway timed it out" is your infrastructure absorbing the hit, not a control. A depth limit alone is blunt, since a shallow but wide query walks right under it. Pair depthLimit(7) with a complexity rule.
The matrix is where the real bugs live
REST enforces authorization at the endpoint. GraphQL demands it at the resolver and field level. Most GraphQL BOLA exists because someone verified "is logged in" and shipped.
Draw a matrix on paper. Rows are object types with an owner: orders, invoices, documents, profiles. Columns are your sessions. You need two same-role users and, in a multi-tenant app, users from different tenants, because two users inside one tenant tell you nothing about cross-tenant access. Each cell gets one test: a captured query from user A with A's ID swapped for B's.
{ order(id: "B-0093") { total shippingAddress } }
B's data comes back, fail. Then run mutations, where severity jumps. IDOR through updateUser(id: 2, role: "admin") is a Critical write-up. Finally the test with no REST analog: query your own object and ask for fields the UI never renders (email, salary, internalNotes, ssn). If the resolver returns them, field-level authorization is missing. No endpoint check could ever see that. Only the resolver can.
Honest caveat: this is a control verification pass, not a pentest. Resolver injection takes longer than an afternoon to audit, so it waits, along with subscription authorization and full cross-tenant sweeps. And findings expire with the next schema change, so the deliverable is a recurring calendar entry, not a PDF.
What I'd do this week:
- Set
allowBatchedHttpRequests: falseexplicitly, and add a plugin rejecting any query containing__schemaor__type, on top of disabling introspection. - Add a MaxAliasesRule and move rate limiting for login and OTP mutations into the resolvers.
- Pair a depth limit with query cost analysis. Either one alone has a hole.
- Before testing anything, line up your sessions: unauthenticated, two same-role users, one cross-tenant user, and an admin if you can get one.
Which of these would your API fail today? My money is on the field-level check.
Longer writeup with the full four-hour schedule and pass/fail criteria: https://axeploit.com/blog/the-four-hour-graphql-security-review-a-schedule-not-a-checklist
Top comments (1)
The alias-batching arithmetic is the clearest framing I've seen for why request-counting limiters are theatre on GraphQL: a thousand aliases is one legal operation, so a per-minute limit of 100 waves through ten thousand resolver calls, and the OTP space just closes itself in ten requests. Depth limits being blunt on their own is the same mistake in the other direction - shallow and wide walks straight under it.
Field suggestion leakage is where I'd push harder on the pass criterion. If the prod server answers "Did you mean 'user'?" then introspection being off is a checkbox, not a control, so normalising error text on unauthenticated traffic belongs in the same review as the schema. Do you cap aliases per operation, or per request total - because a bounded count still costs if someone packs a nested list selection into each one?