graphql-security
GraphQL Security
GraphQL's flexibility — arbitrary queries, nested traversal, batched operations — creates attack surfaces that don't exist in REST. Each must be explicitly mitigated.
1. Disable Introspection in Production
Introspection exposes the full schema: every type, field, mutation, and their relationships. It's a free recon tool for attackers.
// Apollo Server
new ApolloServer({
introspection: process.env.NODE_ENV !== 'production',
});
// GraphQL Yoga / envelop
import { useDisableIntrospection } from '@envelop/disable-introspection';
plugins: [useDisableIntrospection()];
// graphql-js (manual)
const NO_INTROSPECTION_RULE = (context) => ({
Field({ name }) {
if (name.value === '__schema' || name.value === '__type') {
context.reportError(new GraphQLError('Introspection disabled'));
}
}
});
validationRules: [NO_INTROSPECTION_RULE]
Exceptions: keep introspection on in staging behind auth, or use @apollo/server persisted queries to lock the schema surface entirely.
2. Query Depth Limiting
Deeply nested queries can exhaust memory before hitting a timeout.
# Attack: exponential traversal
{ user { friends { friends { friends { friends { posts { comments { author { friends { ... } } } } } } } } } }
import depthLimit from 'graphql-depth-limit';
new ApolloServer({
validationRules: [depthLimit(7)], // 5–10 is reasonable for most schemas
});
Pick depth based on your schema's legitimate max. Map your deepest real query first — then add 2 levels of headroom.
3. Query Complexity Limiting
Depth alone doesn't catch wide queries. Complexity assigns cost per field and rejects queries over a budget.
import { createComplexityRule } from 'graphql-query-complexity';
validationRules: [
createComplexityRule({
maximumComplexity: 1000,
variables: {},
onComplete(complexity) {
console.log('Query complexity:', complexity);
},
estimators: [
// Lists cost more than scalars
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 }),
],
}),
]
Annotate expensive fields in the schema:
type Query {
users(limit: Int): [User] @complexity(value: 10, multipliers: ["limit"])
user(id: ID!): User @complexity(value: 1)
}
4. Batching Abuse
GraphQL allows multiple operations in one request. Without limits, one request can become thousands of DB queries.
// Single HTTP request, 500 queries
[
{ "query": "{ user(id: \"1\") { email } }" },
{ "query": "{ user(id: \"2\") { email } }" },
...499 more
]
// Apollo Server — disable or limit batching
new ApolloServer({
allowBatchedHttpRequests: false, // disable entirely if not needed
});
// Or limit batch size with a custom plugin
const batchLimitPlugin = {
requestDidStart() {
return {
didResolveOperation({ requestContext }) {
if (Array.isArray(requestContext.request) &&
requestContext.request.length > 10) {
throw new GraphQLError('Batch size exceeds limit');
}
}
};
}
};
5. Field-Level Authorization
GraphQL's biggest REST-unlike footgun: route-level auth isn't enough. Each resolver returns data independently — a user might be authorized to query user but not user.salary.
// ❌ Auth only on the root resolver — nested fields unprotected
const resolvers = {
Query: {
user: requireAuth((_, { id }, ctx) => db.users.findById(id)),
},
User: {
salary: (user) => user.salary, // anyone who can query User gets salary
ssn: (user) => user.ssn, // same
},
};
// ✅ Auth at each sensitive field resolver
const resolvers = {
User: {
salary: (user, _, ctx) => {
if (ctx.user.role !== 'admin' && ctx.user.id !== user.id) return null;
return user.salary;
},
ssn: (user, _, ctx) => {
if (ctx.user.role !== 'admin') return null;
return user.ssn;
},
},
};
Shield for declarative field-level auth
import { shield, rule, and } from 'graphql-shield';
const isAdmin = rule()((_, __, ctx) => ctx.user?.role === 'admin');
const isOwner = rule()((parent, _, ctx) => parent.id === ctx.user?.id);
export const permissions = shield({
Query: { user: isAuthenticated },
User: {
salary: and(isAuthenticated, isOwner), // own record only
ssn: isAdmin,
},
Mutation: {
deleteUser: isAdmin,
},
});
6. N+1 and Data Exposure via DataLoader
Without DataLoader, nested queries cause N+1 DB hits — also a DoS vector.
// ❌ N+1: 1 query for posts + N queries for each author
Post: {
author: (post) => db.users.findById(post.author_id)
}
// ✅ DataLoader batches into 1 query
import DataLoader from 'dataloader';
const userLoader = new DataLoader(async (ids: string[]) =>
db.users.findMany({ where: { id: { in: ids } } })
.then(users => ids.map(id => users.find(u => u.id === id)))
);
Post: {
author: (post, _, ctx) => ctx.loaders.user.load(post.author_id)
}
DataLoader is a correctness fix but also a security fix — it prevents N+1 from being weaponized.
7. Non-Obvious Vectors
| Vector | Issue | Fix |
|---|---|---|
__typename in depth count |
Often excluded from depth limit plugins — still counts | Confirm your depth plugin counts it |
| Aliases bypassing rate limits | { a: user(id:1) { email } b: user(id:2) { email } } — one request, many operations |
Alias count limit or complexity covers this |
| Mutations in subscriptions | Schema allows it; should be blocked | Validate operation type at subscription handler |
| Error messages leaking schema | Default errors expose resolver internals | Mask errors in production: formatError: maskErrors |
| File upload endpoints | multipart/form-data bypasses query validation |
Apply same depth/complexity rules to upload mutations |
| Persisted queries not enforced | Full arbitrary query surface open | Use persisted queries in production to whitelist operations |
Mask errors in production
import { maskErrors } from 'graphql-errors'; // or Apollo's built-in
new ApolloServer({
formatError(err) {
if (process.env.NODE_ENV === 'production') {
// Return safe message; log full error internally
console.error(err.originalError);
return new GraphQLError('Internal server error');
}
return err;
},
});
Audit Checklist
- Introspection disabled in production
- Query depth limit set (based on schema's legitimate max + 2)
- Query complexity limit set; expensive list fields annotated with multipliers
- Batching disabled or limited to a small N
- Field-level auth on sensitive fields — not just root Query/Mutation resolvers
- DataLoader used for all relation resolvers (no N+1)
- Aliases counted in complexity — not just top-level fields
- Errors masked in production; full errors logged internally only
- Rate limiting applied at HTTP level (GraphQL is one POST endpoint)
- Persisted queries enforced in production (if security-critical)
More from blunotech-dev/agents
anti-purple-ui
Enforce a strict monochrome UI with a single high-contrast accent color, removing generic tech gradients and “AI-style” palettes. Use when the user wants minimal, anti-AI, or non-generic aesthetics, or says the UI looks too techy or generic.
9harmonize-whitespace
Align all spacing (padding, margins, gaps) to a consistent 4pt/8pt grid. Use when spacing feels off, inconsistent, cramped, or unbalanced, or when the user asks for a spacing scale or alignment fix.
9typographic-hierarchy
Improve typography by adjusting font sizes, weights, spacing, and contrast to create clear visual hierarchy and readability. Use when text feels flat, unstructured, or when the user asks to refine headings, type scale, or overall readability.
6micro-interaction-adder
Add polished CSS micro-interactions like hover effects, transitions, and feedback states to improve UI feel. Use when the user asks for animations, better UX, or when the interface feels static, plain, or unresponsive.
4consistent-border-radius
Normalizes rounded corners across a file so buttons, inputs, cards, modals, badges, and all UI elements share the exact same curvature. Use this skill whenever the user mentions inconsistent border radii, wants to unify rounded corners, asks to make UI elements look more cohesive, or says things like "make the corners match", "fix the rounding", "unify border radius", "standardize my rounded corners", or "buttons and cards don't match". Also trigger when the user pastes a CSS/HTML/JSX/TSX file and asks for a design consistency pass, border radius is one of the first things to normalize.
4component-split
Analyze a component and determine when and how to split it based on size, responsibility, and reuse signals, producing a refactored structure with clear boundaries. Use when users share large, mixed-concern, or hard-to-test components, or ask about splitting, refactoring, or improving component architecture.
3