GraphQL Security

6 interview angles 6 min read source

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

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

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

  • “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.