AuthZ, Complexity Limits & Abuse Protection
A GraphQL endpoint lets the client choose depth, breadth, and aliases. Authentication at the door is not enough. Authorize each sensitive field, score the query, and prefer persisted documents for first-party apps.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Question ladder
L1
Why is authorization harder on a graph than on a route?
Answer
The client can reach nested fields the route check never named.
L2
What is the difference between depth and complexity?
Answer
Depth caps nesting. Complexity weights expensive fields and multiplies by list size. Use both.
L3
How do aliases amplify a query?
Answer
The same costly field is selected many times under different names in one operation.
L4
What is a persisted query?
Answer
The client sends a hash or id. The server runs only a document it already registered.
L5
Should introspection be on in production?
Answer
Off or tightly restricted for a public API. Internal tooling can keep it if the risk is accepted.
L6
Where does row-level security live?
Answer
In the loader or the database predicate, bound to the tenant and the actor, not only in a GraphQL middleware check.
L7
What must partial errors hide?
Answer
SQL, hostnames, and stack traces. An auth failure must not reveal whether an id exists when that is an oracle.
Failure modes
Root check only
The viewer is authenticated and a nested field still returns another user's email.
Depth limit without a cost score
A shallow query of wide expensive lists still melts the database.
Open introspection on a public graph
The attacker downloads the menu, including internal fields you hoped were obscure.
Error text as an oracle
Not found versus forbidden tells the caller which ids exist.
Misconceptions
JWT plus TLS is the authorization story.
That is authentication and transport. Fields and rows still need checks, and the query still needs a budget.
A depth limit is a complexity limit.
Depth ignores how expensive a field is and how large a list argument is.
Persisted queries are only a performance trick.
They are an allowlist. Unknown documents never run.
Interviewer traps
Redesigning RBAC, ABAC, or a token bucket from scratch.
Say field versus row on the graph, then point at the security and rate-limit clusters for the general models.
Treating HTTP 401 as the only failure mode.
GraphQL often returns 200 with an errors array. Price the query before execute, and keep error text boring.
Design scenario
Same prompt for every reader.
Requirements
PII fields are default deny. Mobile cannot send an unregistered document. Partners have a reviewed query budget. Introspection is off in public production.
Traffic / scale
Partner traffic is bursty. Mobile traffic is the baseline.
Latency
Rejected documents fail before execution. Accepted ones time out at a fixed operation budget.
Consistency
An unauthorized id returns the same boring error whether or not the row exists.
Availability
A cost rejection does not take down the process. It is a per-operation response.
Failure assumptions
- A partner sends the same expensive field under many aliases.
- A global node field fetches by id with no tenant predicate.
Constraints
- First-party clients use persisted queries.
- Loaders apply the tenant predicate.
Prompt
A public partner graph and a first-party mobile app share one schema. Partners explore with their own queries. Mobile ships a known set.
API
Which gate runs before parse, and which gate runs per field?
Data
How does the list multiplier enter the cost?
Architecture
Where do allowlist, AuthN, and row filters sit on the request?
Overview
One endpoint. The client picks the tree. That is the product feature and the attack.
- Nested cost. Depth times lists becomes real work.
- Aliases. The same field under many names runs many times in one document.
- Introspection. A production schema dump is a map.
- Global node lookups. An id with no AuthZ is an IDOR.
- Batched HTTP. Several operations in one request multiply the above.
- Subscription floods. Connections and subscription counts need caps.
Decisions
- 1
HTTP request
- nextAuthN session or JWT
- 2
AuthN session or JWT
- nextAllowlisted query?
- ?
Allowlisted query?
- NoReject
- YesParse and validate
- 4
Reject
- 5
Parse and validate
- nextUnder cost budget?
- ?
Under cost budget?
- NoReject
- YesExecute with field AuthZ
- 7
Execute with field AuthZ
- nextTimeout and rate limit
- 8
Timeout and rate limit
- nextPartial data or errors
- 9
Partial data or errors
Lesson map
AuthZ, Complexity Limits & Abuse Protection
A GraphQL endpoint lets the client choose depth, breadth, and aliases. Authentication at the door is not enough. Authorize each sensitive field, score the query, and prefer persisted documents for first-party apps.
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB a["HTTP request"] b["AuthN session or JWT"] c["Allowlisted query?"] d["Reject"] a -->|HTTP request to AuthN session or JWT| b b -->|AuthN session or JWT| c c -->|No| d
Public traffic should fail closed when the document is not registered. A private admin tool can allow ad-hoc queries if that risk is explicit.
AuthZ patterns
| Pattern | Where it sits | What it is good at |
|---|---|---|
| Gate | Middleware on the route | "You may speak GraphQL at all" |
| Field directive | Schema | A reviewable annotation on email or notes |
| Resolver guard | Code | Flexible, and easy to forget on a new field |
| Pre-execution filter | Planner | Strip fields the caller cannot have |
| Row predicate | Loader or SQL | Multi-tenant isolation |
Default deny on sensitive fields. Never trust a client-supplied user id. Bind the actor from the session.
What a root check actually covers
Prefer
Field and row checks
Each sensitive field and each query predicate knows the actor.
- A nested email cannot hide behind a viewer check.
- Loaders add the tenant to the batch query.
- Directives make the rule visible in review.
Alternative
Authenticated, therefore allowed
The JWT was valid. The graph then returns whatever the selection set named.
- Fine for a truly public field.
- IDOR on a global id field.
- Fails the interview as soon as the tree has two owners.
Price the document before you run it
- 1
Authenticate
Session or token. This names the actor. It does not name the fields. - 2
Allowlist
First-party apps send a persisted id. Unknown documents stop here. - 3
Score
Depth, field weights, list multipliers, and alias count. - 4
Authorize
Field middleware and row predicates during execution. - 5
Bound time
Operation timeout, rate limit, and a cap on subscriptions per connection.
A teaching cost score
FIELD_COST = {
("Query", "viewer"): 1,
("User", "orders"): 10,
("Order", "lines"): 5,
}Depth is the length of the path. Cost is the sum of field weights times the list multiplier. A shallow path of expensive lists fails the cost check and passes a naive depth check. That is why you want both.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Persisted queries
First-party mobile and web send the hash of a query. The server maps it to a document it has seen in CI. Payloads get smaller, GET becomes realistic, and an attacker cannot invent a new selection set.
Partners who must explore need a reviewed process, not open introspection in production. Disable automatic registration of new hashes from anonymous clients.
Rate limits and timeouts
- Per token and per IP limits on operations. Reuse the gateway limiter you already run. Do not re-derive the bucket math here.
- A wall-clock timeout per operation.
- A max subscription count per connection.
- Introspection off, or restricted, in public production.
GraphQL versus REST abuse, in one pass
REST spreads attackers across paths a WAF can name. GraphQL concentrates them on one path with many shapes. Path rules are not the budget. The application budget is mandatory. Status-code design for REST resources stays in API Design.
Ship list
- AuthN required except fields you explicitly mark public.
- Field AuthZ on PII and admin fields.
- Depth, complexity, and alias budgets.
- Persisted queries for first-party apps.
- Introspection restricted in production.
- Operation timeout and a gateway rate limit.
- No open registration of persisted queries without auth.
- Logs for rejected cost, depth, and actor. No query text that includes secrets.
Partial errors
GraphQL can return partial data with errors. Scrub SQL, internal host names, and stack traces. Map authorization failures to stable codes. If revealing whether an id exists would be an oracle, use the same code for missing and forbidden.
Pitfalls
A viewer, a list of 50 orders, and 20 aliases of a lines field. Which gate fires first if the document is not persisted? What changes if it is persisted but the list multiplier blows the cost score?
Interview Q&A
Why is AuthZ harder in GraphQL?
Answer
Clients reach nested fields. A check on the root operation does not run inside every resolver. Authorize the field and the row.
Depth limit versus complexity score?
Answer
Depth caps how deep the tree is. Complexity weights expensive fields and multiplies by list size. A shallow wide query needs the score. Use both.
Should introspection be on in production?
Answer
Prefer off, or restricted, for a public API. Keep it for private tooling only when that risk is accepted and the audience is authenticated.
What is a persisted query?
Answer
A document registered ahead of time and referenced by hash or id. The server does not execute unknown selection sets from that client.
How do aliases enable a denial of service?
Answer
They repeat an expensive field under many names in one operation, so one HTTP request does the work of many.
Where do you enforce row-level security?
Answer
In the loader or the database query, bound to the tenant and the user. A GraphQL-only check that forgets the predicate is not enough.
Are HTTPS and a JWT enough?
Answer
No. They authenticate and protect transport. You still need cost limits and field authorization.
What do you return for a forbidden id?
Answer
A stable error that does not tell the caller whether the row exists, when existence itself is sensitive. Do not include SQL or a stack.