SecLab

GraphQL API vulnerabilities

API1API4CWE-639CWE-770
01

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.

02

Why you should care

Relevance: CoreExpected: L2

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 fetching user { 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 no pageSize parameter 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.

03

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.

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

ProblemMechanismIs the topic
Missing field-level authzA resolver does not check ownership/roleaccess-control (API1)
Query depth/complexity DoSDeep nesting, exponential expansionapi-security (API4), CWE-770
Batching bypasses rate limitsThousands of operations in one requestrate-limiting
Introspection leaks the schema__schema returns the whole structureinfo-disclosure
Field suggestions via errors"Did you mean...?" leaks field namesinfo-disclosure
Injection in argumentsResolver arguments into SQL/NoSQLsql-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.

04

Concrete example

The same schema, three attack paths.

graphql
# ① 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  }}
graphql
# ② 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.
graphql
# ③ 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.}
JSON
// ④ 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.
C#A resolver with no ownership check, and a server with no depth or batch limits.
// 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();
05

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.

06

How to defend

Layer 1

Authorise in EACH resolver, not at the route level

mandatory

GraphQL 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. An orders resolver must filter by the caller, not return every order.
  • Sensitive fields check the role in the resolver itself. paymentDetails, internalNotes need 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.

C# · Layer 1Authz per resolver, depth + complexity caps, introspection off in production.
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(),});
Layer 1b

Depth, complexity and batch caps — the client holds the query, so bound it

mandatory

REST 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↔posts and 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.

Layer 1c

Disable introspection and field suggestions in production

This is the information-disclosure control (the info-disclosure topic), applied to GraphQL.

  • Disable introspection in production. __schema and __type return 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.

Layer 2

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 argument url a 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.

Layer 3

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 verifyOtp aliases is not a real client.
  • __schema/__type queries 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".

07

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:

Shell
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 fast

3. Test batching is limited — the GraphQL-specific check few people write:

Shell
# 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 400

4. Check introspection is off in production:

Shell
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 0

5. 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).

TypeScriptField-level cross-user, depth cap, and batching — GraphQL's three core checks.
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();});
08

Common mistakes

The "fix"Why it is wrong
Authorise at the /graphql endpointIt is one endpoint, but the surface is every field. Authorisation must live in each resolver
Rely on the UI not requesting the sensitive fieldThe client holds the query. Every field in the schema is publicly queryable (API3)
Rate limit per HTTP requestBatching packs thousands of operations into one request. Rate limit by query COST
A depth cap onlyA shallow-but-wide query is still a DoS. You also need a COMPLEXITY cap
Disable introspection and call it safeIt only slows things. "Did you mean" and trying still leak fields. Field-level authz is what blocks
Trust resolver argumentsAn argument into SQL is injection, into a fetch is SSRF. Arguments are untrusted input
Scatter if (canSee) across resolversOpen-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.

09

References

Tier 1API4:2023 Unrestricted Resource Consumption · OWASP · API Security Top 10 · 2023
Tier 1GraphQL Specification — Introspection · GraphQL Foundation · GraphQL spec · October 2021
Tier 2GraphQL API vulnerabilities · PortSwigger · Web Security Academy
Tier 2GraphQL Cheat Sheet · OWASP · Cheat Sheet Series
Tier 2API3:2023 Broken Object Property Level Authorization · OWASP · API Security Top 10 · 2023
Part of path
Secure Backend DeveloperView path

Comments

Join the discussion
Sign up to comment

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.

Sign upSign in

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…