API Security12 min read

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.

GQ

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.