GraphQL Security

Updated 6 interview angles 6 min read source
On this page15
  1. The threats specific to GraphQL
  2. Query depth limiting
  3. Query complexity / cost analysis
  4. Query timeout
  5. Persisted queries — the strongest defense
  6. Disable introspection in production
  7. Authentication
  8. Authorization — per-field
  9. Validation: input length, content
  10. Rate limiting
  11. CSRF
  12. Subscription auth
  13. Common pitfalls
  14. Common interview confusions
  15. Interview angle

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:

python
# 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:

graphql
{
  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:

graphql
{
  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.

python
# 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:

python
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.

http
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

python
schema = strawberry.Schema(
    query=Query,
    config=StrawberryConfig(disable_introspection=True),
)

Or with middleware:

python
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.

python
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:

graphql
{
  user(id: 42) {
    name              # public
    email             # private (only owner or admin)
    adminNotes        # only admin
  }
}

You need per-field authorization. Three approaches:

Resolver-level checks

python
@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

graphql
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:

python
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:

python
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: Int pagination.
  • String content (SQL injection, XSS — usually not GraphQL-specific but easy to ignore).

Rate limiting

Per-IP or per-user rate limits on /graphql:

python
@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=Lax or Strict cookies (modern default).
  • Custom Content-Type: application/json requirement (preflight forces CORS).
  • CSRF tokens.
  • Authorization header 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:

javascript
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/graphql allows anyone; the query inside can pull from sensitive fields.
  • Trusting variables but not the query string — both come from the client. Validate both.
  • first: Int with no capfirst: 100000000 exhausts 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 6

  • “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 @auth directives, field middleware. Don’t authorize only at the route boundary — a single /graphql request 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.