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

Need Expert Cybersecurity Guidance?

Our team of security experts is ready to help protect your business from evolving threats.