notes 2026-07-24 · 6 min read

GraphQL for Pentesters: Schema, Resolvers and Batching

GraphQL pentesting: schema discovery without introspection, resolver level authorization, aliases and batching, and query complexity limits.

GraphQL for Pentesters: Schema, Resolvers and Batching

GraphQL puts an entire API behind one endpoint and lets the client choose the shape of the response.

That is convenient for developers.

It also changes how security testing works.

With REST, you enumerate many routes.

With GraphQL, much of the surface lives in the schema, resolvers, and relationships between objects.

The examples below assume a lab or an authorized penetration test.

GraphQL attack surface

One endpoint can be misleading

A GraphQL deployment may expose only:

1
POST /graphql

That looks like a small surface.

Inside the request body, the client chooses the query, fields and arguments. It also chooses related objects.

Example:

1
2
3
4
5
6
7
8
9
10
query {
  project(id: "42") {
    id
    name
    owner {
      id
      email
    }
  }
}

One route now represents many logical operations.

Path fuzzing alone will not map the API.

Discover the schema first

If introspection is enabled, the server can describe its own types, queries, and mutations.

A small request:

1
2
3
4
5
6
7
8
9
10
query {
  __schema {
    queryType {
      name
    }
    mutationType {
      name
    }
  }
}

Tools can reconstruct the full schema from introspection.

That speeds up testing significantly.

Exposed introspection in production is often a hardening concern rather than compromise by itself.

Its offensive value comes from what it reveals.

Administrative mutations.

Deprecated fields.

Internal types.

Optional arguments.

Objects the frontend never uses.

Disabled introspection does not end enumeration

GraphQL implementations often suggest names when a field is wrong.

You send:

1
2
3
4
5
query {
  usr {
    id
  }
}

The server may respond with something like:

1
Did you mean "user"?

That behavior helps schema recovery.

Errors may also reveal type and resolver names.

Wordlist-based enumeration becomes much more effective when the server provides hints.

Authorization belongs in the correct resolver

Consider:

1
2
3
4
5
6
7
8
9
query {
  project(id: "42") {
    id
    name
    owner {
      email
    }
  }
}

The application checks that the requester may access the project.

Then the owner.email resolver returns the field without checking whether that data is allowed in this context.

Authorization needs to follow nodes and relationships.

A check on the parent resolver does not always protect child fields.

Authorization across nodes and edges

BOLA still exists

GraphQL changes syntax.

Object authorization remains the same security problem.

Account A sends:

1
2
3
4
5
6
query {
  invoice(id: "inv_9001") {
    total
    customerEmail
  }
}

If inv_9001 belongs to account B and the resolver fails to validate ownership, that is Broken Object Level Authorization.

IDs may be UUIDs, Base64, or Relay Global IDs.

Opaque formatting does not replace access control.

Mutations deserve priority

Queries read.

Mutations change state.

Look for names such as:

1
2
3
4
5
6
updateUser
deleteProject
inviteMember
changeRole
transferOwnership
createApiKey

Then compare behavior across users.

An administrative mutation may be hidden from the frontend and still remain available in the schema.

The backend must enforce authorization.

Hiding the button has never been a security boundary.

Aliases multiply work inside one request

GraphQL supports aliases.

Example:

1
2
3
4
5
query {
  a:user(id:"1"){email}
  b:user(id:"2"){email}
  c:user(id:"3"){email}
}

One request carries several logical lookups.

That can bypass rate limits implemented per HTTP request.

It also speeds up object enumeration.

If the gateway counts one request while GraphQL executes one hundred resolvers, the rate limit is measuring the wrong unit.

Batching creates a similar problem

Some implementations accept multiple operations in one request.

That can test several credentials, OTP values, or object IDs at once.

Risk depends on what the gateway and GraphQL engine consider an operation.

Good rate limiting may need to count operation cost rather than just HTTP connections.

Query complexity matters for availability

GraphQL allows deep nesting.

A query can ask for:

1
2
3
4
5
6
7
8
9
10
11
12
13
query {
  organizations {
    projects {
      members {
        projects {
          members {
            id
          }
        }
      }
    }
  }
}

Depending on the schema, this can create a large amount of backend work.

Depth limits help.

Cost analysis helps more when fields have very different costs.

Pagination needs limits too.

first: 1000000 deserves testing if the schema accepts it.

Sensitive fields may already exist in the type

Consider:

1
2
3
4
5
6
7
type User {
  id: ID!
  name: String!
  email: String!
  passwordResetToken: String
  internalNotes: String
}

The frontend requests only id and name.

The pentester requests the rest.

If the resolver returns them, the API has excessive data exposure or broken property-level authorization.

GraphQL makes this test direct because the client chooses fields.

GET can introduce another surface

Some servers accept queries over GET:

1
/graphql?query=...

That may interact differently with caches, logs, CSRF, and observability tools.

I test which methods are accepted and which operations work through each one.

Mutation over GET deserves special attention.

A testing method that works

Find the endpoint first.

Then try introspection.

If it fails, use errors and frontend traffic to reconstruct operations.

Map queries and mutations.

Create two accounts.

Cross object IDs.

Cross fields.

Then test aliases, batching and depth. Finish with cost and rate limiting.

GraphQL testing sequence

Remediation

Authorization should live close to the data and action.

A resolver returning a sensitive object should validate context.

A mutation should validate role and ownership.

Limit depth and complexity.

Control batching.

Apply operation-aware rate limiting where required.

Disabling introspection can reduce discovery in production.

It does not fix a vulnerable resolver.

Persisted queries do not replace authorization

Some APIs use persisted queries. The client sends a hash or identifier instead of the full query text.

That can reduce arbitrary query input in some flows and improve operational control. It still does not make an unsafe mutation safe.

If a privileged operation identifier can be discovered and its resolver does not validate role, the weakness remains.

I treat persisted queries as transport control and an operation allowlist. Authorization still belongs in the resolver and domain logic.

Subscriptions add a long-lived connection

GraphQL subscriptions commonly use WebSocket.

Authentication may happen when the connection opens. Authorization still needs to be correct when the client chooses which topic or object to follow.

A user may be allowed to open the socket without being allowed to receive events for projectId=42.

I also test what happens when a session expires after the connection is already open. Some implementations validate the token once and keep the channel alive for too long.

Federation creates authorization boundaries between services

Apollo Federation and similar architectures split the graph across subgraphs.

That adds another trust boundary.

The gateway may authenticate the user. A subgraph receives internal headers and assumes the gateway already made every security decision.

If that subgraph is also reachable directly, internal headers may become a bypass path.

During a pentest I identify the subgraphs, check whether they accept traffic outside the gateway and trace which identity data crosses service boundaries.

GraphQL centralizes the client experience. Authorization may still be distributed across several backends.

References

OWASP Cheat Sheet Series, GraphQL Cheat Sheet.

PortSwigger Web Security Academy, GraphQL API vulnerabilities.

GraphQL Foundation, GraphQL Specification.

This post is licensed under CC BY 4.0 by the author.