What it is
GraphQL is a query layer that lets the client choose the shape of the data it receives. The security problems come from that flexibility: the client decides the query, so the attack surface is the whole graph — not a fixed set of endpoints — and authorisation and resource limits must be applied at the field level, not the route level.
Why you should care
GraphQL creates no new vulnerabilities; it shifts the old ones to a tier the old tooling and habits do not reach. Three shifts to grasp:
- Authorisation moves from the route to the FIELD. REST has
GET /orders/{id}to hang a check on; GraphQL has one query fetchinguser { orders { paymentDetails } }, and each field in it needs its own authorisation. A field resolver that forgets to check is a BOLA/IDOR (the access-control topic) that no route represents. - Resource limits move from pageSize to query DEPTH/COMPLEXITY. A nested query
posts { author { posts { author { ... } } } }is a DoS — it expands exponentially with nopageSizeparameter to bound it (api-security topic, API4). - Introspection and error fields leak the structure. GraphQL by default lets you ask "what is in the graph" (introspection), and error messages suggest field names — both are information disclosure (the info-disclosure topic).
And one GraphQL-specific point: batching defeats per-request rate limiting. A single GraphQL HTTP request can contain thousands of operations (aliases or a batch), so "100 requests/minute" becomes 100×N operations — enough to brute-force an OTP code in one request.
Put differently: if you have read access-control, api-security and info-disclosure, you already know the vulnerabilities — this topic is about what they look like when the client holds the query.
How the attack works
The mechanism: the client sends a query, the server runs a tree of resolvers — one function per field. The attack surface is that tree, not a list of endpoints.
flowchart TD Q["Client-composed query<br/>user(id:7) { email, orders { paymentDetails } }"] --> R{Resolver per field} R --> F1["user resolver — authorised?"] F1 --> F2["orders resolver — ownership checked?"] F2 --> F3["paymentDetails resolver — field-level check?"] F3 -->|"one field forgets to check"| B["🔓 BOLA/IDOR at the field level"] Q --> D["Deeply nested / batched thousands of ops"] D --> DoS["🔓 DoS: exponential expansion, bypasses rate limit"]The key point: there is no route to hang a check on. In REST, paymentDetails is its own endpoint with its own [Authorize]; in GraphQL it is a field inside a larger query, and the check must live inside that field's resolver — otherwise a clever query fetches it.
A table of problems and their mapping to other topics:
| Problem | Mechanism | Is the topic |
|---|---|---|
| Missing field-level authz | A resolver does not check ownership/role | access-control (API1) |
| Query depth/complexity DoS | Deep nesting, exponential expansion | api-security (API4), CWE-770 |
| Batching bypasses rate limits | Thousands of operations in one request | rate-limiting |
| Introspection leaks the schema | __schema returns the whole structure | info-disclosure |
| Field suggestions via errors | "Did you mean...?" leaks field names | info-disclosure |
| Injection in arguments | Resolver arguments into SQL/NoSQL | sql-injection |
The "batching" row deserves its own note because it is counter-intuitive: per-HTTP-request rate limiting is meaningless for GraphQL, because one request can contain [{login}, {login}, {login}, …] a thousand times via aliases — so an OTP brute-force completes in one request, under the threshold of any per-request rate limit.
Diagram description: A diagram for a client-composed GraphQL query fetching user(id:7) with email and orders with paymentDetails. The server runs a resolver tree, one function per field: the user resolver, then the orders resolver, then the paymentDetails resolver. Each field needs its own authorisation; if one field forgets to check, that is a BOLA or IDOR at the field level that no route represents. Another branch shows a deeply nested or batched query of thousands of operations leading to an exponential-expansion DoS that bypasses per-request rate limiting.
Concrete example
The same schema, three attack paths.
# ① Missing field-level authz. paymentDetails does not check ownership.query { user(id: 7) { # user 7 is NOT the caller email paymentDetails { cardLast4, billingAddress } # resolver forgets to check → leak }}# ② Query-depth DoS. Bouncing author↔posts, expanding exponentially.query { posts { author { posts { author { posts { author { posts { id } } } } } } }}# Each level multiplies the node count; 8 levels is millions of resolvers → CPU/RAM exhausted.# ③ Batching bypasses rate limiting. One HTTP request, thousands of OTP tries via aliases.mutation { a: verifyOtp(code: "0000") { ok } b: verifyOtp(code: "0001") { ok } c: verifyOtp(code: "0002") { ok } # ... 10000 more aliases. A "100 requests/minute" limit sees EXACTLY ONE request.}// ④ Introspection leaks the whole schema (on by default in many servers).{"query":"{ __schema { types { name fields { name } } } }"}// → returns every type, every field, including internal fields nobody meant to publish.# After the fix: authz per resolver, depth + complexity caps, batch limits,# introspection off in production, error messages that do not suggest fields.// HotChocolate (GraphQL cho .NET).public class Query{ // ❌ Field-level authz thiếu. Resolver này trả user theo id BẤT KỲ, không kiểm // người gọi có quyền xem không. Trong REST đây là GET /users/{id} với // [Authorize] của nó; ở GraphQL nó là một field, và phép kiểm phải ở ĐÂY. public async Task<User?> GetUser(int id, [Service] IUserRepository users) => await users.GetByIdAsync(id); // không có userId của người gọi → BOLA} public class UserType : ObjectType<User>{ protected override void Configure(IObjectTypeDescriptor<User> d) { // ❌ paymentDetails là field nhạy cảm nhưng resolver của nó không kiểm role. // Một query user(id:7){ paymentDetails } lấy được thẻ của người khác. d.Field(u => u.PaymentDetails); }} var builder = WebApplication.CreateBuilder(args);builder.Services .AddGraphQLServer() .AddQueryType<Query>(); // ❌ Không MaxExecutionDepth, không cost analysis, không giới hạn batch. Một query // lồng 20 cấp hay một batch 1000 alias verifyOtp chạy tự do. // ❌ Và introspection bật theo mặc định ở production. var app = builder.Build();app.MapGraphQL();What happened in the wild
The field-level authz bug bounty family (many reports). The recurring pattern: a REST app adds GraphQL for a new feature, authorisation is carefully applied at the REST routes but the GraphQL resolvers miss it — so the same data is reachable through the graph but not through a route. Memorable because it shows GraphQL is neither "safer" nor "worse" than REST — it just moves the check to a place a REST-habituated team does not look.
Batching OTP/2FA brute-force (many reports, ~2018–present). Reports describe the same technique: pack thousands of verifyOtp aliases into one mutation, so brute-forcing a six-digit code completes in one HTTP request — under the threshold of any per-request rate limit. This is the source of the batching concept in block 3.
DoS via deeply nested queries (many CVEs in GraphQL servers). Many servers do not limit depth/complexity by default, so a query bouncing between two bidirectionally-related types exhausts resources. It illustrates CWE-770 and the API4 argument: with no pageSize parameter, nothing bounds it on its own.
How to defend
Authorise in EACH resolver, not at the route level
mandatoryGraphQL has no route to hang [Authorize] on, so authorisation must live in the field's resolver — the same principle as the access-control topic, just at a different tier.
- Every resolver returning user-scoped data checks ownership, exactly like
GetByIdAsync(id, userId)in the access-control topic. Anordersresolver must filter by the caller, not return every order. - Sensitive fields check the role in the resolver itself.
paymentDetails,internalNotesneed a role, and the check lives in that field's resolver — not the parent's. - Do not rely on the UI not requesting the field. The client holds the query, so every field in the schema is publicly queryable. The same lesson as API3/API9 in the api-security topic.
The durable approach: authorise at the data layer, not in scattered resolvers — a data loader applying ownership on load, or RLS in the DB (access-control layer 2), so a new resolver that forgets to check still cannot return other people's data. Scattering if (user.canSee(...)) across resolvers is open-by-default, and resolver 41 will be missing it.
public class Query{ // Field-level authz: resolver nhận người gọi từ context và lọc theo ownership — // đúng cùng nguyên tắc GetByIdAsync(id, userId) ở topic access-control. public async Task<User?> GetUser( int id, [Service] IUserRepository users, [GlobalState] CurrentUser caller) { // Người dùng thường chỉ xem được chính mình; admin xem được người khác. if (id != caller.Id && caller.Role < SystemRole.Admin) return null; // 404-tương-đương: không xác nhận sự tồn tại (topic access-control) return await users.GetByIdAsync(id, caller.Id); }} public class UserType : ObjectType<User>{ protected override void Configure(IObjectTypeDescriptor<User> d) { // Field nhạy cảm kiểm quyền TRONG resolver của chính field đó — không ở resolver cha. d.Field(u => u.PaymentDetails) .Authorize("SelfOrAdmin"); // policy: chủ tài khoản hoặc admin }} var builder = WebApplication.CreateBuilder(args);builder.Services .AddGraphQLServer() .AddQueryType<Query>() .AddAuthorization() // Trần độ sâu: chặn query lồng qua lại author↔posts nở cấp số nhân. .ModifyRequestOptions(o => o.Complexity.Enable = true) .AddMaxExecutionDepthRule(10) // Trần độ phức tạp: gán chi phí, từ chối query vượt ngân sách. Mạnh hơn trần độ // sâu vì nó bắt cả query nông mà rộng, và nó là cơ sở cho rate limit theo chi phí. .ModifyRequestOptions(o => { o.Complexity.MaximumAllowed = 1000; o.ExecutionTimeout = TimeSpan.FromSeconds(10); // query chạy lâu cũng là DoS }); var app = builder.Build(); // Introspection và GraphiQL CHỈ ở Development — cùng mẫu Swagger ở topic info-disclosure.app.MapGraphQL().WithOptions(new GraphQLServerOptions{ Tool = { Enable = app.Environment.IsDevelopment() }, EnableSchemaRequests = app.Environment.IsDevelopment(),});Depth, complexity and batch caps — the client holds the query, so bound it
mandatoryREST bounds with pageSize; GraphQL has none, so you must bound the query itself (api-security topic, API4, CWE-770):
- A depth cap (say 10 levels). Stops a query bouncing
author↔postsand expanding exponentially. - A complexity cap: assign a cost to each field (a list costs more than a scalar) and reject queries over budget. This is stronger than a depth cap because it catches shallow-but-wide queries too.
- Batch and alias limits: this is the GraphQL-specific control and the commonly-skipped one. Limit the number of operations in one request and the number of aliases of the same field — otherwise batching bypasses every rate limit (example ③ in block 4: OTP brute-force in one request).
- A returned-node cap (like pageSize): an unpaginated list is an unbounded list.
Plus a timeout for the whole query: a query complex enough to run 30 seconds is a DoS a depth cap might allow. Use an existing library (graphql-depth-limit, graphql-cost-analysis) rather than writing your own — computing cost correctly is hard.
Disable introspection and field suggestions in production
This is the information-disclosure control (the info-disclosure topic), applied to GraphQL.
- Disable introspection in production.
__schemaand__typereturn the entire graph structure — every type, every field, including internal ones. It is useful in development (GraphiQL needs it), so enable it in Development and disable it in Production — the same pattern as Swagger in the info-disclosure topic. - Turn off "did you mean" in error messages. Many GraphQL servers suggest near-match field names when the client mistypes — that is introspection through a back door. Return generic errors in production.
- Do not return stack traces in resolver errors — the same correlation-id fix as the info-disclosure topic.
A note on severity: disabling introspection is not a real security control, it only slows things — the attacker still guesses fields via "did you mean" or by trying. It is layer 1c, not layer 1: field-level authorisation (layer 1) is what blocks; disabling introspection only reduces the reconnaissance surface.
Rate limit by query COST, not by request; and arguments are untrusted input
Batching makes per-request rate limiting meaningless (block 3), so rate limiting must count something else.
- Rate limit by COST, not by request. Assign a per-user, per-minute cost budget, and deduct the real complexity of each query (the same formula as the layer-1 complexity cap). A request with thousands of operations costs thousands of times the budget — so it is blocked at exactly the point "100 requests/minute" lets through.
- Resolver arguments are untrusted input. An argument
filter: "...")reaching SQL is SQL injection (the sql-injection topic); an argumenturla resolver fetches is SSRF (the ssrf topic). GraphQL does not make arguments more trustworthy than a query param.
And persisted queries (an allowlist of known queries) are a strong control when the client is your own: the server runs only the queries on a registered list, so depth, complexity and batching are all bounded by that list — the client cannot compose an arbitrary query.
Detection: queries anomalous in shape are a signal
Your legitimate clients send a fairly fixed set of query shapes (especially with persisted queries), so deviations from that shape are a signal.
- Queries over the depth/complexity cap are rejected → log them. A run of near-cap queries is a probe for the limit.
- A request with an anomalous alias/operation count → the signature of batching brute-force. A mutation with 1000
verifyOtpaliases is not a real client. __schema/__typequeries in production → introspection is being probed (if you have not disabled it, this is also the reminder to).- A sensitive field queried with ids not belonging to the caller, repeatedly → BOLA is being probed, the same detection as the access-control topic (many field-level 404/403s).
Layer 3 because layer 1 already blocks; logging turns "blocked" into "we know who is trying".
Verifying the fix
1. Test field-level authz — one cross-user test per sensitive field. See the csharp / test tab. The same convention as the access-control topic: send a query fetching another user's field and assert it returns no data. This is the most important check, because field-level authz is GraphQL's most common flaw.
2. Test the depth and complexity caps:
B=https://staging.example.com/graphql# A 20-level nested query must be refused before running (not run then time out).Q=$(python3 -c "print(\"query{\"+\"posts{author{\"*20+\"id\"+\"}}\"*20+\"}\")")curl -s -o /dev/null -w '%{http_code}\n' "$B" -H 'Content-Type: application/json' \ -d "{\"query\":\"$Q\"}" # must be 400, and fast3. Test batching is limited — the GraphQL-specific check few people write:
# A mutation with 1000 verifyOtp aliases must be refused, not run to completion.python3 -c "print('mutation{'+''.join(f'a{i}:verifyOtp(code:\\\"{i:04d}\\\"){{ok}}' for i in range(1000))+'}')" > batch.txtcurl -s -o /dev/null -w '%{http_code}\n' "$B" -H 'Content-Type: application/json' \ --data-binary "{\"query\":\"$(cat batch.txt)\"}" # must be 4004. Check introspection is off in production:
curl -s "$B" -H 'Content-Type: application/json' \ -d '{"query":"{ __schema { types { name } } }"}' \ | grep -q '__schema' && { echo "introspection is still on in production"; exit 1; }exit 05. Check error messages do not suggest fields — mistype a field and assert the response does not contain "Did you mean". That is introspection through a back door.
6. Check resolver arguments go through the injection checks — a filter argument reaching a DB query must be parameterised (the sql-injection topic), a url argument a resolver fetches must go through an allowlist (the ssrf topic).
import { test, expect } from "vitest"; const GQL = "http://localhost:5100/graphql"; async function gql(query: string, cookie: string) { const res = await fetch(GQL, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ query }), }); return { status: res.status, body: await res.json() };} // Field-level authz — phép kiểm quan trọng nhất, vì đây là lỗ hổng GraphQL phổ biến// nhất. Cùng quy ước cross-user với topic access-control.test("a user cannot read another user's payment details", async () => { const bob = await signInAs("bob"); const aliceId = await seedUser("alice"); const { body } = await gql("{ user(id: " + aliceId + ") { paymentDetails { cardLast4 } } }", bob); // Không trả dữ liệu của Alice — hoặc null, hoặc lỗi authz, KHÔNG phải thẻ của cô ấy. expect(body.data?.user?.paymentDetails ?? null).toBeNull();}); // Trần độ sâu: query lồng 20 cấp phải bị TỪ CHỐI trước khi chạy, không chạy rồi timeout.test("a deeply nested query is rejected", async () => { const bob = await signInAs("bob"); const deep = "query{" + "posts{author{".repeat(20) + "id" + "}}".repeat(20) + "}"; const { body } = await gql(deep, bob); expect(body.errors?.[0]?.message).toMatch(/depth|complexity/i); expect(body.data).toBeUndefined();}); // Batching — phép kiểm RIÊNG của GraphQL, ít ai viết. Một mutation với nghìn alias// verifyOtp là brute-force trong một request, dưới ngưỡng rate limit theo request.test("thousands of aliased operations are refused", async () => { const bob = await signInAs("bob"); const aliases = Array.from({ length: 1000 }, (_, i) => "a" + i + ': verifyOtp(code: "' + String(i).padStart(4, "0") + '") { ok }').join(" "); const { status, body } = await gql("mutation { " + aliases + " }", bob); expect(status).toBe(400); // Và KHÔNG có alias nào thực thi — không mã OTP nào bị thử. expect(body.data).toBeUndefined();}); // Introspection tắt ở production build.test("introspection is disabled in production", async () => { const { body } = await gql("{ __schema { types { name } } }", ""); expect(body.data?.__schema).toBeUndefined();});Common mistakes
| The "fix" | Why it is wrong |
|---|---|
Authorise at the /graphql endpoint | It is one endpoint, but the surface is every field. Authorisation must live in each resolver |
| Rely on the UI not requesting the sensitive field | The client holds the query. Every field in the schema is publicly queryable (API3) |
| Rate limit per HTTP request | Batching packs thousands of operations into one request. Rate limit by query COST |
| A depth cap only | A shallow-but-wide query is still a DoS. You also need a COMPLEXITY cap |
| Disable introspection and call it safe | It only slows things. "Did you mean" and trying still leak fields. Field-level authz is what blocks |
| Trust resolver arguments | An argument into SQL is injection, into a fetch is SSRF. Arguments are untrusted input |
Scatter if (canSee) across resolvers | Open-by-default; resolver 41 forgets. Authorise at the data layer / a data loader |
| "GraphQL is safer than REST" | It is neither safer nor worse — it moves the vulnerabilities to the field tier a REST habit does not watch |
The model mistake, and it is the central one: bringing REST thinking (one route, one check) to GraphQL. GraphQL has ONE endpoint and countless query paths, so the check must live at the field tier, and resource limits must live at the query-shape tier — not the route tier.
The classification mistake: treating GraphQL as its own topic needing new techniques. Its vulnerabilities ARE access-control, api-security, info-disclosure, sql-injection, ssrf — just seen from a different tier. If you have read those topics, what remains is applying them at the resolver tier.
Comments
Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.
You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.
Loading comments…