GraphQL Security: Preventing Introspection Abuse, Injection, and DoS
Disable introspection in production, enforce query depth and complexity limits, require auth on every resolver, and use persisted queries to lock down your GraphQL API.
GraphQL Security: Preventing Introspection Abuse, Injection, and DoS
GraphQL's expressive query language is also its attack surface. A single endpoint that accepts arbitrarily nested queries opens the door to resource exhaustion attacks that would be impossible against REST APIs. Introspection lets attackers map your entire data model in seconds. And because GraphQL resolvers are often written by teams not focused on security, authorization is frequently bolted on inconsistently or forgotten entirely. This guide covers the complete set of controls needed to run GraphQL safely in production.
Disable Introspection in Production
Introspection is GraphQL's built-in schema discovery mechanism. It allows any client to query __schema and __type to learn every type, field, query, mutation, and subscription your API exposes. This is invaluable during development and for tooling. In production, it hands attackers a complete map of your API.
With the schema in hand, an attacker can:
- Identify fields that sound sensitive (
creditCard,ssn,internalNotes). - Find mutations that bypass expected UI flows.
- Discover deprecated fields that may lack proper authorization checks.
Disabling in Apollo Server
import { ApolloServer } from '@apollo/server';
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== 'production',
});
Disabling in GraphQL Yoga / Envelop
import { createYoga } from 'graphql-yoga';
import { useDisableIntrospection } from '@envelop/disable-introspection';
const yoga = createYoga({
schema,
plugins: [
useDisableIntrospection({
// Optionally allow introspection for authenticated admins
isIntrospectionAllowed: (context) => context.user?.role === 'admin',
}),
],
});
Field suggestion hiding
Even with introspection disabled, GraphQL error messages often include "Did you mean X?" suggestions that leak field names. Suppress these:
import { ApolloServer } from '@apollo/server';
import { ApolloServerPluginLandingPageDisabled } from '@apollo/server/plugin/disabled';
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: false,
formatError: (formattedError) => {
// Strip field suggestions from error messages in production
if (process.env.NODE_ENV === 'production') {
return { message: formattedError.message.replace(/Did you mean.*\?/g, '').trim() };
}
return formattedError;
},
});
Query Depth and Complexity Limits
GraphQL's recursive query capability enables a category of DoS attack that REST cannot match. Consider this query against a social graph:
{
user(id: "1") {
friends {
friends {
friends {
friends {
# 10 levels deep
name email
}
}
}
}
}
}
Each level multiplies the database queries. A 10-level deep query against a database with average 100 friends per user would theoretically resolve 100^10 rows. Even with pagination limits, deeply nested queries can cause catastrophic performance.
Depth limiting
import depthLimit from 'graphql-depth-limit';
import { ApolloServer } from '@apollo/server';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(5)], // Reject queries deeper than 5 levels
});
Choose your depth limit based on your deepest legitimate query. Five levels accommodates most real applications. For APIs with complex nested types, measure your actual query patterns before setting limits.
Complexity limits
Depth alone is insufficient. A shallow but wide query with many fields can be just as expensive. Complexity analysis assigns a cost to each field and rejects queries that exceed a threshold.
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const ComplexityLimitRule = createComplexityLimitRule(1000, {
onCost: (cost) => console.log('Query cost:', cost),
formatErrorMessage: (cost) =>
`Query complexity ${cost} exceeds maximum allowed complexity of 1000`,
// Custom costs for expensive resolvers
fieldConfigEstimator: () => 1,
listFactor: 10, // Lists cost 10x more than scalars
});
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [ComplexityLimitRule],
});
Alternatively, use graphql-query-complexity:
import queryComplexity, {
simpleEstimator,
fieldExtensionsEstimator,
} from 'graphql-query-complexity';
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
{
requestDidStart: () => ({
didResolveOperation: ({ request, document }) => {
const complexity = queryComplexity.getComplexity({
schema,
query: document,
variables: request.variables ?? {},
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 }),
],
});
if (complexity > 1000) {
throw new Error(`Query complexity ${complexity} exceeds limit of 1000`);
}
},
}),
},
],
});
Authorization on Every Resolver
The most common authorization mistake in GraphQL is implementing auth at the route level — checking that the user is authenticated when they hit the /graphql endpoint — but failing to check authorization on individual resolvers. Because all operations go through one endpoint, a user authenticated for viewer role can potentially access adminMutation fields.
Authorization at the field level
import { GraphQLError } from 'graphql';
const resolvers = {
Query: {
publicPosts: () => Post.find({ published: true }),
// Requires authentication
myDraftPosts: (_: unknown, __: unknown, context: AppContext) => {
if (!context.user) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
return Post.find({ authorId: context.user.userId, published: false });
},
// Requires admin role
allUsers: (_: unknown, __: unknown, context: AppContext) => {
if (!context.user) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
if (context.user.role !== 'admin') {
throw new GraphQLError('Admin access required', {
extensions: { code: 'FORBIDDEN' },
});
}
return User.find({});
},
},
};
Schema directives for authorization
Inline checks scatter authorization logic across every resolver. Schema directives centralize it:
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
import { defaultFieldResolver } from 'graphql';
function authDirectiveTransformer(schema: GraphQLSchema) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const authDirective = getDirective(schema, fieldConfig, 'auth')?.[0];
if (authDirective) {
const { requires } = authDirective;
const { resolve = defaultFieldResolver } = fieldConfig;
fieldConfig.resolve = async (source, args, context, info) => {
if (!context.user) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
if (requires && context.user.role !== requires) {
throw new GraphQLError('Insufficient permissions', {
extensions: { code: 'FORBIDDEN' },
});
}
return resolve(source, args, context, info);
};
}
return fieldConfig;
},
});
}
SDL usage:
directive @auth(requires: Role = USER) on FIELD_DEFINITION
enum Role { USER ADMIN }
type Query {
publicData: String
userData: UserProfile @auth
adminReport: AdminData @auth(requires: ADMIN)
}
GraphQL Injection
GraphQL queries are parsed by the library, not executed as strings, so traditional SQL injection via the query structure is not a concern. The injection risks are in resolvers that take arguments and pass them to databases or other systems.
MongoDB injection via GraphQL arguments
// DANGEROUS — passes user input directly to Mongoose
const resolvers = {
Query: {
searchUsers: (_: unknown, { filter }: { filter: object }) =>
User.find(filter), // ❌ filter could be { $where: "..." }
},
};
// SAFE — use a typed input with explicit field mapping
const resolvers = {
Query: {
searchUsers: (_: unknown, { name, email }: { name?: string; email?: string }) => {
const query: Record<string, unknown> = {};
if (name) query.name = { $regex: name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), $options: 'i' };
if (email) query.email = email.toLowerCase();
return User.find(query);
},
},
};
Define strict input types in your SDL. GraphQL's type system is your first line of defense:
input UserSearchInput {
name: String
email: String
role: Role
}
type Query {
searchUsers(filter: UserSearchInput!): [User!]!
}
Persisted Queries
Persisted queries lock down your GraphQL API to only the operations your own clients send. At startup, your server loads a map of { queryHash: queryDocument }. Clients send only the hash; the server looks up the full query. Unknown hashes are rejected.
This eliminates ad-hoc queries from attackers entirely:
import { ApolloServer } from '@apollo/server';
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
// Server: only accept known hashes
const server = new ApolloServer({
typeDefs,
resolvers,
allowBatchedHttpRequests: false,
persistedQueries: {
cache: new InMemoryLRUCache({ maxSize: 1000 }),
// Return error for unknown queries
ttl: null,
},
// In strict mode, reject any query not in the known hash set
formatError: (err) => {
if (err.extensions?.code === 'PERSISTED_QUERY_NOT_FOUND') {
// Log and return user-facing error
}
return err;
},
});
Persisted queries also improve performance: clients send a 32-byte SHA-256 hash instead of a potentially multi-kilobyte query string.
Additional Controls
- Request size limits: Reject requests over a maximum body size (e.g., 100KB) to prevent trivial DoS via large string arguments.
- Batching limits: If you allow batched requests, limit the number of operations per batch.
- Rate limiting by operation name: Apply stricter limits to expensive operations using the parsed
operationName. - Timeouts: Set a resolver execution timeout to prevent slow queries from consuming worker threads.
// Set timeout at the HTTP level
app.use('/graphql', (req, res, next) => {
res.setTimeout(10000, () => {
res.status(408).json({ errors: [{ message: 'Request timeout' }] });
});
next();
});
GraphQL security requires intentional configuration at every layer. Disabling introspection removes your schema from attackers, complexity and depth limits prevent resource exhaustion, field-level authorization ensures no resolver is accidentally public, and persisted queries eliminate the entire class of ad-hoc query attacks.