GraphQL Security for Node.js and Next.js: Attacks, Defenses, and Production Checklists
Introduction
GraphQL is a staple in the modern Node.js stack. Startups love it because a single endpoint replaces a dozen REST routes, clients fetch exactly what they need, and tooling like Apollo Client and TanStack Query make frontend integration seamless. Pair it with Next.js Server Components and you have a powerful full-stack data layer.
But GraphQL's flexibility is also its biggest security challenge. A REST API has a fixed surface — you know every endpoint and every payload shape. GraphQL's surface is infinite: an attacker can craft arbitrary queries, nest resolvers 20 levels deep, request the same field under 500 aliases, and enumerate every type in your schema — all through a single POST /graphql.
In this guide, you'll learn:
- The six most critical GraphQL attack vectors in Node.js/Next.js apps
- Production defenses with code examples (Apollo Server, Yoga, GraphQL Armor)
- How to test your GraphQL security with open-source tooling
- A deployment checklist for safe GraphQL in production
1. Introspection Abuse and Schema Leaks
Introspection is a core GraphQL feature — clients use it to generate type-safe queries and power GraphiQL/GraphQL Playground. But in production, an exposed introspection query reveals your entire schema, including internal types, deprecated fields, and mutation names.
The Attack
# Anyone can send this to your /graphql endpoint
query __schema {
__schema {
types {
name
fields {
name
type { name kind }
}
}
}
}
An attacker learns your resetPassword, impersonateUser, or adminDeleteAccount mutations exist, then targets them individually.
The Defense
Apollo Server 4:
// src/lib/apollo.ts
import { ApolloServer } from "@apollo/server";
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== "production",
});
Yoga (GraphQL Yoga):
// src/lib/yoga.ts
import { createYoga } from "graphql-yoga";
export const yoga = createYoga({
schema,
graphqlEndpoint: "/api/graphql",
graphiql: process.env.NODE_ENV !== "production",
});
Next.js API route with conditional introspection:
// src/app/api/graphql/route.ts
import { createYoga } from "graphql-yoga";
import { schema } from "@/lib/schema";
const { GET, POST } = createYoga({
schema,
graphqlEndpoint: "/api/graphql",
graphiql: process.env.NODE_ENV === "development",
// Also: disable __schema on the resolver level for defense-in-depth
});
export { GET, POST };
Defense-in-depth: Set
introspection: falsein production on your GraphQL server AND block it at the CDN/WAF level. Some load balancers (Cloudflare, AWS WAF) have GraphQL-specific rules that can reject queries containing__schemaor__type.
2. Query Depth and Complexity Attacks
The #1 GraphQL denial-of-service vector. A single query can force your server to resolve thousands of nested database calls.
The Attack: Deeply Nested Query
query DeepNest {
users {
posts {
comments {
author {
posts {
comments {
author { name }
}
}
}
}
}
}
}
A trivial query like this, running 4-5 levels deep, can trigger exponential resolver chains. With aliases, it gets worse:
query Bomb {
a1: users { posts { title } }
a2: users { posts { title } }
# ... repeat 499 more times
a500: users { posts { title } }
}
The Defense: GraphQL Armor
GraphQL Armor is the most battle-tested defense library for Node.js. It protects against depth, aliases, cost, and more.
npm install graphql-armor
// src/lib/envelop.ts
import { EnvelopArmorPlugin } from "@graphql-armet";
const armor = EnvelopArmorPlugin({
// Max query depth — blocks anything deeper than 6 levels
maxDepth: {
n: 6,
onAccept: [],
onReject: [],
},
// Max aliases — blocks queries with more than 15 field aliases
maxAliases: {
n: 15,
},
// Max tokens — blocks queries with more than 1000 lexical tokens
maxTokens: {
n: 1000,
},
});
// Apply to Apollo Server
const server = new ApolloServer({
schema,
plugins: [armor],
});
With Yoga (built-in plugins):
// src/lib/yoga.ts
import { createYoga } from "graphql-yoga";
import { useDepthLimit } from "@envelop/depth-limit";
import { useAliases } from "@envelop/aliases";
export const yoga = createYoga({
schema,
plugins: [
useDepthLimit({ maxDepth: 6 }),
useAliases({ maxAliases: 15 }),
],
});
Cost-Based Rate Limiting (Advanced)
For fine-grained control, calculate the cost of each query by assigning weights to fields:
// src/lib/cost-limit.ts
import { createCostLimitRule } from "graphql-cost-analysis";
const costLimitRule = createCostLimitRule({
maxCost: 1000,
defaultCost: 1,
// Mutations cost more
mutationCost: 5,
// List fields cost per item
listCost: 1,
});
This catches attacks that fall below depth limits but hammer expensive list resolvers.
3. Batching Attacks (Resource Enumeration)
GraphQL supports batching — sending multiple queries or mutations in a single request. Apollo's @apollo/client uses this legitimately. But batching can be weaponized to brute-force user IDs without triggering per-request rate limits.
The Attack
[
{ "query": "{ user(id: 1) { email } }" },
{ "query": "{ user(id: 2) { email } }" },
{ "query": "{ user(id: 3) { email } }" },
# ... 100 more user IDs
]
One HTTP request, 100 queries. Without batch-aware rate limiting, each "request" looks innocent, but the attacker enumerates 5,000 users in 50 requests.
The Defense
Disable request batching unless you need it:
// Apollo Server 4 disables batching by default
// When you DO need it, apply batch-aware rate limiting:
// src/lib/batch-limiter.ts
let batchCounter = 0;
const BATCH_HARD_LIMIT = 10;
export function checkBatchLimit(body: unknown): void {
if (Array.isArray(body)) {
batchCounter += body.length;
if (body.length > BATCH_HARD_LIMIT) {
throw new Error("Batch size exceeds limit");
}
}
}
// src/middleware.ts
import { NextRequest, NextResponse } from "next/server";
export function middleware(req: NextRequest) {
// Some batch limiting at the network edge
if (req.nextUrl.pathname === "/api/graphql" && req.method === "POST") {
const response = NextResponse.next();
response.headers.set("X-GraphQL-Batch-Limit", "10");
return response;
}
return NextResponse.next();
}
Better: Use persisted operations. Apollo's persisted queries registry (or GraphQL Yoga's Trusted Documents) turns GraphQL into an allowlist — only pre-registered queries execute:
// next.config.ts — with persisted queries
const nextConfig = {
// ...
};
// On the server, reject queries not in the persisted list
// See: Apollo Persisted Queries or Yoga's Trusted Documents
With persisted operations, batching attacks are impossible because every query must be pre-approved.
4. Resolver-Level Authorization Bypasses
The most common GraphQL vulnerability in production systems: an authorization check at the query level, but not at the resolver level.
The Pattern That Fails
// src/app/api/graphql/route.ts
// BAD: Auth check only in the route handler
export async function POST(req: NextRequest) {
const user = await authenticate(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
// Execute the full GraphQL query
const result = await yoga.process(req);
return NextResponse.json(result);
}
This checks the user is logged in — but it doesn't check what they can do. An attacker can still query adminDashboard { revenue } because the auth check is at the HTTP layer, not the resolver layer.
The Fix: Authorization per Resolver
// src/graphql/resolvers/user.ts
interface Context {
user: { id: string; role: "admin" | "user" } | null;
}
export const resolvers = {
Query: {
users: async (_: unknown, __: unknown, ctx: Context) => {
// Check authorization HERE, at the resolver
if (!ctx.user || ctx.user.role !== "admin") {
throw new GraphQLError("Forbidden", {
extensions: { code: "FORBIDDEN" },
});
}
return db.user.findMany();
},
userProfile: async (_: unknown, args: { id: string }, ctx: Context) => {
// Users can only see their own profile (unless admin)
if (ctx.user?.id !== args.id && ctx.user?.role !== "admin") {
throw new GraphQLError("Forbidden", {
extensions: { code: "FORBIDDEN" },
});
}
return db.user.findUnique({ where: { id: args.id } });
},
},
Mutation: {
deleteUser: async (_: unknown, args: { id: string }, ctx: Context) => {
if (!ctx.user || ctx.user.role !== "admin") {
throw new GraphQLError("Forbidden", {
extensions: { code: "FORBIDDEN" },
});
}
return db.user.delete({ where: { id: args.id } });
},
},
};
Authorization with Middleware (graphql-shield)
For larger schemas, use graphql-shield to declare authorization rules declaratively:
npm install graphql-shield
// src/lib/shield.ts
import { shield, rule, and, or } from "graphql-shield";
import { ForbiddenError } from "apollo-server-errors";
const isAuthenticated = rule()(async (_parent, _args, ctx) => {
return ctx.user !== null;
});
const isAdmin = rule()(async (_parent, _args, ctx) => {
return ctx.user?.role === "admin";
});
const isOwnProfile = rule()(async (_parent, args, ctx) => {
return ctx.user?.id === args.id;
});
export const permissions = shield({
Query: {
users: isAdmin,
userProfile: isAuthenticated,
},
Mutation: {
deleteUser: isAdmin,
updateProfile: isAuthenticated,
// ... every mutation needs a rule
},
});
Apply it to your server:
import { applyMiddleware } from "graphql-middleware";
import { permissions } from "@/lib/shield";
const schemaWithAuth = applyMiddleware(schema, permissions);
Golden rule: Every resolver must check authorization. There is no "global" auth for GraphQL — the client constructs arbitrary traversal paths, and authorization must be checked at every branch.
5. Injection via GraphQL Variables
GraphQL variables reduce the risk of injection compared to REST, but they don't eliminate it — especially with NoSQL databases like MongoDB.
The Attack: NoSQL Injection
query Login($email: String!, $password: String!) {
login(email: $email, password: $password) { token }
}
# Variables:
{ "email": "admin@example.com", "password": { "$ne": "" } }
If your resolver passes $password directly to MongoDB without type checking, the $ne operator bypasses password verification entirely.
The Defense: Type-Strict Variables
// src/graphql/resolvers/auth.ts — VULNERABLE
export const resolvers = {
Mutation: {
login: async (_: unknown, args: { email: string; password: unknown }) => {
// DANGER: args.password could be an object!
const user = await db.users.findOne({
email: args.email,
password: args.password, // <-- passes object to MongoDB
});
return user ? { token: signToken(user) } : null;
},
},
};
// src/graphql/resolvers/auth.ts — SAFE
export const resolvers = {
Mutation: {
login: async (_: unknown, args: { email: unknown; password: unknown }) => {
// Validate types before use
if (typeof args.email !== "string" || typeof args.password !== "string") {
throw new GraphQLError("Bad request", {
extensions: { code: "BAD_USER_INPUT" },
});
}
const user = await db.users.findOne({
email: args.email,
password: hash(args.password), // <-- hash before query
});
return user ? { token: signToken(user) } : null;
},
},
};
For REST endpoints that proxy GraphQL variables, use Zod:
// src/lib/validate-variables.ts
import { z } from "zod";
const loginVariablesSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export function validateVariables<T>(schema: z.ZodType<T>, variables: unknown): T {
return schema.parse(variables);
}
6. Information Disclosure in Errors
GraphQL's detailed error messages are great for debugging — and great for attackers mapping your infrastructure.
The Attack
// Error response leak
{
"errors": [
{
"message": "Cannot read properties of null (reading 'email')",
"locations": [{ "line": 2, "column": 3 }],
"path": ["user", "email"],
"extensions": {
"stacktrace": [
"TypeError: Cannot read properties of null (reading 'email')",
" at resolveUser (/app/src/resolvers/user.js:42:17)",
" at /app/node_modules/@graphql-tools/delegate/esm/delegateToSchema.js:98:20"
]
}
}
]
}
The stacktrace reveals:
- Internal file paths (
/app/src/resolvers/) - Node module versions (
@graphql-tools/delegate) - Server architecture (Linux paths)
The Defense: Redact Stack Traces in Production
// src/lib/apollo.ts
import { ApolloServer } from "@apollo/server";
const server = new ApolloServer({
schema,
formatError: (formattedError) => {
// In production, strip all extensions except safe codes
if (process.env.NODE_ENV === "production") {
return {
message: formattedError.message,
extensions: formattedError.extensions?.code
? { code: formattedError.extensions.code }
: undefined,
};
}
return formattedError;
},
});
With Yoga:
// src/lib/yoga.ts
import { createYoga } from "graphql-yoga";
export const yoga = createYoga({
schema,
maskedErrors: process.env.NODE_ENV === "production",
// Custom error map for production-safe messages
errorFormatter: (error) => {
if (process.env.NODE_ENV === "production") {
return {
message: "Internal server error",
extensions: { code: "INTERNAL_ERROR" },
};
}
return error;
},
});
7. CSRF via GET Mutations
GraphQL supports mutations over GET requests — and some clients do this for caching. An attacker can embed a mutation in an <img> tag:
<img src="https://yourapp.com/graphql?query=mutation{logout{success}}" />
If your GraphQL endpoint accepts GET mutations and cookies are used for auth, visiting any page with that image logs the user out.
The Defense
Disable mutations on GET requests:
// src/lib/yoga.ts
import { createYoga } from "graphql-yoga";
export const yoga = createYoga({
schema,
// Allow only queries (not mutations) on GET
batching: false,
});
// Or in your route handler:
// src/app/api/graphql/route.ts
export async function GET(req: NextRequest) {
// Reject mutations on GET
const url = new URL(req.url);
const query = url.searchParams.get("query") ?? "";
if (query.includes("mutation")) {
return NextResponse.json({ error: "Mutations not allowed on GET" }, { status: 405 });
}
return yoga.fetch(req);
}
Better: Serve GraphQL exclusively over POST and reject GET entirely:
// src/app/api/graphql/route.ts
export { POST } from "@/lib/yoga";
// ⚠️ No GET export — Next.js returns 405 for unexported methods
Testing Your GraphQL Security
Three tools to run before every deployment:
1. GraphQL Map (Escape)
npx @escape.tech/graphql-map https://your-graphql-endpoint.com/api/graphql
Generates a map of your entire schema — exactly what an attacker would see. If introspection is disabled, it returns minimal info. Run this on production to verify introspection is truly off.
2. InQL (PortSwigger)
Burp Suite extension for GraphQL security testing. Records queries, detects batching attacks, and tests depth limits. Install from the Burp App Store or use the standalone version.
3. graphql-query-analyzer
npx graphql-query-analyzer --depth 10 --complexity 500 yourschema.graphql
Scans your schema for expensive resolvers and recommends cost limits.
Production Deployment Checklist
Before deploying your GraphQL API:
- [ ] Introspection disabled —
introspection: falsein production - [ ] Query depth limit — maxDepth ≤ 8 (6 recommended)
- [ ] Alias limit — maxAliases ≤ 15
- [ ] Query cost analysis — maxCost enforced on all operations
- [ ] Resolver-level auth — every resolver has its own authorization check
- [ ] No stack traces in errors —
formatErrorstrips extensions in production - [ ] GET mutations disabled —
POST-only endpoint for mutations - [ ] Batch size limited — max 10 operations per batch (or disable batching)
- [ ] Rate limiting — per-IP and per-token sliding window (token bucket or leaky bucket)
- [ ] Input validation — Zod or similar for GraphQL variable types at resolver entry
- [ ] Persisted operations considered — for high-security apps, use document allowlisting
- [ ] Logging + monitoring — every rejected query logged with IP, operation name, and rejection reason
- [ ] npx @escape.tech/graphql-map passes — no unexpected schema fields exposed
Conclusion
GraphQL's power comes from its flexibility — but that same flexibility demands a fundamentally different security mindset than REST. You can't secure a GraphQL API by locking down endpoints; you must secure each resolver, limit query complexity, control introspection, and validate variables at the boundary.
The key takeaways for your Next.js + Node.js GraphQL API:
- Introspection is a debug tool, not a production feature — disable it.
- Every resolver is a security boundary — authorize there, not in the route handler.
- Attackers can craft unbelievably expensive queries — depth, alias, and cost limits are mandatory.
- GraphQL variables are not sanitized — type-check them before they reach your database.
- Error messages are intelligence reports — redact stack traces in production.
Start with GraphQL Armor (or Yoga's built-in plugins) for the DoS protections, add graphql-shield for declarative authorization, and run graphql-map before every deployment to catch schema leaks. Your API clients will thank you — and your attackers won't have anything to exploit.
Need a professional review of your GraphQL implementation? Schedule a security audit — we'll test depth limits, authorization rules, enumeration vectors, and query cost boundaries.
JS Security Audit — Professional React, Next.js, and Node.js security audits.
JS Security Audit
Senior JavaScript security consultants with 10+ years of experience.