GraphQL Security Best Practices

GraphQL security differs from REST because one endpoint replaces dozens. A REST API spreads risk across many URLs, each with its own rate limit and access check. GraphQL collapses that into a single /graphql route where the client builds the query, so authorization, resource limits, and input validation all move from the transport layer into the query itself.

That shift is not academic. OWASP now treats GraphQL-specific abuse as a standard part of its API security guidance, because the flaws that hit GraphQL deployments don't map onto REST's endpoint-by-endpoint model. Teams that apply only REST-era controls end up with a schema that documents itself to attackers and a query engine that executes whatever shape of request it receives. This post covers the risk classes that matter most, as a companion to application security best practices and API security best practices.

Why is introspection dangerous in production? #

Introspection is a built-in GraphQL feature that lets a client ask the schema to describe itself: every type, field, argument, and mutation. It's genuinely useful in development, powering tools like GraphiQL and client codegen. Left enabled in production, it hands an attacker your full API surface without a single guess. They don't need to fuzz endpoints or scrape documentation. One query returns the entire object graph, including internal-sounding fields and mutations nobody meant to expose.

Fix: disable introspection in production builds, or restrict it to authenticated internal tooling behind a separate gateway. Most GraphQL servers (Apollo, GraphQL Yoga, graphql-ruby) expose a single config flag for this. If your public API genuinely needs a browsable schema, publish it as static documentation instead of leaving the live introspection query open to anyone.

How do you prevent query depth and complexity attacks? #

GraphQL's flexibility is also its denial-of-service risk. A client can nest a query arbitrarily deep, or request a field that fans out into thousands of child objects, and the server will try to resolve all of it. A query like user { friends { friends { friends { friends { name } } } } } can turn a cheap request into an exponential amount of database work. Unlike REST, where each endpoint does a fixed amount of work, a GraphQL resolver's cost depends on what the caller asks for.

Fix: set hard limits on query depth and computed complexity, and reject anything over the threshold before execution starts, not after resolvers run.

javascript.javascript

// Apollo Server: query depth + cost limiting
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';

const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(6), // reject queries nested deeper than 6 levels
createComplexityLimitRule(1000, {
// assign per-field cost so paginated/list fields
// count more than scalar fields
scalarCost: 1,
objectCost: 2,
listFactor: 10,
onCost: (cost) => console.log('query cost:', cost),
}),
],
});

Depth limits catch nested recursion; complexity (cost analysis) catches wide fan-out that touches a large number of records without being deep. Run both. A query requesting 5,000 items at depth 2 is just as damaging as one nested 20 levels deep.

What makes batching attacks a GraphQL-specific problem? #

GraphQL supports sending an array of operations in a single HTTP request. That's a legitimate feature for reducing round trips, and it's also a way to route around defenses built for one-request-per-action. A rate limiter tuned for REST typically counts HTTP requests. An attacker who batches 500 login mutations into one POST slips past that counter entirely, brute-forcing credentials while showing up in logs as a single request.

Fix: rate-limit by query cost, not request count. A batch of 500 mutations should register as 500 units of work against the limiter, not one. Cap the number of operations allowed per request for sensitive mutations like login and password reset, and consider disabling batching on public-facing schemas where it isn't a real client need.

Why does field-level authorization matter more in GraphQL? #

REST authorization usually lives at the endpoint: check the token, check the role, return the resource. GraphQL breaks that model because one query can traverse many types in a single request. It's straightforward to authorize the top-level viewer or order query and then forget that a nested field, say order.customer.ssn, is reachable through a path that never passes through the check you wrote. The gap isn't in the query root, it's three levels deep in a field nobody thought to guard.

Fix: push authorization down to the resolver or field level, not just the query root. Every resolver returning sensitive data should independently verify the caller is allowed to see it, regardless of which parent query reached it. Directive-based authorization (@auth(requires: ADMIN) on the field itself) keeps the check next to the data it protects, instead of scattered across query handlers where it's easy to miss on new fields.

How much can verbose GraphQL errors leak? #

GraphQL's error format is more expressive than a typical REST error body, and that's part of the problem. A misconfigured server will return stack traces, resolver file paths, or SQL fragments in the errors array when a query fails, and because errors describe the schema shape, they can leak field and type names an attacker hasn't discovered yet.

Fix: strip stack traces and internal details from error responses, and log the full detail server-side instead. Most GraphQL servers support a formatError (or equivalent) hook for this. For anything customer-facing, return a generic message and an error code, and keep diagnostic detail out of the response body entirely.

Locking the schema down: persisted queries #

The strongest defense for a public-facing GraphQL API is to stop accepting arbitrary queries at all. Persisted queries: the client sends a hash of a pre-approved query instead of the query text, and the server only executes queries on its allowlist. Combined with disabled introspection, this turns GraphQL's biggest attack surface, the ability to ask for anything, into a fixed, auditable set of known operations. It works best for first-party clients rather than third-party consumers who need query flexibility.

The call we'd make #

Treat GraphQL like what it is: a query language sitting on top of your data graph, not a REST API with extra syntax. Disable introspection outside development, enforce depth and complexity limits before execution, rate-limit by cost rather than request count, and move authorization checks down to the field level. If the API serves your own clients, adopt persisted queries and stop accepting arbitrary schema traversal from the open internet. Each is a small, bounded change. Skipping any one turns your schema into documentation handed to the attacker for free.