Security Best Practices
This reference covers security considerations for GraphQL schema design.
Table of Contents
- Introspection
- Query Complexity
- Depth Limiting
- Rate Limiting
- Field-Level Authorization
- Input Validation
- Persisted Queries
- Information Disclosure
Introspection
Disable in Production
Introspection reveals your entire schema. Disable it in production:
// Apollo Server
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== 'production',
});Allow for Development
Keep introspection enabled for:
- Development environments
- Internal tools
- Authorized clients (with authentication)
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV === 'development' ||
process.env.ENABLE_INTROSPECTION === 'true',
});Query Complexity
Why Limit Complexity
A single query can request enormous amounts of data:
# Potentially very expensive
query {
users(first: 1000) {
posts(first: 1000) {
comments(first: 1000) {
author {
posts(first: 1000) {
# ... could go deeper
}
}
}
}
}
}Complexity Calculation
Assign costs to fields and limit total cost:
type Query {
users(first: Int): [User!]! @cost(complexity: 10, multipliers: ["first"])
}
type User {
posts(first: Int): [Post!]! @cost(complexity: 5, multipliers: ["first"])
}Implementation with graphql-validation-complexity
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const complexityLimitRule = createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 10,
listFactor: 10,
});
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [complexityLimitRule],
});Cost Estimation in Schema
Document expected costs:
"""
Returns user's posts.
Cost: Base 5 + (first * 2)
"""
type User {
posts(
first: Int = 20 @cost(weight: 2)
): PostConnection! @cost(complexity: 5)
}Depth Limiting
Why Limit Depth
Prevent deeply nested queries:
# Depth: 10+ levels deep
query {
user {
friends {
friends {
friends {
friends {
# ...
}
}
}
}
}
}Implementation
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(10)],
});Recommended Limits
| Application Type | Max Depth |
|---|---|
| Simple API | 5-7 |
| Complex API | 7-10 |
| Internal tools | 10-15 |
Rate Limiting
Query-Based Rate Limiting
Limit queries per time window:
// Example with express-rate-limit
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 1000, // limit each IP to 1000 requests per window
});
app.use('/graphql', limiter);Complexity-Based Rate Limiting
Limit based on query cost, not just count:
// Track complexity per user
const userComplexityBudget = new Map();
const complexityPlugin = {
requestDidStart() {
return {
didResolveOperation({ request, document, context }) {
const complexity = calculateComplexity(document);
const userId = context.user?.id || request.http?.headers.get('x-forwarded-for');
const current = userComplexityBudget.get(userId) || 0;
if (current + complexity > MAX_COMPLEXITY_PER_MINUTE) {
throw new GraphQLError('Rate limit exceeded');
}
userComplexityBudget.set(userId, current + complexity);
}
};
}
};Field-Specific Rate Limiting
Limit expensive fields specifically:
type Query {
"""
Rate limited to 10 requests per minute
"""
expensiveAnalytics: Analytics! @rateLimit(max: 10, window: "1m")
}Field-Level Authorization
Schema Design for Authorization
Don't expose unauthorized fields in schema:
# User-facing schema
type User {
id: ID!
name: String!
publicProfile: PublicProfile!
}
# Admin-only schema (separate schema or schema stitching)
type User {
id: ID!
name: String!
email: String! # Admin only
internalNotes: String # Admin only
}Resolver-Level Authorization
Check permissions in resolvers:
const resolvers = {
User: {
email: (user, args, context) => {
if (!context.user || context.user.id !== user.id) {
if (!context.user?.isAdmin) {
return null; // or throw error
}
}
return user.email;
},
internalNotes: (user, args, context) => {
if (!context.user?.isAdmin) {
throw new GraphQLError('Not authorized', {
extensions: { code: 'UNAUTHORIZED' }
});
}
return user.internalNotes;
}
}
};Directive-Based Authorization
directive @auth(requires: Role!) on FIELD_DEFINITION
enum Role {
USER
ADMIN
SUPER_ADMIN
}
type User {
id: ID!
name: String!
email: String! @auth(requires: USER) # Own data or admin
ssn: String @auth(requires: SUPER_ADMIN)
}Input Validation
Schema-Level Validation
Use custom scalars for validation:
scalar Email # Validates email format
scalar URL # Validates URL format
scalar DateTime # Validates ISO 8601 format
type Mutation {
createUser(
email: Email!
website: URL
birthDate: DateTime!
): User!
}Input Constraints
Document and enforce constraints:
input CreatePostInput {
"""
Title of the post.
Min length: 1
Max length: 200
"""
title: String!
"""
Post content.
Max length: 50000
"""
content: String!
"""
Tags for the post.
Max items: 10
"""
tags: [String!]
}Resolver Validation
Always validate in resolvers:
const resolvers = {
Mutation: {
createPost: (_, { input }) => {
if (input.title.length > 200) {
throw new GraphQLError('Title too long', {
extensions: { code: 'VALIDATION_ERROR', field: 'title' }
});
}
if (input.content.length > 50000) {
throw new GraphQLError('Content too long', {
extensions: { code: 'VALIDATION_ERROR', field: 'content' }
});
}
if (input.tags?.length > 10) {
throw new GraphQLError('Too many tags', {
extensions: { code: 'VALIDATION_ERROR', field: 'tags' }
});
}
// ... create post
}
}
};Persisted Queries
What Are Persisted Queries?
Map query hashes to pre-approved queries:
Client sends: { "extensions": { "persistedQuery": { "sha256Hash": "abc123..." }}}
Server looks up: abc123... → "query GetUser($id: ID!) { user(id: $id) { id name }}"Benefits
- Security: Only allow approved queries
- Performance: No parsing overhead
- Bandwidth: Smaller payloads
- CDN: Queries can be cached
Implementation
// Apollo Server with automatic persisted queries
import { ApolloServerPluginLandingPageDisabled } from '@apollo/server/plugin/disabled';
const server = new ApolloServer({
typeDefs,
resolvers,
persistedQueries: {
cache: new KeyValueCache(), // Your cache implementation
},
});Strict Mode
In production, reject non-persisted queries:
const server = new ApolloServer({
typeDefs,
resolvers,
persistedQueries: {
cache: persistedQueryCache,
},
allowBatchedHttpRequests: false,
plugins: [
{
async requestDidStart() {
return {
async didResolveOperation({ request }) {
if (!request.extensions?.persistedQuery) {
throw new GraphQLError('Only persisted queries allowed');
}
}
};
}
}
]
});Information Disclosure
Error Messages
Don't leak internal details:
// Bad: Exposes internal implementation
throw new Error(`Database error: SQLSTATE[23000]: duplicate key 'users_email_unique'`);
// Good: User-friendly message
throw new GraphQLError('Email already registered', {
extensions: { code: 'EMAIL_EXISTS' }
});Stack Traces
Disable in production:
const server = new ApolloServer({
typeDefs,
resolvers,
includeStacktraceInErrorResponses: process.env.NODE_ENV === 'development',
});Schema Descriptions
Don't expose sensitive information in descriptions:
# Bad: Exposes internal details
"""
User entity. Stored in PostgreSQL users table.
Synced with Salesforce via nightly cron job.
"""
type User { ... }
# Good: Public-facing description
"""
A user account in the system.
"""
type User { ... }Field Nullability and Errors
Consider what null vs error reveals:
type User {
# Returns null if no permission - reveals existence
secretData: String
# Returns error if no permission - might reveal existence
secretData: String!
}
# Better: Same behavior whether exists or not
# Query returns null/error whether user exists or not