GraphQL Introspection: Mapping a Hidden Schema in One Query
Introspection is built into every GraphQL server, and by default it hands any client a complete map of your type system. Here is how a single query dumps every query, mutation and hidden field, how field suggestions leak the schema even when introspection is off, and how to lock it down.
Did you mean "user"?On this page
GraphQL ships with a feature that no REST API has by default: the schema can describe itself. Introspection is part of the specification, and it powers the tooling everyone loves — autocomplete in GraphiQL, generated client types, schema diffs. The same machinery that feeds your IDE feeds an attacker, because introspection makes no distinction between a developer exploring the API and an adversary inventorying it.
The result is that a single unauthenticated request can return your entire type system — every query, every mutation, every field, including the ones you assumed were private because nothing documents them. Treating the schema as a secret is not a security control, and GraphQL was never designed to keep it one.
How it happens
Introspection is enabled by default in most GraphQL servers, and a typical production setup never turns it off:
const server = new ApolloServer({ typeDefs, resolvers, introspection: true, });
With this in place, two reserved meta-fields — __schema and __type — are queryable by anyone who can reach the endpoint. They expose the full type system: object types, input types, enums, interfaces, every field and argument, and the descriptions attached to them. Crucially, they also expose fields marked @deprecated and any mutation that exists in the schema, regardless of whether the public front-end ever calls it.
The second, subtler leak survives even when you disable introspection. To improve developer experience, GraphQL servers return "Did you mean…?" suggestions when a query names a field that does not exist. Those suggestions are computed against the real schema, so an attacker can rebuild the type system one guess at a time without ever touching __schema.
A concrete attack
The attacker starts with the canonical introspection query. A single request returns every type, query and mutation, with their fields and arguments:
{
__schema {
queryType { name }
mutationType { name }
types {
name
fields(includeDeprecated: true) { name }
}
}
}
The response includes far more than the documented surface. Among the mutations is one the public client never references:
{
"data": {
"__schema": {
"mutationType": { "name": "Mutation" },
"types": [
{ "name": "Mutation", "fields": [
{ "name": "login" },
{ "name": "impersonateUser" },
{ "name": "rotateServiceCredential" }
]}
]
}
}
}
impersonateUser and rotateServiceCredential were meant to be internal. They are not, because the resolver behind each is reachable the moment the attacker can name it. With the schema mapped, probing a sensitive mutation is mechanical:
mutation { impersonateUser(id: "1") { sessionToken } }
Now suppose introspection has been disabled. The attacker falls back to field suggestions, sending a deliberately misspelled field and reading the hint that comes back:
POST /graphql HTTP/1.1
Content-Type: application/json
{"query":"{ usr(id:1){ id } }"}
The server replies with Cannot query field "usr" on type "Query". Did you mean "user"?, confirming a user query exists. Repeated across guessed names, this reconstructs the schema without a single introspection call.
Hiding a field from your documentation is not access control. If the schema can describe itself — or the error messages will describe it for you — an attacker who can name an object can reach it.
Why it matters
A fully mapped schema removes the attacker's guesswork. Every other GraphQL weakness — broken object-level authorization, injectable resolver arguments, expensive nested queries — gets cheaper once the type system is known, because the attacker no longer probes blindly. Hidden admin queries and privileged mutations are exactly the targets that schema obscurity was quietly relied upon to protect, and obscurity is the first thing introspection strips away.
The fix: disable introspection, suppress suggestions, authorize regardless
Lock the schema down in production while keeping it available where developers need it, and never let the schema's visibility stand in for authorization.
const isProd = process.env.NODE_ENV === 'production'; const server = new ApolloServer({ typeDefs, resolvers, introspection: !isProd, formatError: (formattedError) => { if (isProd && /Did you mean/.test(formattedError.message)) { return { ...formattedError, message: 'Bad Request' }; } return formattedError; }, });
The essential controls:
- Disable introspection in production so
__schemaand__typeare not handed out for free, while keeping it on in development and staging. - Turn off field suggestions in production error formatting, so a misspelled field cannot leak real field names one guess at a time.
- Never rely on schema obscurity for authorization — assume the attacker has the full schema and enforce object-level checks in every resolver, so an exposed
impersonateUseris still useless without the right permissions.
How SelfSec finds it
SelfSec crawls the target, detects GraphQL endpoints, and sends __schema and typed __type queries to see whether the server describes itself. When introspection is disabled, it falls back to field-suggestion probing — sending near-miss field names and keying on the "Did you mean…?" patterns that rebuild the schema indirectly. It confirms an exposed type system from the real response rather than guessing from the URL, and flags the deprecated and undocumented mutations the mapping reveals. The whole scan runs locally on 127.0.0.1, your schema and scan data stay on your machine, and each finding ships with the exact request to reproduce it.
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.