GraphQL Security: Introspection, Query Complexity, and Injection
GraphQL introduces unique security challenges that REST APIs don't have. Learn how to disable introspection in production, limit query depth and complexity, prevent batching attacks, and enforce authorization at the field level.
GraphQL's flexibility — the ability for clients to query exactly the fields they need — is also its primary security challenge. A single GraphQL endpoint can expose every model in your system, and without careful guardrails, a malicious client can craft queries that exhaust your server, extract unauthorized data, or enumerate your entire schema.
This guide covers the GraphQL security controls every production API needs.
1. Disable Introspection in Production
GraphQL's introspection feature lets clients query the schema itself — what types exist, what fields they have, what arguments they accept. During development this is invaluable; in production it gives attackers a complete map of your API.
# A single introspection query
curl -X POST https://api.example.com/graphql \
-H 'Content-Type: application/json' \
-d '{"query": "{ __schema { types { name fields { name } } } }"}'
If introspection is enabled, this returns every type and field in your system. Attackers use this to identify mutation endpoints, admin-only fields, and potential injection points without ever reading your documentation.
Disabling with Apollo Server
import { ApolloServer } from '@apollo/server';
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== 'production', // disable in prod
});
Disabling with graphql-yoga or raw graphql-js
import { createYoga } from 'graphql-yoga';
import { NoSchemaIntrospectionCustomRule } from 'graphql';
const yoga = createYoga({
schema,
validationRules: process.env.NODE_ENV === 'production'
? [NoSchemaIntrospectionCustomRule]
: [],
});
Note: disabling introspection is not a substitute for authorization — it merely reduces information disclosure. Determined attackers can still fuzz your API.
2. Query Depth Limiting
GraphQL allows nested queries that can reach deeply into your object graph. Without depth limits, an attacker can craft a trivially small query that causes your server to resolve thousands of database calls:
# Deeply nested — each level triggers new DB queries
{
user {
friends {
friends {
friends {
friends {
friends {
name
}
}
}
}
}
}
}
Implementing Depth Limiting
npm install graphql-depth-limit
import { ApolloServer } from '@apollo/server';
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(5), // Reject queries nested more than 5 levels deep
],
});
A depth limit of 5–7 is reasonable for most APIs. Review your deepest legitimate queries before setting the limit.
3. Query Complexity Analysis
Depth alone doesn't capture complexity. A query with many sibling fields at the same depth can still be expensive. Complexity analysis assigns a cost to each field and rejects queries that exceed a threshold.
npm install graphql-query-complexity
import { ApolloServer } from '@apollo/server';
import {
createComplexityLimitRule,
fieldExtensionsEstimator,
simpleEstimator,
} from 'graphql-query-complexity';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
createComplexityLimitRule(1000, {
estimators: [
// Default cost: 1 per field
simpleEstimator({ defaultComplexity: 1 }),
// Field-level overrides via schema extensions
fieldExtensionsEstimator(),
],
onCost: (cost) => console.log(`Query complexity: ${cost}`),
}),
],
});
You can override costs per field in your type definitions:
const typeDefs = gql`
type Query {
users: [User!]! @complexity(value: 10, multipliers: ["limit"])
user(id: ID!): User @complexity(value: 1)
}
`;
4. Batching Attack Prevention
GraphQL allows query batching — sending an array of operations in a single HTTP request. This can be used to perform large numbers of operations while appearing to a rate limiter as a single request:
[
{ "query": "mutation { login(email: \"a@a.com\", password: \"password1\") { token } }" },
{ "query": "mutation { login(email: \"a@a.com\", password: \"password2\") { token } }" },
{ "query": "mutation { login(email: \"a@a.com\", password: \"password3\") { token } }" }
]
If your rate limiter counts HTTP requests rather than individual operations, this bypasses it entirely.
Mitigations
// Option 1: Disable batching entirely
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
// In your Express setup, check for array body:
app.use('/graphql', (req, res, next) => {
if (Array.isArray(req.body)) {
return res.status(400).json({ error: 'Batched requests are not allowed' });
}
next();
});
// Option 2: Limit batch size
app.use('/graphql', (req, res, next) => {
if (Array.isArray(req.body) && req.body.length > 5) {
return res.status(400).json({ error: 'Batch size limit exceeded' });
}
next();
});
Rate limit by operation count, not just request count.
5. Authorization at Field Level
A common mistake is to authorize at the resolver's entry point (e.g., the users query) but not on individual fields within the returned objects. This leads to BOLA (Broken Object Level Authorization) vulnerabilities where a user can read another user's private fields.
// WRONG — only checks if user is authenticated, not if they can see each field
const resolvers = {
Query: {
user: async (_, { id }, context) => {
if (!context.user) throw new Error('Unauthorized');
return User.findById(id); // Returns ALL fields including sensitive ones
},
},
};
Field-Level Authorization with GraphQL Shield
npm install graphql-shield
import { shield, rule, and } from 'graphql-shield';
const isAuthenticated = rule()((parent, args, ctx) => ctx.user !== null);
const isOwner = rule()((parent, args, ctx) => {
return parent.userId === ctx.user.userId;
});
const isAdmin = rule()((parent, args, ctx) => ctx.user.role === 'admin');
export const permissions = shield({
Query: {
users: isAdmin,
me: isAuthenticated,
},
User: {
email: and(isAuthenticated, isOwner),
creditCardLast4: and(isAuthenticated, isOwner),
passwordHash: isAdmin, // Should never be exposed, but just in case
},
Mutation: {
deleteUser: isAdmin,
updateProfile: and(isAuthenticated, isOwner),
},
});
Alternatively, enforce authorization inside each resolver or use a data masking approach that strips fields before returning:
const resolvers = {
User: {
email: (parent, args, context) => {
if (context.user.userId !== parent.userId && context.user.role !== 'admin') {
return null; // or throw new ForbiddenError()
}
return parent.email;
},
},
};
6. Input Validation in GraphQL
GraphQL's type system provides basic validation (string vs int, required fields), but it does not validate the semantic meaning of inputs. Always add application-level validation:
import { z } from 'zod';
const CreateUserInput = z.object({
email: z.string().email(),
password: z.string().min(8).max(128),
role: z.enum(['user', 'moderator']), // Prevent privilege escalation
});
const resolvers = {
Mutation: {
createUser: async (_, args, context) => {
const result = CreateUserInput.safeParse(args.input);
if (!result.success) {
throw new UserInputError('Invalid input', {
validationErrors: result.error.flatten(),
});
}
const { email, password, role } = result.data;
// ... create user
},
},
};
7. GraphQL-Specific Injection Attacks
While GraphQL's type system prevents traditional SQL injection at the query parsing level, injection can still occur if you use user-provided values in database queries within resolvers.
// VULNERABLE — passing GraphQL args directly to MongoDB regex
const resolvers = {
Query: {
searchUsers: async (_, { term }) => {
// If term is { "$regex": ".*", "$options": "i" }, returns all users
return User.find({ name: term });
},
},
};
// SAFE — validate and use a string pattern
const resolvers = {
Query: {
searchUsers: async (_, { term }) => {
const safe = z.string().max(100).parse(term);
return User.find({ name: { $regex: safe, $options: 'i' } });
},
},
};
Also guard against NoSQL injection by validating that input values are the expected primitive types before passing them to database queries.
8. Persisted Queries
Persisted queries are an advanced technique where only pre-registered query hashes are accepted. This prevents attackers from sending arbitrary queries at all:
import { ApolloServer } from '@apollo/server';
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
// Server — only allow registered hashes
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
{
requestDidStart() {
return {
async didResolveOperation({ request }) {
if (!request.extensions?.persistedQuery) {
throw new ForbiddenError('Only persisted queries are allowed');
}
},
};
},
},
],
});
This approach is most effective for public-facing APIs with known clients (your own web/mobile apps).
Summary Checklist
| Control | Tool |
|---|---|
| Disable introspection in production | introspection: false in ApolloServer |
| Query depth limiting | graphql-depth-limit |
| Query complexity limiting | graphql-query-complexity |
| Batch attack prevention | Middleware to reject or limit array bodies |
| Field-level authorization | graphql-shield or per-field resolver checks |
| Input validation | Zod / Joi inside resolvers |
| NoSQL injection prevention | Validate input types before DB queries |
| Persisted queries | Apollo persisted queries for known clients |
GraphQL security requires layered controls. The schema's expressiveness is a feature for legitimate clients and an attack surface for malicious ones — every production deployment needs at minimum depth limits, complexity analysis, and proper field-level authorization.