How to Threat Model a GraphQL API

Most of the advice in how to threat model a REST API still applies to GraphQL: you still need a true inventory, you still ask who can call something, what they may do with it, and what it returns, and object-level authorization is still where the breaches are. What changes is where all of that lives. A REST API spreads its attack surface across dozens or hundreds of routes, and each route is a natural place to hang an authorization check, a rate limit, a gateway rule, or a log line. GraphQL folds the whole API into a single endpoint, usually POST /graphql, and hands the client a query language to decide what each request fetches, how deep it goes, and how much work it causes. The controls that used to sit on the route have nowhere to sit.

The core shift: in GraphQL the unit you threat model is not the endpoint, it's the schema. Every type, field, and relationship edge can be reached in any combination the schema allows, and the client chooses the combination. So authorization has to hold at the resolver, and cost has to be controlled per query, not per request.

What changes when there's only one endpoint

A few properties of GraphQL are worth writing down before any threat is scored, because each one quietly disables a control a REST-shaped threat model would otherwise assume is there:

  • One URL, one method, one status code. Almost every operation is a POST to the same path, and many servers return 200 even when the query partly failed, with the errors in the response body. Path-based WAF rules, per-route rate limits, and dashboards that alert on 4xx/5xx rates see one uniform stream of traffic.
  • The client shapes the response. There is no fixed response per endpoint for a reviewer to read. What leaves the server is whatever a query asked for, within whatever the schema exposes.
  • The client shapes the cost. One small HTTP request can ask for a few rows or for millions, depending on nesting, pagination arguments, and aliases.
  • Resolvers are the real endpoints. Each field is backed by a resolver function, and resolvers call databases, other services, and third-party APIs. Those calls are the data flows your DFD needs, and they're invisible from the outside.

Inventory: the schema, not the route table

For a REST API the inventory is the route table. For GraphQL it is the schema: every query, mutation, and subscription root field, every type and field reachable from them, and every argument that reaches a data source. Export it from the server itself, not from the documentation, and diff it against what your first-party clients actually use. Fields nobody calls any more, admin-only mutations that live in the same schema as everything else, and debug fields left in from development are the GraphQL equivalent of the undocumented route.

Then decide what you're assuming about schema secrecy, because the common assumption is wrong. Introspection, the built-in query that returns the whole schema, is commonly disabled in production. Apollo Server, for example, turns it off by default when NODE_ENV is production. That's reasonable hygiene, but it's not a control: many servers still answer a mistyped field with a "Did you mean…?" suggestion, your web client ships its queries in the JavaScript bundle, and there is published tooling that rebuilds large parts of a schema from suggestions alone. Record "the attacker has the full schema" as the working assumption, and let introspection being off buy you time, not safety.

Authorization lives in resolvers, and it leaks at the edges

Broken Object Level Authorization is first on the OWASP API Security Top 10 for REST and GraphQL alike. What GraphQL adds is more paths to the same object, and more of them sit away from the query everyone reviews:

  • The generic node(id:) lookup. Schemas that follow the Relay convention expose a root field that fetches any object by its global ID. If the type-specific queries check ownership but node just decodes the ID and loads the row, every one of those checks has a bypass.
  • Nested relationship edges. me { organization { members { email } } } starts from an object the caller is entitled to and walks outward. Authorization on me says nothing about whether every hop after it is allowed. Each edge is its own access decision, and it's easy to forget that a list the UI only shows to admins can be reached from a type every user can load.
  • Sensitive fields on public types. A User type shared between a public profile page and an account settings page tends to carry both sets of fields. OWASP files this as Broken Object Property Level Authorization: the object is fine to read, but not every property on it is.
  • Mutations that check the action but not the object. updateInvoice(id:, input:) checks that the caller may update invoices, then updates whichever invoice ID was passed in. The same mistake as REST, now spread over every mutation argument that references another object.

The fix that scales is to enforce authorization in the business-logic layer that every resolver goes through, rather than re-implementing it per resolver. That way there's one place to review and one place to fail closed. When you walk the schema, ask the same question the REST post asks of every endpoint that takes an ID, but ask it of every field that takes an ID and every edge that returns another object: where is the line of code that ties this object to this caller?

One GraphQL-specific wrinkle belongs here too. Most servers batch database access through a per-request loader (the DataLoader pattern) to avoid the N+1 query problem. If that loader is created once per process instead of once per request, its cache is shared between users, and one caller can be served another caller's cached object. It's an easy thing to check, and it's worth a line in the model.

Cost is a security property

OWASP's API4, Unrestricted Resource Consumption, is where GraphQL differs most from REST, because a single request can be made arbitrarily expensive without breaking any rule that request-level controls can see:

TechniqueWhat it doesControl
Deep nesting Circular relationships (author → posts → author → posts…) let one query fan out exponentially. Maximum query depth, enforced before execution.
Large pagination posts(first: 100000), repeated at every level of nesting, multiplies. A hard server-side cap on every list argument, not just a default.
Aliases The same field requested hundreds of times under different names in one query, including a login mutation, turning one HTTP request into hundreds of password guesses. Limit aliases per operation, and rate limit sensitive operations by execution count, not by HTTP request.
Batching Many operations sent as a JSON array in one HTTP request, where the server supports it. Disable batching if you don't need it; if you do, cap batch size and count each operation against limits.
Expensive fields Fields that trigger search, report generation, or third-party calls, requested freely. Query cost analysis that weights fields by real cost, rejecting over-budget queries before they run.

For APIs whose only callers are your own clients, the strongest control is an allowlist: register the exact queries your clients use at build time (often called trusted documents or a persisted query list) and reject everything else. That removes most of the attack surface in one move. Be careful with the similar-sounding automatic persisted queries: they're a caching optimization, and any client can register a new query, so they don't restrict anything.

The STRIDE sweep, at schema altitude

CategoryWhat to ask of a GraphQL API
Spoofing How are subscriptions authenticated? WebSocket connections typically authenticate once, at connection start. Is the token re-checked, and what happens to an open subscription when that user's access is revoked?
Tampering Do resolvers pass arguments straight into SQL, NoSQL, or shell calls? GraphQL's type system checks that an argument is a String, not what's inside it, so injection is exactly as possible as in REST. Do input types accept fields (role, ownerId) the client should never set?
Repudiation Is the operation name and the actual query logged with the acting identity? Access logs that record only POST /graphql 200 can't reconstruct what anyone did.
Information disclosure Do errors include stack traces or internal details in extensions? Do field suggestions leak a hidden schema? Can nested edges or node(id:) reach objects the top-level query wouldn't return?
Denial of service Are depth, pagination, alias, batch, and cost limits enforced before execution? Everything in the table above.
Elevation of privilege Are admin mutations in the same schema as user queries, protected only by not being in the UI? Is authorization enforced in shared business logic, or re-implemented (and sometimes forgotten) per resolver?

One cross-site issue deserves its own line: if the API authenticates with cookies and accepts queries over GET, or accepts POST bodies with a content type a browser can send cross-origin without a preflight, a malicious page can make a signed-in user's browser run mutations. Recent Apollo Server versions block this by default. Other stacks may not, so check rather than assume.

Federation and gateways: the internal boundary

Larger GraphQL deployments split the schema across subgraphs behind a gateway (Apollo Federation is the common example). The gateway is usually where authentication and query limits are enforced, which makes it tempting to treat the subgraphs as internal and trusted. They're only internal if they're actually unreachable. A federated subgraph exposes extra root fields for the gateway's use, such as _entities, which resolves entities by key. If a subgraph can be reached directly, that field can hand out objects with none of the gateway's checks in the path. This is the same "internal is a network fact, not an authentication fact" problem the microservices guide covers. Draw the gateway-to-subgraph hop as a trust boundary and ask what enforces it.

Modeling it in a DFD and attack tree

On the DFD, resist drawing the GraphQL server as a single box with one flow in and one flow out. It is one process, but the useful detail is behind it: draw each data source a resolver reaches (the main database, the search index, the internal services, the third-party APIs) as its own flow, because that's where both the data and the cost go. Put the trust boundary where the external request enters, and a second one at every hop the server makes on the caller's behalf.

For the attack tree, two goals cover most of the ground. Under "Read another user's private data through the GraphQL API", branch on node(id:) lookup, nested edge traversal, sensitive fields on shared types, mutation arguments referencing foreign objects, a direct call to a subgraph, and a shared loader cache. Under "Degrade the API for every user", branch on depth, pagination, aliases, batching, and expensive fields. Each branch is testable with a single hand-written query, which makes this one of the more satisfying models to validate: you can find out in minutes whether a branch is real.

Common mistakes

  • Treating disabled introspection as a control. It hides the schema from casual browsing, not from someone who wants it.
  • Authorizing the query, not the graph. Checking the top-level field and trusting every nested edge beneath it.
  • Rate limiting by HTTP request. Aliases and batching make one request worth hundreds of operations.
  • No limits until there's an incident. Depth, pagination, and cost limits are much easier to set before clients depend on huge queries than after.
  • Mistaking automatic persisted queries for an allowlist. Only a registered, closed list of queries restricts anything.
  • Trusting subgraphs because they're "behind" the gateway. They're only behind it if nothing else can reach them.

None of this makes GraphQL less safe than REST by nature. It moves the work: fewer places to put a control, more reasons to put it in the shared layer every resolver goes through, and cost that has to be reasoned about per query. A threat model that starts from the schema rather than the endpoint is what makes that shift visible, instead of leaving it to be discovered the first time someone sends a query nobody on the team would have written.

GraphQL APIs are common in SaaS and fintech products, where the same object-level and cost questions apply to every customer-facing integration. To start from a pre-labelled diagram, see the free threat modeling templates.

Model the graph, not just the endpoint

ThreatTree's attack trees let you score every path through your schema separately -- node lookups, nested edges, aliases, batching -- so the gaps show up as risk-register entries with owners, not as a surprise query in your logs.

Get started free

Not ready to sign up? Get new threat-modeling guides by email instead.