Introspection as Reconnaissance
Introspection as Reconnaissance
Introspection is a feature you ship to attackers for free. One query returns the whole type graph:
{
__schema {
queryType { name }
types {
name
fields {
name
args { name type { name } }
}
}
}
}
What that hands over:
| Revealed | Why it helps an attacker |
|---|---|
Field names like adminImpersonate |
Targets that no UI will ever link to |
Argument names (accountId vs userId) |
Hints at storage shape and id formats |
| Deprecated fields | Recently retired behavior often still wired up |
| Full type graph and nullability | Where expensive queries can be built |
Directives such as @hasRole |
Which fields the defenders considered sensitive |
None of this is a vulnerability on its own — it is reconnaissance that shortens everything after it. The same machinery powers GraphiQL, code generation, and schema-aware linting, which is why the answer is rarely "delete introspection everywhere." The pragmatic split: enabled in development and staging where tooling lives, restricted in production where the schema is already published to your own clients through documentation.
From map to exploit
The map compresses everything that follows it into minutes:
- Choose targets. Filter the field list for
admin,internal,impersonate,export— names no user interface will ever offer. - Learn the shape. Argument types and nullability show which inputs are required and whether a field can return partial data, so the first crafted request is valid instead of one of twenty guesses.
- Check the defenses. Seeing
@hasRoleon a field confirms the defenders considered it sensitive, which makes it a more interesting target, not a safer one.
Without the map an attacker does the same work by guessing against validation errors — slower, and noisy in your logs. That asymmetry, minutes versus hours, is the actual trade: production introspection control raises the cost of reconnaissance while staging keeps it open for GraphiQL, code generation, and schema-aware linting.
Check what your gateway exposes too: introspection may be disabled on the API server while an analytics subgraph or a public explorer endpoint still answers __schema. For the mechanics of the query itself, see introspection mechanics.
Turning Off the Map Without Fooling Yourself
Turning Off the Map Without Fooling Yourself
The standard control is a validation rule that rejects introspection documents before execution:
const { specifiedRules, NoSchemaIntrospectionCustomRule } = require('graphql');
const server = new ApolloServer({
schema,
validationRules: [
...specifiedRules,
...(process.env.NODE_ENV === 'production'
? [NoSchemaIntrospectionCustomRule]
: []),
],
});
Running it as a validation rule means the document fails before a single resolver executes, and gating it on environment keeps GraphiQL and codegen working against non-production endpoints.
Now the part teams skip. Disabling introspection is not authorization. It removes free documentation; it does not remove anything else:
- Queries still execute. An attacker who guesses
{ admin { secrets } }gets an answer if the field exists and has no check. Field-level authorization is a separate control — see role-based field access. - Validation errors still describe your schema. With introspection off,
Cannot query field "invoices" on type "Query"still confirms or denies the existence of a field, and a well-formed error naming the containing type leaks structure. Do not treat "unknown field" errors as harmless noise in your logs; they are a probing signal worth rate-limiting. - The schema is probably already public. Type names live in your client bundle, your error messages, your docs, and your git history.
Treat it as one layer in a stack: hide the map, authorize every field, watch for probing patterns, and bound what a document may ask for. Controls stack honestly only when each one is honest about what it stops — query depth limits bound shape, authorization bounds identity, and introspection control bounds convenience for the attacker.
Allowlists, Depth Rules, and Edge Controls
Allowlists, Depth Rules, and Edge Controls
Once the schema is not the contract, the documents can be. A persisted-query allowlist accepts only pre-registered operations, which shrinks the attack surface to behavior you have reviewed:
// manifest.json: sha256 -> operation, shipped with each client release
const { createHash } = require('crypto');
function isAllowed(document) {
const hash = createHash('sha256').update(document, 'utf8').digest('hex');
return allowlist.has(hash); // unknown hash -> reject before parsing
}
Know which variant you are buying:
| Scheme | Server accepts | What it stops |
|---|---|---|
| Automatic persisted queries (APQ) | hash + body, falls back to the full query | bandwidth only — arbitrary queries still run |
| Persisted query allowlist | registered hashes only | any operation you did not review |
APQ with an open fallback is a cache, not a control; the allowlist is the security property. Ship the manifest with the client, fail closed on unknown hashes, and keep a server-side path for operations your own tooling needs.
Depth and cost rules bound the shape of whatever documents are allowed — a depth cap stops recursive relationship crawls, a cost budget prices aliases and list arguments. See cost analysis budgets for pricing a document. Then push checks to where they are cheapest: an edge or WAF layer can reject oversized bodies, deep documents, and probing patterns before origin compute is spent, while rate limits absorb volume.
Ordering matters when you assemble these: reject at the edge (shape, allowlist, rate), validate at the server (schema rules, depth, introspection), authorize at the field (roles and tenant scope). Each layer assumes the one above may have been bypassed — which is the honest way to stack controls.
Practice Problems
Configure a production GraphQL server so introspection is rejected, only allowlisted operation hashes execute, and documents beyond a depth of 8 fail — while staging keeps introspection for GraphiQL. State precisely what each control does not stop.
Example:
Input: Production request body: { "extensions": { "persistedQuery": { "sha256Hash": "<unknown>" } } }
Output: Rejected before parsing with a persisted-query-not-registered error; no resolver executes
The hash lookup fails closed, so an unregistered document never reaches validation or execution — this is the property APQ-with-fallback does not provide, since it would have accepted the body and run it.
Optimal Solution — O(1) hash lookup per request time, O(A) for the allowlist manifest space
Compose environment-gated validation rules for introspection and depth with a pre-execution plugin that enforces the hash allowlist, failing closed at each stage
const { createHash } = require('crypto');
const { specifiedRules, NoSchemaIntrospectionCustomRule, DepthLimitRule } = require('graphql');
const isProd = process.env.NODE_ENV === 'production';
const server = new ApolloServer({
schema,
validationRules: [
...specifiedRules,
...(isProd ? [NoSchemaIntrospectionCustomRule] : []),
DepthLimitRule(8),
],
plugins: [
{
async requestDidStart() {
return {
async didResolveOperation({ request, document }) {
if (!isProd) return;
const hash = createHash('sha256')
.update(request.query ?? '', 'utf8').digest('hex');
if (!allowlist.has(hash)) {
logger.warn('rejected-unregistered-operation', { hash });
throw new Error('Operation not registered'); // fail closed
}
},
};
},
},
],
}); Edge Cases:
- Staging receives the same allowlist check? No — gate only the security controls, keep tooling open outside production
- Client release without updated manifest: pre-register new operations before rollout, and monitor rejects by hash
- Depth rule and cost budget together: depth stops recursion, cost stops wide alias fan-out — one does not cover the other
Quiz
1. You disable introspection in production. What can an attacker still do?
2. Which control guarantees that only operations you reviewed can reach execution?
3. With introspection disabled, what does an "unknown field" validation error still reveal?
Flashcards
Question
What introspection exposes
Click to reveal answer
Answer
Field and type names, arguments, nullability, deprecations, and directives — a complete map for crafting precise queries.
Question
Introspection vs authorization
Click to reveal answer
Answer
Disabling introspection hides documentation only; queries still execute, so per-field authorization is still required.
Question
APQ vs persisted allowlist
Click to reveal answer
Answer
APQ caches by hash and falls back to arbitrary queries (bandwidth saving); an allowlist accepts only registered hashes (a real control).
Question
Residual leak after disabling introspection
Click to reveal answer
Answer
Unknown-field validation errors still confirm whether a field exists — treat probing patterns as a rate-limitable signal.
Question
Layered request defenses
Click to reveal answer
Answer
Edge: allowlist, body size, rate limit → server: validation, depth, cost → field: role and tenant authorization.
Revision Notes
Key Takeaways
- 1. Introspection is reconnaissance: it shortens an attack but is not itself the vulnerability
- 2. Hiding the schema does not restrict execution — every field still needs an authorization decision
- 3. A hash allowlist is the control that limits what can run; depth, cost, and rate limits bound how much
Interview Tips
- • Give the honest version: "disabling introspection is hygiene, not authorization" — interviewers are testing for exactly that distinction
- • Describe your production stack in layers and say what each layer would miss if the layer above failed
Cheat Sheet
Schema Exposure Cheat Sheet
Introspection off in production
validationRules: [
...specifiedRules,
...(isProd ? [NoSchemaIntrospectionCustomRule] : []),
]
What it stops: free schema documentation · What it does not: guessed queries, unknown-field error probing
Document controls
const hash = createHash('sha256').update(doc, 'utf8').digest('hex');
if (!allowlist.has(hash)) reject(); // fail closed, before parsing
| Scheme | Property |
|---|---|
| APQ with fallback | bandwidth only |
| Persisted allowlist | only reviewed operations run |
Defense order: edge (hash, body size, rate) → server (validation, depth/cost, no introspection) → field (role, tenant)
Keep enabled: staging introspection for GraphiQL and codegen; publish the schema to your own clients deliberately