GraphQL API Security: Protecting Against Query Complexity and Injection Attacks
Essential security practices for GraphQL APIs, including query depth limiting, cost analysis, authentication, authorization, and protection against common GraphQL-specific vulnerabilities.
API Security Team
GraphQL Engineering
Introduction
GraphQL offers powerful flexibility for API clients, but this flexibility introduces unique security challenges. Unlike REST APIs with fixed endpoints, GraphQL allows clients to request arbitrary data combinations, which can be exploited for resource exhaustion, data exposure, and injection attacks. This guide covers essential security practices specific to GraphQL implementations.
1. Query Complexity and Depth Limiting
Prevent Resource Exhaustion
Implement query depth and complexity limits to prevent malicious or poorly-written queries from overwhelming your server:
// Using graphql-depth-limit
import depthLimit from 'graphql-depth-limit';
import { ApolloServer } from '@apollo/server';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(7)] // Max depth of 7
});
// Malicious query that will be rejected:
query {
user {
posts {
comments {
author {
posts {
comments {
author {
# Too deep!
}
}
}
}
}
}
}
}Query Cost Analysis
Implement a cost-based system to limit expensive queries:
// Custom cost analysis implementation
import { GraphQLError } from 'graphql';
const MAX_QUERY_COST = 1000;
const costDirective = {
cost: (complexity, args, cost) => complexity * cost
};
// Add costs to your schema
type Query {
users(limit: Int): [User!]! @cost(complexity: 1, multipliers: ["limit"])
posts(limit: Int): [Post!]! @cost(complexity: 2, multipliers: ["limit"])
}
// Validate query cost
function calculateQueryCost(query, variables) {
let totalCost = 0;
// Calculate based on query structure and cost directives
return totalCost;
}
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [{
async requestDidStart() {
return {
async didResolveOperation(requestContext) {
const cost = calculateQueryCost(
requestContext.document,
requestContext.request.variables
);
if (cost > MAX_QUERY_COST) {
throw new GraphQLError(
`Query cost ${cost} exceeds maximum ${MAX_QUERY_COST}`,
{ extensions: { code: 'QUERY_TOO_COMPLEX' } }
);
}
}
};
}
}]
});Rate Limiting per Field
import { graphqlRateLimiter } from 'graphql-rate-limit';
const rateLimiter = graphqlRateLimiter({
identifyContext: (ctx) => ctx.user?.id || ctx.ip
});
const resolvers = {
Query: {
users: rateLimiter({
max: 100,
window: '1m',
message: 'Too many requests'
})(async (parent, args, context) => {
return context.dataSources.users.getAll(args);
}),
expensiveReport: rateLimiter({
max: 5,
window: '1h',
message: 'Report generation limit exceeded'
})(async (parent, args, context) => {
return context.dataSources.reports.generate(args);
})
}
};2. Authentication and Authorization
Implement Field-Level Authorization
// Schema with authorization directives
directive @auth(requires: Role = USER) on FIELD_DEFINITION | OBJECT
enum Role {
USER
ADMIN
MODERATOR
}
type Query {
me: User! @auth(requires: USER)
users: [User!]! @auth(requires: ADMIN)
posts: [Post!]!
}
type User {
id: ID!
email: String! @auth(requires: USER)
privateNotes: String @auth(requires: ADMIN)
posts: [Post!]!
}
// Implement authorization directive
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
function authDirectiveTransformer(schema, directiveName) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const authDirective = getDirective(
schema,
fieldConfig,
directiveName
)?.[0];
if (authDirective) {
const { requires } = authDirective;
const { resolve = defaultFieldResolver } = fieldConfig;
fieldConfig.resolve = async function (source, args, context, info) {
const userRole = context.user?.role;
if (!userRole) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' }
});
}
if (!hasRole(userRole, requires)) {
throw new GraphQLError('Not authorized', {
extensions: { code: 'FORBIDDEN' }
});
}
return resolve(source, args, context, info);
};
}
return fieldConfig;
}
});
}
const schema = authDirectiveTransformer(baseSchema, 'auth');Context-Based Authorization
// Row-level security in resolvers
const resolvers = {
Query: {
post: async (parent, { id }, context) => {
const post = await context.dataSources.posts.findById(id);
if (!post) {
throw new GraphQLError('Post not found', {
extensions: { code: 'NOT_FOUND' }
});
}
// Check if user can view this post
if (post.visibility === 'PRIVATE' &&
post.authorId !== context.user?.id) {
throw new GraphQLError('Not authorized to view this post', {
extensions: { code: 'FORBIDDEN' }
});
}
return post;
}
},
User: {
email: (parent, args, context) => {
// Only show email to the user themselves or admins
if (context.user?.id === parent.id || context.user?.role === 'ADMIN') {
return parent.email;
}
return null;
}
}
};3. Preventing Injection Attacks
Input Validation and Sanitization
import { z } from 'zod';
// Define validation schemas
const CreatePostInput = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1).max(10000),
tags: z.array(z.string()).max(10).optional()
});
const resolvers = {
Mutation: {
createPost: async (parent, { input }, context) => {
// Validate input
const validated = CreatePostInput.parse(input);
// Sanitize HTML content if needed
const sanitized = {
...validated,
content: sanitizeHtml(validated.content, {
allowedTags: ['p', 'br', 'strong', 'em', 'a'],
allowedAttributes: {
'a': ['href']
}
})
};
return context.dataSources.posts.create(sanitized);
}
}
};
// Schema input validation
type Mutation {
createPost(input: CreatePostInput!): Post!
}
input CreatePostInput {
title: String! @constraint(minLength: 1, maxLength: 200)
content: String! @constraint(minLength: 1, maxLength: 10000)
tags: [String!] @constraint(maxItems: 10)
}Prevent NoSQL Injection
// Bad: Direct use of user input in database queries
const resolvers = {
Query: {
// VULNERABLE!
usersByEmail: async (parent, { email }, context) => {
return context.db.collection('users').find({ email });
}
}
};
// Good: Validate and sanitize inputs
const resolvers = {
Query: {
usersByEmail: async (parent, { email }, context) => {
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new GraphQLError('Invalid email format');
}
// Use parameterized queries or ORM
return context.db.collection('users').find({
email: { $eq: email } // Explicit operator
});
}
}
};4. Introspection and Schema Protection
Disable Introspection in Production
import { ApolloServer } from '@apollo/server';
import { NoSchemaIntrospectionCustomRule } from 'graphql';
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== 'production',
validationRules: process.env.NODE_ENV === 'production'
? [NoSchemaIntrospectionCustomRule]
: []
});
// Or implement custom introspection control
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [{
async requestDidStart() {
return {
async didResolveOperation(requestContext) {
const query = requestContext.document.definitions[0];
if (
query.kind === 'OperationDefinition' &&
query.operation === 'query'
) {
const selections = query.selectionSet.selections;
const isIntrospection = selections.some(
field => field.name.value === '__schema' ||
field.name.value === '__type'
);
if (isIntrospection && !requestContext.contextValue.user?.isAdmin) {
throw new GraphQLError('Introspection is disabled');
}
}
}
};
}
}]
});5. Batching and Caching Security
Secure DataLoader Implementation
import DataLoader from 'dataloader';
// Create context-aware data loaders
function createDataLoaders(user) {
return {
users: new DataLoader(async (ids) => {
const users = await db.users.findByIds(ids);
// Apply authorization rules
return users.map(user => {
if (canViewUser(currentUser, user)) {
return user;
}
return null; // Or filtered user object
});
}),
posts: new DataLoader(async (ids) => {
const posts = await db.posts.findByIds(ids);
// Filter based on user permissions
return posts.map(post => {
if (post.visibility === 'PUBLIC' ||
post.authorId === user?.id) {
return post;
}
return null;
});
}, {
// Important: Include user context in cache key
cacheKeyFn: (id) => `${user?.id || 'anonymous'}:${id}`
})
};
}
const server = new ApolloServer({
typeDefs,
resolvers,
context: async ({ req }) => {
const user = await authenticateUser(req);
return {
user,
dataSources: createDataLoaders(user)
};
}
});6. Error Handling and Information Disclosure
Sanitize Error Messages
import { ApolloServer } from '@apollo/server';
import { unwrapResolverError } from '@apollo/server/errors';
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (formattedError, error) => {
// Log full error details server-side
console.error('GraphQL Error:', {
message: error.message,
path: formattedError.path,
stack: error.stack,
extensions: error.extensions
});
// Don't expose sensitive information in production
if (process.env.NODE_ENV === 'production') {
// Remove stack traces and internal details
if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
return {
message: 'An internal error occurred',
extensions: {
code: 'INTERNAL_SERVER_ERROR'
}
};
}
// Remove database error details
if (error.originalError?.name === 'SequelizeDatabaseError') {
return {
message: 'A database error occurred',
extensions: {
code: 'DATABASE_ERROR'
}
};
}
}
return formattedError;
}
});7. Mutation Security
Implement CSRF Protection
// Require custom header for mutations
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [{
async requestDidStart() {
return {
async didResolveOperation(requestContext) {
const operation = requestContext.document.definitions[0];
if (
operation.kind === 'OperationDefinition' &&
operation.operation === 'mutation'
) {
const csrfToken = requestContext.request.http?.headers.get('x-csrf-token');
const sessionToken = requestContext.contextValue.session?.csrfToken;
if (!csrfToken || csrfToken !== sessionToken) {
throw new GraphQLError('Invalid CSRF token', {
extensions: { code: 'CSRF_VALIDATION_FAILED' }
});
}
}
}
};
}
}]
});8. Monitoring and Logging
Comprehensive Audit Logging
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [{
async requestDidStart(requestContext) {
const startTime = Date.now();
return {
async willSendResponse(requestContext) {
const duration = Date.now() - startTime;
// Log all operations
logger.info('GraphQL Operation', {
operationName: requestContext.operationName,
operation: requestContext.operation?.operation,
userId: requestContext.contextValue.user?.id,
ip: requestContext.request.http?.headers.get('x-forwarded-for'),
duration,
errors: requestContext.errors?.length || 0,
timestamp: new Date().toISOString()
});
// Alert on suspicious patterns
if (duration > 5000) {
alertSlowQuery(requestContext);
}
if (requestContext.errors?.some(e =>
e.extensions?.code === 'UNAUTHENTICATED'
)) {
trackFailedAuth(requestContext);
}
}
};
}
}]
});Security Checklist
GraphQL Security Checklist:
- ✅ Implement query depth limiting
- ✅ Use query cost analysis
- ✅ Apply rate limiting per field
- ✅ Implement field-level authorization
- ✅ Validate and sanitize all inputs
- ✅ Prevent injection attacks
- ✅ Disable introspection in production
- ✅ Secure DataLoader implementations
- ✅ Sanitize error messages
- ✅ Implement CSRF protection for mutations
- ✅ Use HTTPS for all GraphQL endpoints
- ✅ Implement comprehensive logging
- ✅ Monitor for abnormal query patterns
- ✅ Regular security audits of schema
- ✅ Keep dependencies updated
Conclusion
GraphQL's flexibility makes it powerful but requires careful security consideration. By implementing query complexity limits, field-level authorization, proper input validation, and comprehensive monitoring, you can harness GraphQL's benefits while maintaining a strong security posture. Regular security reviews and staying updated with GraphQL security best practices are essential for maintaining secure APIs.