GraphQL APIs
How GraphQL's single endpoint, schema, and resolvers work, the N+1 and query-cost problems that shape its performance and security, and the authorization, batching, and introspection issues that differ from REST.
GraphQL flips the REST model. Instead of many endpoints each returning a fixed shape, there is usually one endpoint, and the client sends a query describing exactly the data it wants. That inversion is powerful and moves the security questions to new places.
A query and its response
query {
user(id: "42") {
name
orders(last: 2) {
id
total
}
}
}
{
"data": {
"user": {
"name": "Lab User",
"orders": [
{ "id": "5520", "total": 12.50 },
{ "id": "5521", "total": 39.98 }
]
}
}
}
The request is a POST to /graphql with the query in a JSON body. One round trip fetched a user and their orders, which in REST might be two or three calls. The client, not the server, decided the response shape.
Building blocks
| Concept | What it is |
|---|---|
| Schema | The typed definition of everything the API exposes |
| Query | A read operation |
| Mutation | A write operation, like createOrder |
| Subscription | A live stream, often over WebSockets |
| Resolver | The server function that fetches one field |
| Variables | Parameters passed separately from the query text |
The resolver is the key to GraphQL security and performance alike. Every field is fetched by its own function, which has two consequences. First, every resolver needs its own authorization check: guarding user but not user.orders or user.paymentMethods is the single most common GraphQL bug, because authorization naturally wants to live at the object boundary and the graph crosses many boundaries in one query.
The N+1 problem and why batching the right way matters
The second consequence of per-field resolvers is performance. Fetch 50 users and then each user’s orders, and a naive implementation runs one query for the users and then 50 more, one per user: the N+1 problem. The standard fix is a DataLoader, which collects the ids requested within a tick and issues one batched query (WHERE user_id IN (...)). This matters to security because the absence of batching, plus the client’s freedom to request deeply nested data, is what makes GraphQL uniquely easy to turn into a denial-of-service, as below.
Introspection
GraphQL can describe itself. An introspection query returns the entire schema, every type, field, query, and mutation, including ones the frontend never uses:
query { __schema { types { name fields { name } } } }
Introspection is invaluable in development and in authorized testing. Many production APIs disable it, though field-suggestion error messages (“Did you mean adminUsers?”) still leak schema details. GraphQL Voyager turns an introspection result into a navigable diagram of the whole attack surface.
Security concerns unique to GraphQL
| Concern | Why it happens | Mitigation |
|---|---|---|
| Missing field-level authorization | Each resolver checks independently | Centralize authorization in the business layer behind resolvers |
| Deeply nested queries | user { friends { friends { friends ... } } } explodes work |
Depth limits and query cost analysis |
| Batching and aliases | Many operations in one HTTP request bypass per-request limits | Limit operations per request, rate limit per operation |
| Introspection in production | Exposes the full attack surface | Disable, or restrict to authenticated staff |
| Verbose errors | Stack traces and suggestions leak internals | Generic errors in production |
| CSRF on GraphQL | A GET-enabled endpoint, or one accepting form-encoded bodies, is forgeable |
Require JSON content type and a custom header, reject GET for mutations |
Two deserve special attention. Query cost: because the client composes the query, a single request can ask for an exponential amount of work through nesting and large pagination arguments. Defenses are a maximum depth, a maximum complexity budget computed before execution, and caps on list arguments, so an expensive query is rejected rather than run. Batching and aliases: an attacker sends the same mutation 500 times as aliases in one HTTP request, so a rate limiter that counts requests sees one. This has been used to brute force one-time codes and coupon redemptions. Rate limit by operation and reject queries with excessive repeated operations.
# Alias batching: one HTTP request, many attempts at a check
mutation {
a: redeem(code: "0001") { ok }
b: redeem(code: "0002") { ok }
c: redeem(code: "0003") { ok }
# ... hundreds more
}
Testing tools and practice
- Burp Suite parses GraphQL, and the InQL extension handles introspection and query generation.
- Altair and GraphiQL are interactive clients.
- Practice targets: Damn Vulnerable GraphQL Application (DVGA) and PortSwigger’s GraphQL API labs.
Field notes: introspection is the whole schema
The first move against any GraphQL endpoint is the introspection query, which dumps every type, field, argument, and mutation the server knows:
{ __schema { types { name fields { name args { name } } } queryType { name } mutationType { name } } }
Paste the result into GraphQL Voyager for a visual map, or use InQL (Burp) or graphql-cog to generate every possible query automatically. The mutations list is a hit list: deleteUser, updateRole, impersonate, internalCreateCoupon, all reachable even when no UI calls them.
When introspection is disabled, you are not stuck. GraphQL servers (especially Apollo) return “did you mean” field suggestions on a typo, so you brute-force field names and read the hints. Clairvoyance automates reconstructing the full schema from suggestion errors alone. Also try introspection over a GET or with a trailing newline, which some filters miss.
Field notes: batching, cost, and injection
- Alias-based brute force. One HTTP request can run a field hundreds of times under different aliases, which sails past per-request rate limiting. Brute-force a login, OTP, or coupon in a single request:
mutation { a:login(pw:"0000"){token} b:login(pw:"0001"){token} c:login(pw:"0002"){token} }
- Query-depth and cost denial of service. Deeply nested recursive relationships (
posts { author { posts { author { ... }}}}) make the server do exponential work. A missing depth or cost limit is a real DoS finding. - GraphQL over GET is CSRF-able. If the endpoint accepts queries (or even mutations) via
GETquery string, you have CSRF with an<img>tag because no custom header is required. - Injection flows through resolvers. Arguments reach the same SQL and NoSQL sinks as any input; the GraphQL layer launders nothing.
- Authorization gaps by relationship. The top-level query is guarded but a nested field is not, so reach sensitive data sideways:
me { company { employees { salary } } }.
Only run these against endpoints in your authorized scope.
Takeaway
GraphQL moves complexity from the URL into the query, so security must move with it: authorization per field, cost and depth limits per query, and rate limits per operation rather than per request. The flexibility that clients love is the same flexibility an attacker uses, which is why the defenses all constrain what a single query is allowed to ask for.