Home/Blog/Cybersecurity/GraphQL Security: Query Depth, Introspection, and Authorization Best Practices
Cybersecurity

GraphQL Security: Query Depth, Introspection, and Authorization Best Practices

Secure your GraphQL APIs against common vulnerabilities including deep queries, introspection attacks, and authorization bypasses. Covers Apollo, Yoga, and best practices.

By Inventive HQ Team
GraphQL Security: Query Depth, Introspection, and Authorization Best Practices

GraphQL's flexibility creates unique security challenges. Unlike REST's fixed endpoints, GraphQL lets clients request arbitrary data structures, making traditional security patterns insufficient. This guide covers GraphQL-specific vulnerabilities and defenses.

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

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

  1. Disable introspection - Never expose your schema in production
  2. Limit query depth - Prevent deeply nested resource exhaustion
  3. Analyze complexity - Reject expensive queries before execution
  4. Authorize at every level - Schema, resolver, and data layer
  5. Use DataLoader - Batch and cache to prevent N+1 attacks
  6. Sanitize errors - Never expose stack traces or internal details
  7. Consider persisted queries - Whitelist approach for high security
  8. Rate limit by complexity - Not just request count
  9. Validate inputs - Type checking isn't enough
  10. Monitor and alert - Track query patterns and anomalies

Next Steps

Frequently Asked Questions

Find answers to common questions

GraphQL-specific risks include: denial of service via deeply nested or complex queries exhausting server resources, introspection exposure revealing your entire schema to attackers, batching attacks sending many operations in one request, authorization bypasses due to inconsistent field-level checks, and information disclosure through verbose error messages. Unlike REST, a single GraphQL endpoint handles all operations, concentrating attack surface.

Yes, disable introspection in production. Introspection queries (__schema, __type) reveal your entire API schema including all types, fields, and relationships—essentially your API documentation. Attackers use this for reconnaissance. Disable with introspection: false in Apollo Server or equivalent in other frameworks. Keep it enabled in development for tooling like GraphiQL.

Implement multiple defenses: limit query depth (typically 5-10 levels), limit query complexity (assign costs to fields, reject over threshold), limit result size, set query timeouts, and use persisted queries (only allow pre-approved queries). Libraries like graphql-depth-limit, graphql-query-complexity, or Apollo Server's built-in limits handle this. Start restrictive and loosen based on legitimate needs.

Query complexity analysis assigns a cost to each field based on its computational expense (database queries, external calls). The total cost of a query is calculated before execution; queries exceeding a threshold are rejected. Assign higher costs to list fields, connections, and computed fields. Use libraries like graphql-query-complexity or graphql-cost-analysis. Example: user(1) + posts(10 each) + comments(5 each) = 1 + 10 + 50 = 61 complexity.

Implement authorization at multiple levels: schema directives (@auth, @hasRole) for declarative rules, resolver-level checks for complex logic, and field-level authorization for sensitive data. Never rely solely on hiding fields from the schema—check permissions in resolvers. Use context to pass authenticated user info. Consider graphql-shield for a rules-based approach. Always authorize at the data layer as the final backstop.

The N+1 problem occurs when resolving a list of items triggers individual database queries for each item's relationships (1 query for list + N queries for related data). Attackers can craft queries that trigger thousands of database calls, causing DoS. Use DataLoader for batching/caching database calls, implement query complexity limits, and monitor database query counts per GraphQL request.

Secure subscriptions by: authenticating the WebSocket connection on connect (validate JWT/token), authorizing each subscription operation, implementing connection-level rate limiting, setting idle timeouts, limiting concurrent subscriptions per user, and validating subscription payloads. Use the connection_init message for auth token exchange. Monitor for subscription abuse (excessive subscriptions, large payloads).

Persisted queries (also called stored/trusted queries) only allow pre-registered queries to execute. Clients send a query hash instead of the full query; the server looks up and executes the stored query. This prevents arbitrary query attacks, eliminates query parsing overhead, and reduces bandwidth. Apollo Server supports Automatic Persisted Queries (APQ). In high-security environments, disable arbitrary queries entirely.

Configure your GraphQL server to sanitize errors in production. Return generic error messages to clients while logging detailed errors server-side. In Apollo Server, use formatError to filter stack traces and sensitive details. Never expose database errors, file paths, or internal implementation details. Include a correlation ID for debugging without revealing internals. Different error handling for development vs production.

Rate limit by: query complexity (not just request count), operation type (mutations vs queries), specific operations (expensive queries get lower limits), and user identity. Track complexity consumed over time windows. Consider operation-based rate limiting where different operations have different costs. Use Redis for distributed rate limiting. Tools like graphql-rate-limit or gateway-level limiting with GraphQL-aware rules.

Don't wait for a breach to act

Get a free security assessment. Our experts will identify your vulnerabilities and create a protection plan tailored to your business.