TechLead
Lesson 18 of 20
5 min read
GraphQL

GraphQL Security

Protect your GraphQL API from common attacks including query depth attacks, batching abuse, injection, and introspection exploitation

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

Continue Learning