← Back to posts

10 min read

The Four-Hour GraphQL Security Review: A Schedule, Not a Checklist

Filed under GraphQL

Most GraphQL security content assumes you have a pentest scope and a week. You probably have a staging URL and until Friday. The four highest-signal checks fit between lunch and dinner, and the last one finds authorization bugs that a REST review process structurally cannot catch.

Why does GraphQL need its own pass at all? Because the type system only validates that a request matches the schema. It says nothing about authentication, authorization, input semantics, rate limiting, query limits, or error handling. Your REST checklist enforces access at the route level. GraphQL has one route. That single fact drives everything below.

1:00 to 1:15: Sessions, traffic capture, ground rules

Get your test identities lined up before you touch a query. You need one unauthenticated session, two same-role users, two users from different tenants if the app is multi-tenant, one elevated user, and an administrator if you can get one. The two same-role users are what make BOLA testing reliable, and note that two users inside the same tenant tell you nothing about cross-tenant authorization.

Then capture legitimate traffic. Open the app, watch the queries and mutations it actually sends, and save them. Every test this afternoon is a captured query with one element changed at a time. For anything load-bearing, start small and increase gradually while watching application and database behavior. You are verifying controls, not load testing. Run against staging if it exists.

1:15 to 2:00: Introspection, then the suggestion leak everyone skips

Send the full introspection query:

graphql
{
  __schema {
    types {
      name
      fields {
        name
        args { name type { name } }
      }
    }
  }
}

Fail: a JSON document listing every type, field, argument, and relationship, including your entire mutation surface. Pass: an error along the lines of "GraphQL introspection is not allowed."

Severity scales disagree here. One common rating puts introspection exposure at High, another puts schema disclosure at Medium. I write it as Medium with a note, because its real function is multiplying every other finding. An attacker with your schema skips hours of guessing.

Now the part most reviews miss. Disabling introspection does not hide the schema, because GraphQL's field suggestion errors keep leaking names. Send this:

graphql
{ usr { id } }

A default server helpfully answers "Cannot query field 'usr' on type 'Query'. Did you mean 'user'?" Iterate prefixes systematically: admin, internal, secret, debug, token, key, config. Tooling like Clairvoyance automates the reconstruction. So your pass criterion has two halves: introspection rejected, and suggestion leakage assessed. With suggestions enabled, an attacker rebuilds your schema slowly instead of instantly. That is the entire difference.

Still disable introspection in production. Just stop logging that as "schema protected." In Apollo:

javascript
const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV !== 'production',
});

Add a plugin that rejects any query containing __schema or __type for a second layer.

2:00 to 3:00: Batching and alias abuse

Rate limiters count HTTP requests, not operations

GraphQL offers two ways to pack many operations into one HTTP request: array batching (a JSON array of operations) and alias batching (the same field repeated under different aliases inside one operation). Test both.

Test 1, array batching:

json
[
  { "query": "mutation { login(user: \"a\", pass: \"x\") { token } }" },
  { "query": "mutation { login(user: \"a\", pass: \"y\") { token } }" }
]

Fail: an array of results comes back. Pass: the server refuses the batch. Apollo Server 4 defaults allowBatchedHttpRequests to false, but set it explicitly, because defaults get edited by people who are not you. Frankly, most APIs have no legitimate array-batching client at all. Turn it off.

Test 2, alias batching, which is legal GraphQL in a single operation and therefore harder to kill:

graphql
mutation {
  a1: verifyOtp(code: "0001") { ok }
  a2: verifyOtp(code: "0002") { ok }
  # ... through a1000
}

The arithmetic is the finding. HTTP-layer rate limiters count requests, not operations. A limiter configured for 100 requests per minute waves through one request carrying 500 mutations. With aliases, all 10,000 possible four-digit OTP codes fit in as few as 10 requests of 1,000 aliases each. Your WAF sees ten requests and goes back to sleep.

Pass criterion: per-operation controls exist, meaning rate limiting inside the resolvers for sensitive mutations plus a cap on aliases per operation. The concrete fixes are a custom validation rule (a MaxAliasesRule that errors when an operation exceeds your alias budget) and a requestDidStart plugin that rejects any batch containing more than 5 operations. Batch through a login or OTP endpoint and you have a High severity write-up.

3:00 to 3:45: Depth and complexity limits

Find a self-referential relationship in the schema (friends, replies, parent categories) and nest it:

graphql
{
  user(id: 1) {
    friends { friends { friends { friends { friends { id } } } } }
  }
}

Run the numbers before you run the query. With 100 friends per user, five levels of nesting touches 100^5 records, which is ten billion. Ten levels is 100^10 potential database calls. One curl command, no botnet required.

The working heuristic: start at 5 nesting levels, step up gradually, and watch for response-time degradation or 500, 503, and timeout errors. If the server accepts depth beyond 10, report it as a DoS risk. Pass: deep queries get rejected by a validation error before execution. Fail: the server executes them, even slowly. "The gateway timed it out" is not a control. That is your infrastructure absorbing the hit.

Depth and amount limits are not natively supported by GraphQL, so this control is always something you add. Per stack:

javascript
// Apollo Server
validationRules: [depthLimit(7), createComplexityLimitRule(1000)]

APIs on graphql-java get MaxQueryDepthInstrumentation. JavaScript servers more broadly can use graphql-depth-limit and graphql-input-number. Strawberry (Python) has QueryDepthLimiter(max_depth=7) and MaxTokensLimiter(max_token_count=1000).

One opinion: a depth limit alone is a blunt instrument, because a shallow but wide query walks right under it. Pair it with query cost analysis and a maximum cost per query, plus pagination, application and infrastructure timeouts, per-user or per-IP rate limiting, and server-side batching with Facebook's DataLoader pattern.

3:45 to 5:00: The authorization matrix, where the real bugs live

The authorization matrix: owned object types x test sessions

This block justifies the whole afternoon. Here is the trap: REST APIs typically enforce authorization at the endpoint, and GraphQL demands checks at the resolver and field level, which endpoint permission checks do not cover. 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: unauthenticated, same-role user B, other-tenant user, elevated user. Each cell gets exactly one test, a captured legitimate query from user A with A's ID swapped for B's:

graphql
# captured as user A: { order(id: "A-1042") { total shippingAddress } }
{ order(id: "B-0093") { total shippingAddress } }

Fail: B's data comes back. Pass: an authorization error. Then run mutations, where severity jumps:

graphql
mutation { updateUser(id: 2, role: "admin") { id role } }

IDOR through a mutation is rated Critical for good reason. The reporting bar I use: Critical for account takeover, privilege escalation, a destructive unauthenticated mutation, or cross-tenant access to sensitive data. Anything in this block that lands Critical ends your afternoon early and starts someone else's.

Finally, the field level, the test with no REST analog. Query your own object but ask for fields the UI never displays:

graphql
{ user(id: "own-id") { email salary internalNotes ssn } }

If the resolver returns fields the client never renders, field-level authorization is missing. An endpoint check cannot see fields. Only the resolver can.

Seventy-five minutes will not cover every type. Cover the ones holding money or PII, then write the rest up as scoped follow-up. If nobody has ever mapped which resolvers touch sensitive objects, that gap is worth a dedicated threat modeling session before the next review.

What this afternoon is not

The obvious pushback: you cannot pentest a GraphQL API in four hours. Correct, and beside the point. This is a control verification pass with pass/fail criteria, and it covers the classes OWASP flags as the common GraphQL attacks: broken authorization, batching abuse, DoS through unbounded queries, and insecure defaults. It deliberately defers what it cannot finish. Resolver injection is the big one, where argument values get concatenated into raw SQL or NoSQL strings. The fix is parameterized queries, bound parameters in SQLAlchemy text(), $1 placeholders in node-postgres, and auditing that across every resolver takes longer than an afternoon, especially in codebases full of copy-pasted and generated resolver code. Subscription authorization and full cross-tenant sweeps go on the follow-up list too.

The other caveat: these findings expire. The next schema change reopens every question on this list, which is why the output of today should be a recurring calendar entry or a pipeline stage rather than a PDF. A continuous threat exposure pipeline is how you keep a one-afternoon review from becoming a one-time review.

If you want a second set of eyes on the batching and nesting blocks specifically: GraphQL batching and nested-query abuse show up faster when the scanner speaks the schema. Try Axeploit's API security checker.

Key takeaways

  • GraphQL's schema validation covers request shape, not auth, rate limits, or query limits, so your REST checklist transfers almost nothing.
  • Disabling introspection is worth doing and worth little alone; field suggestion errors still leak the schema one prefix at a time.
  • Rate limiters count HTTP requests, not operations, so array and alias batching defeat them unless you cap aliases and rate-limit inside resolvers.
  • Depth beyond 10 nesting levels is a reportable DoS risk; enforce depth and complexity limits at validation time, not at the gateway.
  • The authorization matrix (two same-role users, one ID swapped per request, plus a field-level pass) is the highest-yield hour of the afternoon and the one REST reviews never replicate.
Get started

Integrate Axeploit into your workflow today