GraphQL Security
GraphQL exposes new attack surfaces REST doesn’t. Clients can construct arbitrary queries — including deeply nested, hugely expensive ones. Without bounds, your server is a free DoS target.
The threats specific to GraphQL
| Threat | What |
|---|---|
| Deep nesting | {a {a {a {a {a ...}}}}} exploding into N+1+1+1 query |
| Wide aliasing | {a: user(...), b: user(...), c: user(...), ... × 10000} |
| Introspection in production | schema leak / reconnaissance |
| Arbitrary query construction | clients can compose expensive shapes you didn’t anticipate |
| Auth at the wrong layer | top-level checks let queries reach resolvers that should be protected |
Query depth limiting
Cap how deeply nested a query can go:
# Strawberry / graphql-core
from graphql.validation import depth_limit_validator
schema = strawberry.Schema(
query=Query,
validation_rules=[depth_limit_validator(max_depth=10)],
)
max_depth=10 rejects:
{
user {
posts {
comments {
author {
posts { # depth 5
comments { ... } # depth 7+
}
}
}
}
}
}
Reasonable depths: 5–10 for most APIs. Higher = legitimately complex; lower = paranoid.
Query complexity / cost analysis
Depth alone isn’t enough. A wide query at depth 2 can be more expensive than a deep one:
{
users(first: 10000) { # 10000 users
posts(first: 1000) { # × 1000 posts
author { name } # × 1 lookup
}
}
}
Cost analysis assigns a numeric cost per field; the query is rejected if the total exceeds a budget.
# Conceptual
@strawberry.field(complexity=lambda first: first or 1)
def users(self, first: int = 20) -> list[User]: ...
Libraries: graphql-cost-analysis, graphql-query-complexity. They estimate cost as the query is parsed; reject before execution starts.
Set a budget per client class:
- Unauthenticated: 1000.
- Authenticated user: 10000.
- Service-to-service: 100000.
Logging is essential — too-tight limits frustrate users; too-loose lets attackers DoS.
Query timeout
Belt and suspenders. Even with depth + cost limits, set a hard execution timeout:
async def execute_with_timeout(schema, query, timeout=10):
try:
return await asyncio.wait_for(schema.execute(query), timeout=timeout)
except asyncio.TimeoutError:
return {"errors": [{"message": "Query timeout", "code": "TIMEOUT"}]}
Catches the case where your cost estimate was wrong (an unforeseen expensive resolver).
Persisted queries — the strongest defense
The most effective security control: only accept queries registered at deploy time.
POST /graphql
{"queryId": "sha256:abc123", "variables": {"id": 42}}
The server has a registry of known queries (built from client code at deploy time). Unknown queryId → reject.
Effects:
- Clients can’t construct arbitrary queries → no depth/cost surprises.
- Smaller request bodies.
- Cacheable via GET (since the URL is now stable).
Cons:
- Build step required.
- Less flexible for ad-hoc queries (you can support both: persisted in production, full queries in dev).
Apollo and Relay have “automatic persisted queries” (APQ) — client hashes query, sends hash; on miss, server requests full query and registers it. Useful pattern; not as strong as pre-registration.
Disable introspection in production
schema = strawberry.Schema(
query=Query,
config=StrawberryConfig(disable_introspection=True),
)
Or with middleware:
def introspection_blocker(next_, root, info, **args):
if info.field_name in ("__schema", "__type"):
if not is_admin(info.context):
raise Exception("Introspection disabled")
return next_(root, info, **args)
Introspection isn’t real security — clients have the schema in their bundled code. But it removes the casual “send __schema and discover everything” attack. Pair with disabling GraphiQL/playground UIs in production.
Authentication
Same as REST — bearer tokens in Authorization header, validate at the framework boundary, attach user to context.
async def get_context(request: Request):
token = request.headers.get("Authorization", "").removeprefix("Bearer ")
user = await validate_token(token) if token else None
return {"user": user, "request": request}
schema = strawberry.Schema(query=Query)
graphql_app = GraphQLRouter(schema, context_getter=get_context)
Resolvers access via info.context["user"].
Authorization — per-field
In REST you authorize at the endpoint level: “is this user allowed to call GET /admin/users?” In GraphQL the same query can pull from public and private fields:
{
user(id: 42) {
name # public
email # private (only owner or admin)
adminNotes # only admin
}
}
You need per-field authorization. Three approaches:
Resolver-level checks
@strawberry.field
def email(self, info) -> str:
user = info.context["user"]
if user.id != self.id and not user.is_admin:
raise Forbidden("Cannot view email")
return self._email
Verbose but explicit. Works for one-off checks.
Directives
type User {
name: String!
email: String! @auth(requires: [SELF, ADMIN])
adminNotes: String @auth(requires: [ADMIN])
}
Cleaner schema, custom directive handles the check in middleware.
Field-level middleware / extensions
Run a function before each resolver:
def auth_middleware(next_, root, info, **args):
if needs_auth(info.field_name) and not info.context["user"]:
raise Forbidden()
return next_(root, info, **args)
Apollo Server’s “field policies” or Strawberry’s “permission classes” implement this pattern.
Validation: input length, content
Schema scalars don’t enforce business limits. String! accepts any length. You must:
def validate(self, value):
if len(value) > 1000:
raise ValueError("Too long")
Custom scalars (Email, URL, PositiveInt) help. Most libraries have community packages with validation built in.
Watch out for:
- Unbounded list arguments (
tags: [String!]!with 10000 entries). - Unbounded
first: Intpagination. - String content (SQL injection, XSS — usually not GraphQL-specific but easy to ignore).
Rate limiting
Per-IP or per-user rate limits on /graphql:
@app.post("/graphql", dependencies=[Depends(rate_limit)])
async def graphql_endpoint(request: Request): ...
But a single request can vary wildly in cost. Better: combine rate limit (req/sec) with cost limit (units/min). E.g. “100 requests/min OR 100000 cost units/min, whichever first.”
CSRF
GraphQL is typically POST + JSON. If your auth is cookie-based, you’re vulnerable to CSRF — same as REST. Mitigations:
SameSite=LaxorStrictcookies (modern default).- Custom
Content-Type: application/jsonrequirement (preflight forces CORS). - CSRF tokens.
Authorizationheader instead of cookies (no automatic browser submission).
If you accept GET (for persisted queries), CSRF protections matter more — image tag exploits are possible.
Subscription auth
WebSocket connections need their own auth. The graphql-ws protocol’s Connection Init payload carries auth:
const client = new WebSocket("wss://api/graphql");
// On open:
client.send({ type: "connection_init", payload: { authToken: "Bearer ..." } });
Server validates the token on connection init. Subsequent subscription messages use the connection’s auth context.
Don’t rely on cookies for WebSocket auth — they’re sent on the upgrade request but not all WS frameworks expose them.
Common pitfalls
- No depth or cost limit — attacker constructs a 50-level deep query that fans out exponentially.
- Introspection in production with sensitive schema — competitors / attackers see your full schema.
- Authorizing at the route, not the field —
/graphqlallows anyone; the query inside can pull from sensitive fields. - Trusting variables but not the query string — both come from the client. Validate both.
first: Intwith no cap —first: 100000000exhausts memory.- Mutation that doesn’t authorize — pure-resolver-level “is logged in?” check; misses “is allowed to do this specific mutation?”
Common interview confusions
- “GraphQL is more secure than REST because it’s typed.” — typing helps internal correctness; doesn’t prevent abuse. Without limits, GraphQL exposes more attack surface than REST.
- “Disabling introspection makes the API private.” — it makes discovery harder. The schema is still in client bundles. Pair with persisted queries for stronger control.
- “Auth on each resolver is the only way.” — directives + middleware can centralize it; resolver-level checks are appropriate for one-off rules.
Interview angle
- “What are the security risks specific to GraphQL?” — deeply nested queries (cost explosion), wide aliasing (alias bomb), arbitrary query construction (attacker writes expensive queries), introspection leaking schema, per-field authorization complexity.
- “How do you protect against expensive queries?” — query depth limit + query cost analysis (assign costs to fields, reject above budget) + execution timeout + persisted queries (only registered queries allowed).
- “What are persisted queries?” — query strings registered at deploy time, referenced by hash. Server rejects unknown hashes. Smaller bodies, GET-cacheable, eliminates arbitrary query construction.
- “Where do you authorize fields in GraphQL?” — per-field, not per-endpoint. Approaches: resolver-level checks, custom
@authdirectives, field middleware. Don’t authorize only at the route boundary — a single/graphqlrequest mixes public and private fields. - “Should introspection be enabled in production?” — for public-facing APIs you control with persisted queries, no. For internal APIs or developer experience, often yes. It’s not real security (clients have the schema in code) but reduces casual reconnaissance.
- “How do you rate-limit GraphQL?” — combine request rate (req/sec) with query cost budget (cost units/min). Single rate limit doesn’t account for one-cheap-vs-one-expensive request variance.