GraphQL Security Challenges
GraphQL's flexibility creates unique security challenges. The ability for clients to write arbitrary queries means they can potentially request enormous amounts of data, deeply nested resources, or exploit the schema in ways REST APIs cannot. Understanding and mitigating these risks is essential for production APIs.
1. Query Depth Limiting
Without depth limiting, a malicious client can send deeply nested queries that cause exponential database lookups:
# Malicious deeply nested query
query DeepAttack {
user(id: "1") {
posts {
author {
posts {
author {
posts {
author {
posts { # This nests infinitely
title
}
}
}
}
}
}
}
}
}
// Implement query depth limiting
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(10)], // Maximum depth of 10
});
// Or implement custom depth checking
import { getComplexity, simpleEstimator } from 'graphql-query-complexity';
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
{
requestDidStart: async () => ({
async didResolveOperation({ request, document }) {
const complexity = getComplexity({
schema,
operationName: request.operationName,
query: document,
variables: request.variables,
estimators: [
simpleEstimator({ defaultComplexity: 1 }),
],
});
if (complexity > 1000) {
throw new GraphQLError(
`Query too complex: ${complexity}. Maximum allowed: 1000`,
{ extensions: { code: 'QUERY_TOO_COMPLEX' } }
);
}
},
}),
},
],
});
2. Rate Limiting
import rateLimit from 'express-rate-limit';
import { GraphQLError } from 'graphql';
// IP-based rate limiting at the HTTP layer
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per window
standardHeaders: true,
message: { errors: [{ message: 'Too many requests' }] },
});
app.use('/graphql', limiter);
// Operation-specific rate limiting
const operationLimits: Record<string, { max: number; windowMs: number }> = {
signIn: { max: 5, windowMs: 60000 }, // 5 login attempts per minute
signUp: { max: 3, windowMs: 3600000 }, // 3 signups per hour
createPost: { max: 10, windowMs: 3600000 }, // 10 posts per hour
};
const rateLimitPlugin = {
requestDidStart: async () => ({
async didResolveOperation({ request, context }: any) {
const operationName = request.operationName;
const limit = operationLimits[operationName];
if (limit) {
const key = `ratelimit:${context.clientIp}:${operationName}`;
const current = await redis.incr(key);
if (current === 1) {
await redis.expire(key, limit.windowMs / 1000);
}
if (current > limit.max) {
throw new GraphQLError('Rate limit exceeded', {
extensions: { code: 'RATE_LIMITED', retryAfter: limit.windowMs / 1000 },
});
}
}
},
}),
};
3. Disable Introspection in Production
import { ApolloServer } from '@apollo/server';
import { ApolloServerPluginInlineTraceDisabled } from '@apollo/server/plugin/disabled';
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== 'production',
plugins: [
// Disable inline trace in production
...(process.env.NODE_ENV === 'production'
? [ApolloServerPluginInlineTraceDisabled()]
: []),
],
});
4. Persisted Queries
Persisted queries are the gold standard for GraphQL security in production. Instead of accepting arbitrary query strings, the server only accepts pre-registered query hashes:
// Only allow pre-registered queries in production
import { ApolloServer } from '@apollo/server';
// Build-time: generate query manifest
// { "abc123hash": "query GetUser($id: ID!) { user(id: $id) { name } }" }
const allowedQueries = new Map<string, string>(
Object.entries(require('./persisted-queries.json'))
);
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
{
requestDidStart: async () => ({
async didResolveOperation({ request }) {
if (process.env.NODE_ENV === 'production') {
const hash = request.extensions?.persistedQuery?.sha256Hash;
if (!hash || !allowedQueries.has(hash)) {
throw new GraphQLError('Query not allowed. Use persisted queries.', {
extensions: { code: 'PERSISTED_QUERY_NOT_FOUND' },
});
}
}
},
}),
},
],
});
5. Input Validation and Sanitization
import { z } from 'zod';
// Define validation schemas with Zod
const CreatePostSchema = z.object({
title: z.string().min(3).max(200).trim(),
body: z.string().min(10).max(50000).trim(),
tags: z.array(z.string().max(30)).max(10).optional(),
});
const resolvers = {
Mutation: {
createPost: async (_: unknown, args: { input: any }, ctx: Context) => {
// Validate and sanitize input
const result = CreatePostSchema.safeParse(args.input);
if (!result.success) {
const firstError = result.error.errors[0];
throw new GraphQLError(firstError.message, {
extensions: {
code: 'BAD_USER_INPUT',
field: firstError.path.join('.'),
},
});
}
const sanitizedInput = result.data;
return ctx.prisma.post.create({ data: sanitizedInput });
},
},
};
Security Checklist
- Query depth limiting: Set a maximum query depth (8-12 is typical)
- Query complexity analysis: Limit the total cost of a query
- Rate limiting: Apply both global and per-operation rate limits
- Disable introspection: Turn off introspection in production
- Persisted queries: Only allow pre-registered queries in production
- Input validation: Validate and sanitize all mutation inputs
- HTTPS only: Never serve GraphQL over unencrypted connections
- CORS: Restrict allowed origins to your actual domains