GraphQL API Security: Introspection, Deep Queries and the Authorization Gap
GraphQL hands clients a flexible query language — and hands attackers a map of your whole schema if you let it. Here is how introspection, query abuse and missing authorization go wrong, and how to lock the endpoint down.
{ __schema { types { name } } }On this page
GraphQL replaces a fleet of fixed REST endpoints with a single flexible one: the client describes exactly the data it wants, and the server resolves it. That flexibility is the appeal — and the risk. The same query language that lets a front-end fetch precisely what it needs lets an attacker ask for things the developers never meant to expose, as deeply and as often as they like.
GraphQL security problems are rarely a single dramatic bug. They are a cluster of defaults that are convenient in development and dangerous in production: an introspectable schema, no limit on query depth or cost, and resolvers that fetch data without checking whether this caller is allowed to see this object.
How it happens
The root cause is treating GraphQL as if the schema were a private contract. It is not — by default, any client can ask the server to describe itself. Introspection is built into the specification, and a typical setup leaves it on everywhere:
const server = new ApolloServer({ typeDefs, resolvers, introspection: true, });
With introspection enabled, the entire type system is queryable. A second, deeper problem hides in the resolvers. A field that looks innocent often fetches an object by id with no authorization check:
const resolvers = {
Query: {
user: (_, { id }) => db.users.findById(id),
},
};
Nothing here asks "is the caller allowed to read user id?" The resolver trusts the argument. And because GraphQL types reference each other, a single request can traverse those relationships to arbitrary depth — user → posts → author → posts → ... — with no ceiling on how much work the server will do.
A concrete attack
First the attacker maps the schema. A single introspection query returns every type, query and mutation, including fields that were never documented:
{ __schema { queryType { name } mutationType { name } types { name fields { name } } } }
The response reveals an adminUsers query and a resetPassword mutation the public front-end never calls. Now the attacker probes for broken object-level authorization, walking ids that are not theirs:
{ user(id: 1) { name email passwordResetToken } }
If the resolver returns another account's data, that is an IDOR straight through the API. Even with introspection disabled, error messages leak: a misspelled field often triggers a "did you mean…?" suggestion that rebuilds the schema one guess at a time. And a single deeply nested query can exhaust the server:
{ user(id:1){ posts{ author{ posts{ author{ posts{ id } } } } } } }
Each level multiplies the work the resolvers do, turning one request into a denial-of-service.
GraphQL does not enforce authorization for you. Hiding a field from the documentation is not access control — if a resolver will fetch an object, an attacker who can name that object can reach it.
What an attacker gains
The combination of an open schema and weak per-field controls yields:
- Full schema disclosure — every type, query, mutation and hidden field, even when nothing is documented.
- Broken object-level access — reading or mutating records that belong to other users via guessable ids.
- Classic injection through resolver arguments — SQL, NoSQL or command injection where an argument reaches a backend unsanitized.
- Denial of service — deeply nested or aliased queries that amplify one request into massive server work.
The fix: lock the schema, bound the query, authorize every field
GraphQL needs defenses at three layers: control who can see the schema, control how expensive a query may be, and enforce authorization inside the resolvers themselves.
const server = new ApolloServer({ typeDefs, resolvers, introspection: process.env.NODE_ENV !== 'production', validationRules: [depthLimit(7), createComplexityLimitRule(1000)], }); const resolvers = { Query: { user: (_, { id }, ctx) => { if (!ctx.user) throw new ForbiddenError('auth required'); if (!ctx.can('read', 'user', id)) throw new ForbiddenError('denied'); return db.users.findById(id); }, }, };
The essential controls:
- Disable introspection in production so the schema is not handed out for free, and suppress field suggestions in error responses.
- Enforce query depth and complexity limits to cap how much work any single request can demand.
- Apply per-resolver, object-level authorization — every field that returns data must check that the current caller may see this object, not just that they are logged in.
- Validate and parameterize resolver inputs exactly as you would any other untrusted argument, and rate-limit the endpoint to blunt batching and aliasing abuse.
How SelfSec proves this one
Scanner behaviour- Module
- GraphQL
- How it probes
- Introspection recovery, aliased and deeply nested query probes, and object-level authorization checks across resolvers; captured GraphQL-WS frames feed the same attack workers as HTTP.
- How it confirms
- An authorization gap has to return another principal's object, and a cost finding has to move response time measurably against the baseline.
Reported as CWE-639 · ATT&CK T1190 · SARIF 2.1.0
A reflected response alone is not enough to promote a GraphQL finding — the classical engine has to confirm the behaviour before it reaches the report. See the detection engine
SelfSec is intended strictly for authorized security testing of systems you own or are explicitly permitted to assess.
Do both things about GraphQL
SelfSec covers this class from both sides — the scanner confirms it in your own app, the firewall blocks it in front of your origin while the fix ships.