GraphQL gives attackers something REST APIs do not: a built-in way to discover your entire schema. If introspection is enabled in production, you have handed them the blueprint.
The Introspection Problem
GraphQL introspection lets anyone query your schema — every type, field, mutation, and relationship:
{
__schema {
types {
name
fields {
name
type {
name
}
}
}
}
}This returns your entire API surface. An attacker now knows every query, mutation, argument, and type — including internal fields like isAdmin, passwordHash, or internalNotes that you never intended to expose.
Fix: Disable introspection in production.
// Apollo Server
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== "production",
});# Strawberry (Python)
import strawberry
from strawberry.extensions import DisableIntrospection
schema = strawberry.Schema(
query=Query,
extensions=[DisableIntrospection()], # blocks __schema and __type
)Keep introspection enabled in development. Disable it in production. No exceptions.
Query Depth Attacks
GraphQL's nested query structure enables denial-of-service through deeply nested queries:
# This could resolve millions of database rows
{
users {
posts {
comments {
author {
posts {
comments {
author {
posts {
# ...20 levels deep
}
}
}
}
}
}
}
}
}Fix: Limit query depth.
// Using graphql-depth-limit
import depthLimit from "graphql-depth-limit";
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(5)], // reject queries deeper than 5 levels
});A depth limit of 5-7 covers most legitimate use cases. If a query needs to be deeper, restructure your schema.
Query Complexity Analysis
Depth limiting alone is not enough. A shallow but wide query can be equally expensive:
# Depth = 2, but fetches every user and all their data
{
users(first: 10000) {
email
posts(first: 1000) {
title
body
}
}
}Fix: Assign complexity scores to fields and reject queries that exceed a threshold.
// Using graphql-query-complexity
import { createComplexityRule, simpleEstimator, fieldExtensionsEstimator } from "graphql-query-complexity";
const complexityRule = createComplexityRule({
maximumComplexity: 1000,
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 }),
],
onComplete: (complexity) => {
console.log("Query complexity:", complexity);
},
});Mark expensive fields in your schema:
const resolvers = {
Query: {
users: {
extensions: { complexity: ({ args }) => args.first * 10 },
resolve: (_, args) => fetchUsers(args),
},
},
};Batching Attacks
GraphQL supports sending multiple operations in a single HTTP request. Attackers abuse this for brute-force attacks:
[
{ "query": "mutation { login(email: \"admin@co.com\", password: \"pass1\") { token } }" },
{ "query": "mutation { login(email: \"admin@co.com\", password: \"pass2\") { token } }" },
{ "query": "mutation { login(email: \"admin@co.com\", password: \"pass3\") { token } }" }
]One HTTP request, 1000 login attempts. Your rate limiter counts it as one request.
Fix: Limit batch size or disable batching entirely.
// Apollo Server 4 — disable batching
const server = new ApolloServer({
typeDefs,
resolvers,
allowBatchedHttpRequests: false,
});If you need batching, limit it:
const server = new ApolloServer({
typeDefs,
resolvers,
allowBatchedHttpRequests: true,
// Custom plugin to limit batch size
plugins: [{
async requestDidStart() {
return {
async didResolveOperation(ctx) {
// Rate limit per operation, not per HTTP request
}
};
}
}],
});Field-Level Authorization
A common mistake: checking auth at the query level but not at the field level.
type User {
id: ID!
name: String!
email: String! # any authenticated user can see this
ssn: String! # only the user themselves should see this
salary: Float! # only HR should see this
}Fix: Implement field-level auth in resolvers or use directives.
const resolvers = {
User: {
ssn: (parent, args, context) => {
if (context.user.id !== parent.id) {
throw new ForbiddenError("Access denied");
}
return parent.ssn;
},
salary: (parent, args, context) => {
if (!context.user.roles.includes("HR")) {
throw new ForbiddenError("Access denied");
}
return parent.salary;
},
},
};Or use a schema directive:
directive @auth(requires: Role!) on FIELD_DEFINITION
type User {
id: ID!
name: String!
ssn: String! @auth(requires: SELF)
salary: Float! @auth(requires: HR)
}Persisted Queries
Instead of accepting arbitrary queries, whitelist only the queries your frontend actually uses:
// Client sends a hash instead of the full query
POST /graphql
{
"extensions": {
"persistedQuery": {
"sha256Hash": "abc123..."
}
},
"variables": { "id": "42" }
}The server looks up the hash in a pre-registered query map. Unknown queries are rejected. This eliminates entire classes of attacks because attackers cannot craft custom queries.
Security Checklist
- Disable introspection in production
- Limit query depth to 5-7 levels
- Implement query complexity analysis
- Disable or limit batched queries
- Add field-level authorization
- Use persisted queries in production
- Rate limit by operation, not by HTTP request
- Log and monitor query patterns for anomalies
Conclusion
GraphQL is powerful, but its flexibility is a double-edged sword. Every feature that makes development faster — introspection, nested queries, batching — is also an attack vector. Lock them down before you ship to production. Disable introspection, limit depth and complexity, and never trust that authentication at the query level means authorization at the field level.