Introduction: GraphQL Security Is Misunderstood — and Attackers Know It
Most security engineers treat GraphQL as a variant of REST with a different query syntax. That assumption is expensive. GraphQL's design — a strongly-typed schema, a single endpoint, self-describing introspection, and unbounded query composition — creates an attack surface that REST simply does not have.
The misunderstanding runs deep. A penetration tester probing a GraphQL API with a traditional web scanner will fire off the same path-fuzzing, verb-tampering, and injection payloads they would against any other HTTP endpoint. They will likely walk away clean. The API is vulnerable. The scanner just had no idea what it was looking at.
This article documents three interconnected GraphQL attack classes, explains how they work at the resolver level, and shows how they chain together in a realistic attack scenario. At the end, we propose a working taxonomy — GQL-001 through GQL-008 — for classifying GraphQL vulnerabilities in scanner output and bug bounty reports.
1. Schema Enumeration: Introspection and Field Suggestion Harvesting
1.1 Introspection: The Self-Documenting Attack Surface
GraphQL servers expose a built-in introspection system that allows any client to query the schema itself — every type, every field, every argument, every relationship. In development, this powers documentation tools like GraphiQL and Voyager. In production, it is a complete blueprint of the application's data model, handed to any unauthenticated caller willing to ask.
The introspection query is standardized:
query IntrospectionQuery {
__schema {
queryType { name }
mutationType { name }
types {
name
kind
fields {
name
type { name kind ofType { name kind } }
args { name type { name } }
}
}
}
}A server that responds to this query exposes, in a single HTTP response, the equivalent of a complete API specification. Sensitive field names (internalAdminNotes, rawPasswordHash, stripeCustomerId) appear in the schema regardless of authorization controls on the resolvers that back them. Knowing they exist is valuable to an attacker even before they attempt to access the data.
This is GQL-001 in the taxonomy: unauthenticated introspection exposure.
1.2 Field Suggestion Harvesting: Enumeration Without Introspection
Production deployments often disable introspection. This is correct hygiene. It is also, by itself, insufficient.
GraphQL servers — particularly those built on libraries like Apollo Server, graphql-js, and Strawberry — implement a developer experience feature called field suggestions. When a client submits a query referencing a field that does not exist, the server checks whether any similar field names exist in the schema and includes them in the error response as a helpfulness mechanism:
{
"errors": [
{
"message": "Cannot query field \"passward\" on type \"User\". Did you mean \"password\", \"passwordHash\", or \"passwordResetToken\"?"
}
]
}An attacker who knows about field suggestions can probe the schema iteratively without ever triggering an introspection query. By fuzzing field names against a wordlist — common API terms, domain-specific vocabulary extracted from JavaScript bundles, field names from similar open-source codebases — and observing suggestion responses, they can reconstruct significant portions of the schema.
This is GQL-003: field suggestion harvesting. It defeats the most common introspection mitigation deployed in production.
The attack loop looks like this:
for word in wordlist:
send query: { user { <word> } }
if response contains "Did you mean":
extract suggested field names
add new names to the wordlist
recurseA single starting probe against a User type with a 200-word seed list can expand into complete field coverage of that type within a few dozen requests.
Defensive controls for enumeration:
- Disable introspection in production (
introspection: falsein Apollo Server). This is a floor, not a ceiling. - Disable or suppress field suggestions in production. In Apollo Server 4, set
includeExtensions: falseand configure a custom error formatter that strips suggestion text from error messages before they reach the wire. - Apply authentication to the
/graphqlendpoint itself before any query parsing occurs. - Log and alert on schema-probing patterns: high volumes of
Cannot query fielderrors from a single IP.
2. Batch Attacks: Massive Enumeration in a Single HTTP Request
2.1 How GraphQL Batching Works
GraphQL supports two mechanisms that allow multiple operations to be submitted in a single HTTP request. The first is operation batching, where the request body is a JSON array of operation objects. The second is aliased field repetition within a single query, where the same field is queried multiple times with different arguments under different alias names.
Both mechanisms are legitimate features. Both are routinely abused.
2.2 Email Enumeration via Batched Login Mutations
Consider an authentication endpoint that properly rate-limits login attempts: one request per second per IP, lockout after ten failures. An attacker batching login mutations can test hundreds of credentials in a single HTTP request, receiving one HTTP response with hundreds of individual results — all of which arrive before the rate limiter has incremented its counter even once.
[
{ "query": "mutation { login(email: \"alice@corp.com\", password: \"Password1\") { token } }" },
{ "query": "mutation { login(email: \"bob@corp.com\", password: \"Password1\") { token } }" },
{ "query": "mutation { login(email: \"charlie@corp.com\", password: \"Password1\") { token } }" }
// ... 497 more
]The response is an array of 500 results. Successful authentications return tokens. Credential failures return errors — but the error messages themselves often differ based on whether the email exists (Invalid password vs. User not found), enabling pure email enumeration even when every attempt fails authentication.
Aliased batching achieves the same result within a single query document and is even harder to detect as batching because the HTTP request count stays at one:
query {
a1: user(email: "alice@corp.com") { exists }
a2: user(email: "bob@corp.com") { exists }
a3: user(email: "charlie@corp.com") { exists }
}This is GQL-009: batch operation abuse.
Why HTTP-layer defenses fail: Rate limiting at the IP/request level sees one request per second and passes it. WAF rules looking for credential stuffing patterns count one authentication attempt. The batching layer is entirely invisible to any defense that reasons at the HTTP request granularity.
Defensive controls for batch attacks:
- Implement per-operation rate limiting inside the GraphQL execution layer, not at the HTTP layer. Count individual resolver invocations, not HTTP requests.
- Set a maximum batch size (Apollo Server:
allowBatchedHttpRequests: truewith amaxBatchSizeconfig). Ten operations per batch is a reasonable ceiling for legitimate clients. - Normalize error messages so that authentication failures return identical responses regardless of whether the email exists. This eliminates the enumeration signal even when batching succeeds.
- Use query complexity analysis to penalize high-alias queries.
3. Query Depth and Resolver Explosion: DoS from the Query Layer
3.1 Circular Type Relationships and Unbounded Depth
GraphQL schemas frequently model relationships that reflect the application domain. A social graph might expose:
type User {
friends: [User]
}This circular type definition — a User who has friends who are also User objects who also have friends — allows a client to construct a query of arbitrary depth:
{
user(id: "1") {
friends {
friends {
friends {
friends {
friends { id name email }
}
}
}
}
}
}Each nesting level may trigger a separate database query in a naive resolver implementation. A depth-10 query against a social graph with a branching factor of 50 (each user has 50 friends) attempts to load 50¹⁰ = ~97 trillion user objects. The server will crash or timeout far before that, but the point is made: one HTTP request containing ~300 bytes of query text can consume unbounded server resources.
This is GQL-007: unbounded query depth.
3.2 Resolver Math: Exponential Expansion
The resolver execution model makes this precise. For a query of depth d against a list field with average branching factor b, the number of resolver invocations is:
R(d, b) = (b^(d+1) - 1) / (b - 1)For b=10 and d=8, that is approximately 111 million resolver calls. Each call typically involves at least one database round-trip. A single request can saturate a connection pool within milliseconds.
GQL-008 extends this to field duplication attacks — queries that repeat the same non-list field thousands of times using aliases to inflate query complexity without exploiting circular types:
{
user(id: "1") {
f1: email f2: email f3: email f4: email f5: email
# ... repeated 995 more times
}
}The email resolver is called 1000 times for a single user. At scale, this is a viable CPU exhaustion vector even against schemas with no circular relationships.
Defensive controls for depth and complexity attacks:
- Enforce a maximum query depth at parse time, before any resolver executes. A limit of 7–10 is appropriate for most production APIs.
- Implement query complexity scoring. Assign a cost to each field, multiply by list branching factors, reject queries that exceed a total complexity budget. Libraries:
graphql-query-complexity(Node.js),graphql-cost-analysis. - Use query timeout enforcement as a last line of defense — kill any execution context that runs longer than a configured threshold.
- Consider query whitelisting (persisted queries) for high-security APIs that do not need to support arbitrary client-composed queries.
4. Why Traditional API Security Tools Miss These Attacks
Conventional DAST scanners, WAFs, and API gateways are built around the REST mental model. They reason about HTTP verbs, URL paths, and header values. A GraphQL API, by contrast, routes every request through a single endpoint (POST /graphql) and encodes all semantic meaning in the request body.
The practical consequences:
- A WAF inspecting the URL
POST /graphqlsees nothing unusual and passes the request. - A DAST scanner fuzzing path parameters finds no parameters to fuzz — there are no URL paths.
- A rate limiter counting requests per IP counts one request whether the body contains one operation or 500.
- An intrusion detection system looking for SQL injection in URL parameters completely misses injection payloads embedded in GraphQL arguments.
- No conventional tool parses the query document to evaluate depth, complexity, or alias count.
Additionally, GraphQL error messages are far more verbose than REST errors by convention, and the field suggestion mechanism — described above — exists precisely to assist developers. The same verbosity that improves developer experience becomes an enumeration primitive in adversarial hands.
5. Defensive Controls: A Summary by Attack Class
Attack ClassIDPrimary ControlSecondary ControlIntrospection exposureGQL-001Disable introspection in productionRequire authentication before query parsingField suggestion harvestingGQL-002Suppress suggestion messages in errorsMonitor Cannot query field error ratesBatch operation abuseGQL-008Per-operation rate limiting in execution layerMaximum batch size enforcementUnbounded query depthGQL-006Parse-time depth limiting (max 7–10)Query timeout enforcementResolver explosionGQL-007Query complexity scoring with budget limitsAlias count limits
The key architectural principle is that defenses must operate at the GraphQL execution layer, not at the HTTP transport layer. Any control that cannot inspect and understand a parsed GraphQL query document is structurally blind to this attack surface.
6. A Working GraphQL Attack Taxonomy: GQL-001 through GQL-012
As GraphQL security research matures, a consistent vocabulary for classifying vulnerabilities helps both offensive researchers writing reports and defensive teams triaging findings. The following taxonomy covers twelve attack classes observed across production GraphQL deployments:
GQL-001 — Unauthenticated Introspection Exposure The schema is fully disclosed via the introspection API without requiring authentication.
GQL-002 — Introspection Bypass via Alternative Endpoints or Headers Introspection remains accessible through non-standard paths, HTTP method variations, or header manipulation even when the primary control is in place.
GQL-003 — Field Suggestion Harvesting Schema enumeration via error-message analysis, reconstructing type and field names without triggering introspection.
GQL-004 — GraphQL Playground / IDE Exposure Interactive query environments (GraphiQL, Apollo Sandbox, GraphQL Playground) are accessible in production, providing both schema documentation and an execution interface to unauthenticated callers.
GQL-005 — Stack Trace Disclosure Unhandled resolver errors return full stack traces in the errors extension block, leaking internal file paths, library versions, and application structure.
GQL-006 — Sensitive Field Exposure Fields carrying internally sensitive data — tokens, hashes, internal identifiers, audit metadata — are present in the schema and resolvable without elevated authorization.
GQL-007 — Unbounded Query Depth / Recursive Type Exhaustion Circular type relationships exploited to generate exponential resolver invocation counts from a small query payload.
GQL-008 — Query Complexity Abuse / Field Duplication DoS Alias flooding or high-weight field repetition to exhaust CPU or database connection pool resources within a single request.
GQL-009 — Batch Operation Abuse Array batching or alias batching to bypass per-request rate limiting, enabling credential stuffing or large-scale enumeration at HTTP request cost of one.
GQL-010 — GET-Based Query Execution The server accepts full GraphQL query execution over HTTP GET, bypassing CSRF protections and enabling query injection via URL.
GQL-011 — SQL Injection via GraphQL Arguments (Error-Based) SQL injection payloads delivered through GraphQL field arguments surface database error messages in the response, confirming exploitability and leaking schema structure.
GQL-012 — Unauthenticated Mutations State-changing operations — account creation, password reset, data modification — are executable without any authentication token.
This taxonomy covers the categories most frequently encountered in production systems and bug bounty engagements. GraphQL subscriptions introduce an additional denial-of-service surface not covered here, and multipart file upload mutations carry their own vulnerability class — both are natural extensions as the taxonomy evolves.
GQLS: An Open-Source Scanner Built Around This Taxonomy
GQLS-CLI is a GraphQL-specific security scanner written in Go, built to operationalize the twelve attack classes documented above. Unlike general-purpose API scanners, it understands GraphQL semantics at the query and schema level — every check is designed around the resolver model, not HTTP surface heuristics.
The tool accepts targets via a --url flag, custom --header flags for authentication, or a raw curl command pasted directly from browser DevTools. That last input mode is practically useful: you can copy an authenticated request straight out of Chrome Network tab and hand it to the scanner without manually extracting tokens or reconstructing headers.
# Minimal scan
gqls scan --url https://api.example.com/graphql
# Authenticated scan with a bearer token
gqls scan \
--url https://api.example.com/graphql \
--header 'Authorization: Bearer eyJ...'
# Paste a curl command from DevTools directly
gqls scan --curl 'curl https://api.example.com/graphql \
-H "Authorization: Bearer eyJ..." \
--data-raw '"'"'{"query":"{ __typename }"}'"'"''Each finding maps to a GQL-00x identifier and includes a ready-to-run reproduction curl command, a description of attacker impact, and a remediation recommendation. Output formats cover terminal, txt, json, and SARIF 2.1.0 — the SARIF output integrates directly into GitHub Advanced Security and similar CI/CD pipelines, with severity mapped to SARIF levels (CRITICAL/HIGH → error, MEDIUM → warning).
The check table as it stands today:
IDNameSeverityGQL-001Introspection EnabledHIGHGQL-002Introspection Bypass via __typeHIGHGQL-003Schema Exposed via Field SuggestionsMEDIUMGQL-004GraphQL Playground ExposedMEDIUMGQL-005Stack Trace / Debug Info in ErrorsMEDIUMGQL-006Sensitive Fields Exposed in SchemaINFOGQL-007Query Depth Limit Not EnforcedHIGHGQL-008Query Complexity Limit Not EnforcedHIGHGQL-009Batch Query AbuseHIGHGQL-010GraphQL GET Queries EnabledLOWGQL-011SQL Injection (Error-Based)CRITICALGQL-012Unauthenticated Access to MutationsHIGH
The CI integration story is clean: --fail-on HIGH causes the process to exit 1 when any finding meets or exceeds that severity threshold, which drops naturally into any pipeline gate. False positives can be suppressed by fingerprint in a gqls.yaml config file, which also supports environment variable interpolation for secrets — useful when $API_TOKEN lives in a secrets manager rather than a dotfile.
The project is at v0.1.0, MIT-licensed, and written entirely in Go with no runtime dependencies beyond the binary. The check registry is designed to make adding new detectors straightforward as the taxonomy grows.
Conclusion
GraphQL's power comes from giving clients expressive control over queries. That expressiveness is precisely what makes the attack surface differ structurally from REST. Schema self-description enables enumeration without guesswork. Batching inverts the economics of rate limiting. Recursive type resolution converts a small HTTP payload into an arbitrarily expensive server operation.
None of these are implementation bugs in a specific library. They are emergent properties of the GraphQL specification itself, which means they appear across every language ecosystem and framework. Defending against them requires controls that understand GraphQL semantics — parse-time analysis, execution-layer rate limiting, and schema-aware error sanitization. The taxonomy above is a starting point for building that understanding into scanners, security reviews, and bug bounty report templates.
Filed under: GraphQL, API Security, AppSec Research, Bug Bounty, DAST