GraphQL routes all queries through one endpoint - making it easy to assume the gateway handles authorization. It does not: every resolver must check access independently, and a missing check on a sensitive field hands callers data they should never see.
GraphQL routes every query and mutation through a single HTTP endpoint - and that uniformity creates an authorization illusion. Locking the endpoint with authentication middleware feels like it covers access control. It does not. Each resolver in a GraphQL schema must enforce its own authorization independently; the schema layer has no central gate that applies field-by-field access rules. A resolver that returns a sensitive field without checking the caller's role is exploitable whether the query reaches it from a public client or a first-party web app.
In a REST API, access control is typically enforced at the route handler - one check per path. GraphQL flips that model: there is one route, and authorization must be applied inside each resolver for each field or object type. Frameworks do not enforce this by default. A developer adds a resolver for a sensitive field, omits the role check, and the field is immediately queryable by anyone who can reach the endpoint - including a regular user who is correctly authenticated but not authorized to see that data.
The confirming artifact is a differential. Make the same query under two distinct roles - one that should have access to the sensitive field, one that should not - and compare the responses. If the lower-privileged role receives the same data as the higher-privileged one, the resolver does not enforce access control. No data is fabricated; the probe uses only fields the caller legitimately declared in the query. The confirming evidence is the second response returning data it should have refused.
# Request 1: privileged role (HR) - establishes expected access POST /graphql Authorization: Bearer <hr-token> {"query":"{user(id:\"102\"){id salary adminNotes}}"} HTTP/1.1 200 {"data":{"user":{"id":"102","salary":82000,"adminNotes":"PIP 2026-05"}}} # Request 2: unprivileged role (standard user) - should be denied POST /graphql Authorization: Bearer <user-token> {"query":"{user(id:\"102\"){id salary adminNotes}}"} HTTP/1.1 200 {"data":{"user":{"id":"102","salary":82000,"adminNotes":"PIP 2026-05"}}} # Differential: responses identical - resolver enforces no access control
Nothing destructive runs. The probe uses two sessions the test environment already holds and requests only the fields declared in the schema. The confirming artifact is the second response returning data a restricted role should never receive.
The attack surface extends beyond top-level fields. GraphQL allows nested object resolution and query aliasing - both extend the reach of a missing auth check. A nested resolver for team { members { salary } } may check authorization on the team resolver but omit it on the inner members resolver, exposing every member's sensitive data through one hop. Aliases let a caller rename the same field in one query, which can defeat naive denylist filters that block the literal field name rather than enforcing policy at the resolver. Fragment spreads reach the same sensitive field across multiple types in a single request.
| Pattern | REST equivalent | GraphQL-specific risk |
|---|---|---|
| Field-level auth missing | Unguarded route handler | Any authenticated caller who knows the field name can request it |
| Nested resolver gap | Nested resource with no secondary check | Parent auth passes; child field leaks through the parent resolver |
| Query aliasing | Parameter alias bypass | Renamed field evades a string-match denylist; resolver still runs |
| Fragment spread | Multi-endpoint sweep | One query reaches the same sensitive field via multiple object types |
Introspection compounds every one of these. An attacker who already queried the schema knows the exact field names, their types, and which objects they belong to. The probe above does not require guessing - the schema handed over the field list. This is why introspection and field-level authorization are two separate issues with a one-way dependency: disabling introspection raises the bar for reconnaissance but does not close the authorization gap. Confirmed introspection is a prerequisite finding, not a fix.
An authorization gap in a GraphQL resolver is rated by what the differential actually produced. A resolver returning another user's public profile fields to a restricted caller is a privacy violation - rated medium. A resolver returning payroll records, administrative notes, or session tokens for arbitrary user IDs is a confirmed breach - rated critical. The data decides the severity, not the mechanism. The same field-level gap in a billing resolver versus a preferences resolver is two different findings at two different severities, and we rate them accordingly.
Fixes belong in the resolver layer, not at the schema level alone. Middleware that validates a JWT at the gateway does not know which object the resolver is about to return or whether the caller has the right to see that specific object for that specific ID. A resolver-level check is the only place that has all three pieces of context: who is calling, what object they asked for, and what data is about to leave. Libraries like graphql-shield (Node.js) and Strawberry permissions (Python) attach rules directly to resolvers and types, making the check the default and the omission the anomaly a review can catch.
The introspection schema tells an attacker which fields exist. A resolver that does not check authorization tells them they can have those fields. Confirming the gap is a differential probe away; fixing it requires every resolver to treat the caller's role and the requested object as explicit inputs, not assumptions the gateway already handled.