Securing a GraphQL API comes down to five controls, and the order matters: disable introspection in production, cap query depth (5-10 levels), enforce a query-complexity budget, authorize at the resolver and data layer (never by hiding fields), and rate-limit by complexity rather than request count. GraphQL is riskier than REST because a single endpoint accepts arbitrary, client-defined query shapes — one crafted request can nest a hundred levels deep, alias the same expensive field a thousand times, or walk a friends { friends { friends } } cycle until the server runs out of memory. Traditional per-endpoint WAF rules and URL-based rate limits don't see any of that, because to them it's all one POST to /graphql.
That's the summary an AI Overview will hand you. Here's what it can't show you: which control to ship first when you have limited time, the exact query shapes attackers send, and how the defensive layers stack. Below is a ranked defense table (prioritized by attack-surface reduction per hour of work), an animated diagram of the depth attack that most GraphQL DoS incidents actually use, and copy-paste configs for Apollo, Yoga, and graphql-shield.
GraphQL Defenses, Ranked by What to Ship First
You cannot do all of this in one sprint. This table ranks GraphQL hardening by how much attack surface each control removes relative to the effort it takes, so you can start at the top and stop when your risk budget is spent. "Effort" assumes an existing Apollo or Yoga server.
| Priority | Control | Attack it stops | Effort | Library / setting | Ship first? |
|---|---|---|---|---|---|
| 1 | Disable introspection | Schema reconnaissance | Minutes | introspection: false (Apollo) / useDisableIntrospection() (Yoga) | Yes — do it today |
| 2 | Query depth limit | Nested/circular DoS | Minutes | graphql-depth-limit (set 5–10) | Yes |
| 3 | Sanitize production errors | Stack-trace & path disclosure | ~1 hour | formatError filter | Yes |
| 4 | Query complexity budget | Expensive list/connection DoS | Half day | graphql-query-complexity | High value |
| 5 | Resolver + data-layer authz | BOLA, field-level leaks | Days | graphql-shield / directives / resolver checks | Critical, but bigger lift |
| 6 | DataLoader batching | N+1 amplification DoS | Days | dataloader | High value |
| 7 | Complexity-based rate limiting | Alias/batch limit bypass | Days | graphql-rate-limit + Redis | After 1–5 |
| 8 | Alias & batch caps | Rate-limit multiplication | Half day | Custom validation rule | Medium |
| 9 | Persisted / trusted queries | Arbitrary query execution | Days–weeks | Apollo APQ / allowlist | High-security only |
| 10 | Disable GraphiQL/Playground | Interactive schema probing | Minutes | Landing-page plugin | Yes |
Which should I do if I only have one afternoon? Rows 1, 2, 3, and 10 — introspection off, depth limit on, errors sanitized, Playground disabled. Those four are all minutes-to-an-hour of work and remove the reconnaissance and cheapest-DoS attack surface. Everything below row 3 is real engineering (authz, DataLoader, rate limiting) and deserves its own ticket.
GraphQL Attack Surface
┌─────────────────────────────────────────────────────────────────────────────┐
│ GRAPHQL-SPECIFIC VULNERABILITIES │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ DENIAL OF │ │ INFORMATION │ │ AUTHORIZATION │ │
│ │ SERVICE │ │ DISCLOSURE │ │ BYPASS │ │
│ ├─────────────────┤ ├─────────────────┤ ├─────────────────┤ │
│ │ • Deep queries │ │ • Introspection │ │ • Field-level │ │
│ │ • Complex │ │ • Error messages│ │ auth gaps │ │
│ │ queries │ │ • Stack traces │ │ • Broken │ │
│ │ • Batch attacks │ │ • Schema │ │ object-level │ │
│ │ • N+1 queries │ │ exposure │ │ auth (BOLA) │ │
│ │ • Alias abuse │ │ • Debug info │ │ • Mutation │ │
│ │ • Circular refs │ │ │ │ side effects │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ INJECTION │ │ SUBSCRIPTION │ │ BATCHING │ │
│ │ ATTACKS │ │ ABUSE │ │ ATTACKS │ │
│ ├─────────────────┤ ├─────────────────┤ ├─────────────────┤ │
│ │ • SQL injection │ │ • Resource │ │ • Multiple ops │ │
│ │ via arguments │ │ exhaustion │ │ in one req │ │
│ │ • NoSQL │ │ • Unauthorized │ │ • Alias │ │
│ │ injection │ │ data streams │ │ multiplication│ │
│ │ • SSRF via │ │ • Connection │ │ • Bypass rate │ │
│ │ variables │ │ flooding │ │ limits │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Disable Introspection in Production
Apollo Server
import { ApolloServer } from '@apollo/server';
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== 'production',
});
GraphQL Yoga
import { createYoga, useDisableIntrospection } from 'graphql-yoga';
const yoga = createYoga({
schema,
plugins: process.env.NODE_ENV === 'production'
? [useDisableIntrospection()]
: [],
});
Custom Validation Rule
import { ValidationContext, GraphQLError } from 'graphql';
function disableIntrospection(context: ValidationContext) {
return {
Field(node) {
if (node.name.value === '__schema' || node.name.value === '__type') {
context.reportError(
new GraphQLError('Introspection is disabled', { nodes: node })
);
}
},
};
}
Query Depth Limiting
A depth limit rejects a query during validation — before a single resolver runs — by counting how many levels of nested selections it contains. The diagram below shows the classic circular-relationship attack (user → friends → friends → …) burrowing one level at a time until the validator with depthLimit(5) stops it dead at level 6. No database is ever touched.
Using graphql-depth-limit
import depthLimit from 'graphql-depth-limit';
import { ApolloServer } from '@apollo/server';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(5)], // Max 5 levels deep
});
Attack Example (Deep Query)
# Malicious deeply nested query
query DeepAttack {
user(id: "1") { # Depth 1
friends { # Depth 2
friends { # Depth 3
friends { # Depth 4
friends { # Depth 5
friends { # Depth 6 - BLOCKED
name
}
}
}
}
}
}
}
Query Complexity Analysis
Using graphql-query-complexity
import {
getComplexity,
simpleEstimator,
fieldExtensionsEstimator,
} from 'graphql-query-complexity';
import { ApolloServer } from '@apollo/server';
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
{
async requestDidStart() {
return {
async didResolveOperation({ request, document }) {
const complexity = getComplexity({
schema,
operationName: request.operationName,
query: document,
variables: request.variables,
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 }),
],
});
const MAX_COMPLEXITY = 100;
if (complexity > MAX_COMPLEXITY) {
throw new GraphQLError(
`Query complexity ${complexity} exceeds maximum ${MAX_COMPLEXITY}`
);
}
console.log(`Query complexity: ${complexity}`);
},
};
},
},
],
});
Schema with Complexity Costs
const typeDefs = gql`
type Query {
user(id: ID!): User @complexity(value: 1)
users(limit: Int = 10): [User!]! @complexity(value: 10, multipliers: ["limit"])
search(query: String!): [SearchResult!]! @complexity(value: 20)
}
type User {
id: ID!
name: String! @complexity(value: 0)
email: String! @complexity(value: 0)
posts(limit: Int = 10): [Post!]! @complexity(value: 5, multipliers: ["limit"])
friends: [User!]! @complexity(value: 10)
}
type Post {
id: ID!
title: String!
comments(limit: Int = 20): [Comment!]! @complexity(value: 3, multipliers: ["limit"])
}
`;
Complexity Calculation Example
query {
users(limit: 5) { # 10 * 5 = 50
name # 0
posts(limit: 3) { # 5 * 3 * 5 = 75
title # 0
comments(limit: 10) { # 3 * 10 * 3 * 5 = 450
text # 0
}
}
}
}
Total: 50 + 75 + 450 = 575 (REJECTED if max is 100)
Authorization Patterns
Schema Directives
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
import { defaultFieldResolver, GraphQLSchema } from 'graphql';
function authDirectiveTransformer(schema: GraphQLSchema) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const authDirective = getDirective(schema, fieldConfig, 'auth')?.[0];
const requiresDirective = getDirective(schema, fieldConfig, 'requires')?.[0];
if (authDirective || requiresDirective) {
const { resolve = defaultFieldResolver } = fieldConfig;
const requiredRole = requiresDirective?.role;
fieldConfig.resolve = async function (source, args, context, info) {
// Check authentication
if (!context.user) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
// Check authorization
if (requiredRole && !context.user.roles.includes(requiredRole)) {
throw new GraphQLError('Not authorized', {
extensions: { code: 'FORBIDDEN' },
});
}
return resolve(source, args, context, info);
};
}
return fieldConfig;
},
});
}
// Schema with directives
const typeDefs = gql`
directive @auth on FIELD_DEFINITION
directive @requires(role: String!) on FIELD_DEFINITION
type Query {
publicData: String
myProfile: User @auth
adminDashboard: AdminData @requires(role: "ADMIN")
}
type User {
id: ID!
name: String!
email: String! @auth
ssn: String @requires(role: "ADMIN")
}
`;
Using graphql-shield
import { shield, rule, and, or, not } from 'graphql-shield';
// Define rules
const isAuthenticated = rule()(async (parent, args, ctx, info) => {
return ctx.user !== null;
});
const isAdmin = rule()(async (parent, args, ctx, info) => {
return ctx.user?.role === 'ADMIN';
});
const isOwner = rule()(async (parent, args, ctx, info) => {
return ctx.user?.id === parent.userId;
});
// Define permissions
const permissions = shield({
Query: {
users: isAdmin,
user: isAuthenticated,
me: isAuthenticated,
},
Mutation: {
createPost: isAuthenticated,
deletePost: and(isAuthenticated, or(isAdmin, isOwner)),
updateUser: and(isAuthenticated, or(isAdmin, isOwner)),
},
User: {
email: and(isAuthenticated, or(isAdmin, isOwner)),
ssn: isAdmin,
},
}, {
allowExternalErrors: true,
fallbackRule: isAuthenticated,
});
// Apply to schema
import { applyMiddleware } from 'graphql-middleware';
const schemaWithPermissions = applyMiddleware(schema, permissions);
Field-Level Authorization in Resolvers
const resolvers = {
Query: {
user: async (_, { id }, context) => {
// Always fetch the user
const user = await db.users.findById(id);
if (!user) return null;
// Check if requester can access this user
if (!context.user) {
throw new AuthenticationError('Must be logged in');
}
// Allow if user is viewing their own profile or is admin
if (context.user.id !== id && !context.user.roles.includes('ADMIN')) {
throw new ForbiddenError('Cannot access this user');
}
return user;
},
},
User: {
// Field-level resolver for sensitive data
email: (user, _, context) => {
// Only show email if it's the user's own profile or requester is admin
if (context.user?.id === user.id || context.user?.roles.includes('ADMIN')) {
return user.email;
}
return null; // or throw error
},
ssn: (user, _, context) => {
// Only admins can see SSN
if (!context.user?.roles.includes('ADMIN')) {
throw new ForbiddenError('Admin access required');
}
return user.ssn;
},
},
};
Rate Limiting
Complexity-Based Rate Limiting
import { createRateLimitRule } from 'graphql-rate-limit';
const rateLimitRule = createRateLimitRule({
identifyContext: (ctx) => ctx.user?.id || ctx.ip,
});
const permissions = shield({
Query: {
// 100 complexity points per minute
'*': rateLimitRule({ window: '1m', max: 100 }),
},
Mutation: {
// Stricter limits for mutations
createPost: rateLimitRule({ window: '1m', max: 10 }),
deleteAccount: rateLimitRule({ window: '1h', max: 1 }),
},
});
Operation-Based Rate Limiting
import { ApolloServer } from '@apollo/server';
const operationCosts: Record<string, number> = {
'Query.users': 10,
'Query.search': 20,
'Mutation.sendEmail': 50,
default: 1,
};
const rateLimiter = new Map<string, { count: number; resetAt: number }>();
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
{
async requestDidStart({ contextValue }) {
return {
async didResolveOperation({ operation }) {
const userId = contextValue.user?.id || contextValue.ip;
const operationName = operation?.name?.value || 'anonymous';
const now = Date.now();
const windowMs = 60000; // 1 minute
const maxCost = 100;
let userLimit = rateLimiter.get(userId);
if (!userLimit || userLimit.resetAt < now) {
userLimit = { count: 0, resetAt: now + windowMs };
rateLimiter.set(userId, userLimit);
}
const cost = operationCosts[operationName] || operationCosts.default;
userLimit.count += cost;
if (userLimit.count > maxCost) {
throw new GraphQLError('Rate limit exceeded', {
extensions: {
code: 'RATE_LIMITED',
retryAfter: Math.ceil((userLimit.resetAt - now) / 1000),
},
});
}
},
};
},
},
],
});
Preventing Batch Attacks
Limit Operations Per Request
import { ApolloServer } from '@apollo/server';
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
{
async requestDidStart() {
return {
async didResolveOperation({ document }) {
const definitions = document.definitions.filter(
(def) => def.kind === 'OperationDefinition'
);
if (definitions.length > 1) {
throw new GraphQLError('Batched operations are not allowed', {
extensions: { code: 'BATCH_NOT_ALLOWED' },
});
}
},
};
},
},
],
});
Prevent Alias Abuse
// Malicious query using aliases to bypass rate limits
query AliasAttack {
user1: user(id: "1") { name }
user2: user(id: "2") { name }
user3: user(id: "3") { name }
# ... hundreds more
}
// Defense: Count aliases
import { ApolloServer } from '@apollo/server';
import { visit } from 'graphql';
const MAX_ALIASES = 10;
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
{
async requestDidStart() {
return {
async didResolveOperation({ document }) {
let aliasCount = 0;
visit(document, {
Field(node) {
if (node.alias) {
aliasCount++;
if (aliasCount > MAX_ALIASES) {
throw new GraphQLError(`Maximum ${MAX_ALIASES} aliases allowed`);
}
}
},
});
},
};
},
},
],
});
Error Handling
Sanitize Production Errors
import { ApolloServer } from '@apollo/server';
import { GraphQLError, GraphQLFormattedError } from 'graphql';
import { v4 as uuidv4 } from 'uuid';
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (formattedError: GraphQLFormattedError, error: unknown) => {
// Generate correlation ID for debugging
const correlationId = uuidv4();
// Log the full error server-side
console.error({
correlationId,
message: formattedError.message,
path: formattedError.path,
stack: error instanceof Error ? error.stack : undefined,
extensions: formattedError.extensions,
});
// In production, sanitize the error
if (process.env.NODE_ENV === 'production') {
// Preserve known error codes
const safeExtensions = formattedError.extensions?.code
? { code: formattedError.extensions.code, correlationId }
: { code: 'INTERNAL_SERVER_ERROR', correlationId };
// Check if it's a client error we can show
const clientErrors = [
'UNAUTHENTICATED',
'FORBIDDEN',
'BAD_USER_INPUT',
'VALIDATION_ERROR',
'NOT_FOUND',
'RATE_LIMITED',
];
if (clientErrors.includes(safeExtensions.code as string)) {
return {
message: formattedError.message,
extensions: safeExtensions,
};
}
// Generic error for unexpected issues
return {
message: 'An unexpected error occurred',
extensions: safeExtensions,
};
}
// In development, return full error
return {
...formattedError,
extensions: {
...formattedError.extensions,
correlationId,
},
};
},
});
Persisted Queries
Apollo Automatic Persisted Queries (APQ)
import { ApolloServer } from '@apollo/server';
import { ApolloServerPluginLandingPageDisabled } from '@apollo/server/plugin/disabled';
import { KeyValueCache } from '@apollo/utils.keyvaluecache';
// Redis cache for persisted queries
import { RedisCache } from 'apollo-server-cache-redis';
const cache = new RedisCache({
host: 'redis',
port: 6379,
});
const server = new ApolloServer({
typeDefs,
resolvers,
cache,
persistedQueries: {
cache,
ttl: 900, // 15 minutes
},
});
Strict Persisted Queries (Whitelist Only)
import { ApolloServer } from '@apollo/server';
import { GraphQLError } from 'graphql';
// Pre-approved queries
const allowedQueries = new Map([
['abc123hash', 'query GetUser($id: ID!) { user(id: $id) { id name } }'],
['def456hash', 'query GetPosts { posts { id title } }'],
]);
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
{
async requestDidStart() {
return {
async didResolveOperation({ request }) {
const queryHash = request.extensions?.persistedQuery?.sha256Hash;
// Require persisted query hash
if (!queryHash) {
throw new GraphQLError('Only persisted queries are allowed', {
extensions: { code: 'PERSISTED_QUERY_REQUIRED' },
});
}
// Validate against whitelist
if (!allowedQueries.has(queryHash)) {
throw new GraphQLError('Unknown persisted query', {
extensions: { code: 'PERSISTED_QUERY_NOT_FOUND' },
});
}
},
};
},
},
],
});
Security Testing Checklist
┌─────────────────────────────────────────────────────────────────────────────┐
│ GRAPHQL SECURITY CHECKLIST │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ CONFIGURATION │
│ [ ] Introspection disabled in production │
│ [ ] GraphQL Playground/GraphiQL disabled in production │
│ [ ] Debug mode disabled │
│ [ ] Error messages sanitized │
│ │
│ DENIAL OF SERVICE │
│ [ ] Query depth limit configured │
│ [ ] Query complexity limit configured │
│ [ ] Request timeout configured │
│ [ ] Maximum aliases limit │
│ [ ] Batch query disabled or limited │
│ [ ] DataLoader used for N+1 prevention │
│ │
│ AUTHENTICATION & AUTHORIZATION │
│ [ ] All mutations require authentication │
│ [ ] Field-level authorization implemented │
│ [ ] Object-level authorization (BOLA prevention) │
│ [ ] Subscriptions authenticate on connect │
│ │
│ INPUT VALIDATION │
│ [ ] Arguments validated (type, length, format) │
│ [ ] File uploads limited and validated │
│ [ ] SQL/NoSQL injection prevented │
│ │
│ RATE LIMITING │
│ [ ] Complexity-based rate limiting │
│ [ ] Per-operation rate limits │
│ [ ] Per-user rate limits │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Best Practices
- Disable introspection - Never expose your schema in production
- Limit query depth - Prevent deeply nested resource exhaustion
- Analyze complexity - Reject expensive queries before execution
- Authorize at every level - Schema, resolver, and data layer
- Use DataLoader - Batch and cache to prevent N+1 attacks
- Sanitize errors - Never expose stack traces or internal details
- Consider persisted queries - Whitelist approach for high security
- Rate limit by complexity - Not just request count
- Validate inputs - Type checking isn't enough
- Monitor and alert - Track query patterns and anomalies
Next Steps
- API Security Complete Guide - Comprehensive API security overview
- API Input Validation - Schema validation and sanitization
- API Gateway Security - Gateway-level protection
- JWT Security Best Practices - Token handling