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
- Disable introspection - Never expose your schema in production
- Limit query depth - Prevent deeply nested resource exhaustion
- Analyze complexity - Reject expensive queries before execution
- Authorize at every level - Schema, resolver, and data layer
- Use DataLoader - Batch and cache to prevent N+1 attacks
- Sanitize errors - Never expose stack traces or internal details
- Consider persisted queries - Whitelist approach for high security
- Rate limit by complexity - Not just request count
- Validate inputs - Type checking isn't enough
- Monitor and alert - Track query patterns and anomalies
Next Steps
- API Security Complete Guide - Comprehensive API security overview
- API Input Validation - Schema validation and sanitization
- API Gateway Security - Gateway-level protection
- JWT Security Best Practices - Token handling