Blog · API Security

BOLA in GraphQL: how ‘opaque’ Global IDs still leak data

GraphQL does not give you a new class of bug. It gives the single most common API flaw, broken object level authorization, a bigger and harder-to-scan surface. Here is how it breaks behind base64 Global IDs that only look unguessable, three disclosed proofs, and the two-account query that settles whether your API has it.

Planck Proof · Offensive Security Team · September 15, 2026 · 9 min read

On August 17, 2026, GitLab shipped an out-of-band patch for CVE-2026-19478, a critical (CVSS 9.4) flaw in which a GraphQL directive was evaluated before field-level authorization ran, letting an unauthenticated caller modify or delete public projects. It was the kind of finding that only happens in GraphQL: authorization wired to the wrong layer, bypassed by a request the schema was happy to accept. The directive bug is its own story, but it points at the theme of this article. GraphQL is not a new bug factory. It is a new surface for an old, familiar failure.

That failure is Broken Object Level Authorization, API1:2023, the top risk on the OWASP API Security Top 10 and the same bug the rest of the industry calls IDOR. In a REST API it is the endpoint that returns any record whose ID you put in the path. In GraphQL it is the resolver that returns any object whose ID you put in a variable. The move to GraphQL does not fix it. It makes it easier to introduce, harder to test, and, thanks to one convention, easy to mistake for solved.

The one-endpoint problem

A REST API spreads its objects across many routes, and authorization usually lives in middleware that runs once, before the route handler. It is coarse, but it is in the path of every request. GraphQL collapses all of that into a single POST /graphql. One query can touch dozens of fields, each resolved by its own function, and there is no middleware choke point that sees them all. Authorization has to be enforced inside each resolver, on each object, on every request.

This is exactly where teams get caught. A guard on the query root protects the entry point a client names, but nested resolvers, fragments, aliases, and the node(id:) interface below it inherit no protection by default. Authorization gets bolted on at the HTTP layer, REST-style, and never propagated down to the resolver that actually loads the object. The result is a valid query, a correct-looking 200 OK, and another tenant's data in the response.

Anatomy of a ‘Global ID’

Many GraphQL schemas hand out a Global ID (a node ID) for every object, so a client can refetch anything through one node(id:) resolver. It looks opaque. It is not. A Global ID is just base64 over a plain string of the form gid://service/Type/N, where N is the backend primary key. Decode it and the disguise falls off:

# A Global ID as it appears in an API response
Z2lkOi8vZXhhbXBsZS9JbnZvaWNlLzEwNDI=

# It is only base64. Decode it.
echo 'Z2lkOi8vZXhhbXBsZS9JbnZvaWNlLzEwNDI=' | base64 -d
# -> gid://example/Invoice/1042

# The "opaque" identifier is a sequential integer. Add one, re-encode.
printf '%s' 'gid://example/Invoice/1043' | base64
# -> Z2lkOi8vZXhhbXBsZS9JbnZvaWNlLzEwNDM=

The encoding is a reminder to treat the value as opaque, not a security control. RFC 9562, the current UUID specification, says the quiet part out loud in its Security Considerations: implementations “SHOULD NOT assume that UUIDs are hard to guess… they MUST NOT be used as security capabilities (identifiers whose mere possession grants access).” A base64 Global ID is weaker still, because the integer inside it is sequential. The point holds either way: an identifier is not authorization.

The proof nobody prints

Most “GraphQL security” guides define BOLA, show a one-line vulnerable resolver, and stop. The artifact that decides whether you actually have the bug is two queries from two accounts and what came back. Here is that artifact. Account A and account B are ordinary users of the same API. Account A reads its own invoice, notes the Global ID, decodes it, increments the integer, re-encodes it, and asks again, still as itself. Illustrative example, not a specific customer result.

# Baseline: account A refetches its own object through node(id:)
curl -s https://api.example.com/graphql \
  -H "Authorization: Bearer <ACCOUNT_A_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"query":"query($id:ID!){node(id:$id){... on Invoice{id total email}}}",
       "variables":{"id":"Z2lkOi8vZXhhbXBsZS9JbnZvaWNlLzEwNDI="}}'

# -> 200 OK
{ "data": { "node": { "id": "...1042", "total": "120.00", "email": "[email protected]" } } }

# Attack: same token, the decoded integer incremented and re-encoded
curl -s https://api.example.com/graphql \
  -H "Authorization: Bearer <ACCOUNT_A_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"query":"query($id:ID!){node(id:$id){... on Invoice{id total email}}}",
       "variables":{"id":"Z2lkOi8vZXhhbXBsZS9JbnZvaWNlLzEwNDM="}}'

# -> 200 OK
{ "data": { "node": { "id": "...1043", "total": "4210.00", "email": "[email protected]" } } }

What proves the flaw: the second query carries account A's token throughout, yet returns account B's email and invoice total with a clean 200 OK and no error. Account A was never re-authenticated; the node resolver just loaded the object because the ID decoded to a valid primary key, not because it checked who was asking. As with any BOLA, the oracle matters as much as the request: a 200 OK proves nothing on its own, because the field might be public or empty. The finding is real only when the response carries data that provably belongs to account B, a field tied to B and not to A. That is the exact line between a genuine cross-tenant read and a false positive, and it is the line a signature cannot see.

This is not theoretical

The pattern above is one of the most reliably disclosed bugs in public bug bounty programs. Three examples, all resolved and public:

  • GitLab, ML Model Registry. An IDOR in the getModel GraphQL query (report #2528293, $1,160) exposed any private machine-learning model. The variable was gid://gitlab/Ml::Model/1000401, and, in the reporter's words, the IDs were “easy to guess as they are incremental.”
  • HackerOne’s own platform. An IDOR in the AddTagToAssets operation (report #2633771) turned on a tagId that decoded to gid://hackerone/AsmTag/4979xxxx: decode, brute-force the integer, re-encode, reach another user's asset tag.
  • Shopify, billing. An IDOR on the BillDetails and BillingDocumentDownload GraphQL operations (report #2207248, $5,000) leaked other merchants' email, full address, and the last four digits and type of their payment card.

A 2026 empirical taxonomy of 84 confirmed BOLA disclosures put numbers on it: sequential integers remain the single most common identifier format, but non-sequential formats, including encoded Global IDs, still account for 39.2 percent of known-format cases. GraphQL-specific mechanisms, global-ID leakage combined with encoded-ID manipulation, appear in a combined 9.6 percent. Opacity did not save any of them.

Why UUIDs and ‘opaque’ IDs don’t save you

The usual reaction is to make the identifier harder to guess: switch the primary key to a UUID, or lean on the base64 wrapper. Neither adds an authorization check, and both leak. In GraphQL the leaks are structural. A node appears in one field, then again as an edge in a connection, then again in a mutation payload, then in an error message that echoes the ID back. Any field that returns another user's Global ID reopens the door for every other field that accepts one. We wrote about why unguessable names are defense in depth rather than authorization in BOLA vs IDOR. GraphQL simply hands attackers more places to pick the ID up.

Batching and aliasing: one request, many authorization decisions

GraphQL also lets an attacker fan a single request across many objects. Aliases let one query ask for the same field several times with different IDs, and query batching lets one HTTP request carry an array of operations. Where a REST attacker walks IDs one request at a time, a GraphQL attacker can enumerate a range of decoded integers in a single round trip, often sliding under per-request rate limits that were never counting fields. The authorization gap is the same BOLA; the blast radius and the speed are larger.

Why scanners and DAST miss it

If BOLA is the top API risk, why does it survive automated testing? Because scanners match patterns, and BOLA has no pattern. A scanner watches a valid query go out and a 200 OK come back and has no idea whose data is in the response. It has no notion of “account A” and “account B,” no model of which object should belong to whom, so a cross-tenant read looks exactly like a user reading their own record. Access-control add-ons exist, but they require you to hand-define, per user, every object each identity is allowed to reach before the tool can flag a violation. That is authorization logic, supplied by a human, not something a signature discovers. See agentic pentesting vs DAST for why that gap is structural rather than a tuning problem, and why a large language model pointed at the same task, without a verification step, tends to report confident findings at roughly a one-in-five false-positive rate.

The fix, and how to test for it

The fix lives in one place: every resolver that returns an object must check that the authenticated caller is entitled to that object, regardless of how the field was reached, direct query, nested traversal, fragment, alias, or the node(id:) interface. Authorization at the query root is not enough. Treat the Global ID as an opaque handle for refetching, never as proof of ownership.

Testing follows the same shape as the fix. Work from the GraphQL schema, not a crawl. List every query and mutation that accepts an object ID, expand the node interface, and cover reads, writes, and deletes. Provision two peer accounts, capture a Global ID that belongs to account B, and replay it under account A across every operation. The methodology hub for the broader matrix, across roles, tenants, and object properties, is API authorization testing; the object-level slice, with more worked examples, is BOLA testing.

How Operator proves it

This is the class of bug Operator is built to catch, and the reason it can is the reason the explainers cannot: it does not stop at flagging a suspicious field. It reads your GraphQL schema the way it reads an OpenAPI spec, enumerates every operation that accepts an object ID, decodes and manipulates Global IDs the way an attacker would, and replays the boundary between identities across nested fields, aliases, and batches. It reports a finding only when a response provably contains another identity's data, with the two-account query pair attached as evidence. An engineer confirms it in minutes instead of debating whether it is real. We wrote more about proof-based delivery in how agentic pentesting proves an exploit, and the whole approach is set out on agentic API penetration testing. GraphQL does not get a lighter test. It gets the same one, run over a wider surface.

FAQ

Common questions

Are GraphQL Global IDs (node IDs) safe against IDOR?

No. A Global ID such as gid://service/Type/1042 is only base64-encoded, not encrypted. Decode it and you usually find a sequential integer you can increment and re-encode. Opacity is not authorization: unless the resolver checks that the authenticated caller owns that specific object, the server returns it to anyone who presents a valid ID. An empirical study of disclosed reports found non-sequential identifier formats, including encoded Global IDs, in 39.2 percent of known-format BOLA cases.

Does switching to UUIDs stop BOLA in GraphQL?

No. UUIDs make an identifier hard to guess blind, but they do not add an ownership check, and IDs leak through other query fields, node and edge lists, logs, referrer headers, and shared links. RFC 9562 states that UUIDs must not be used as security capabilities. The fix is a server-side authorization check in every resolver that returns an object, not an unguessable name.

Can a scanner or DAST tool find BOLA in a GraphQL API?

Rarely. A scanner sends a valid query, gets a 200 OK, and has no concept of which user should own which object, so a cross-tenant read looks identical to a user reading their own record. Access-control add-ons exist but require you to hand-define, per user, which object each identity may reach. Detecting GraphQL BOLA takes two authenticated identities and a reproducible query that reads across the boundary, then confirms the response contains the other identity's data.

How do you test a GraphQL API for broken object level authorization?

Work from the schema, not a crawl. Enumerate every query and mutation that accepts an object ID, including the global node(id:) resolver, nested fields, and aliased or batched operations. Authenticate as two peer accounts, capture a Global ID that belongs to account B, then request it with account A's token across reads, writes, and deletes. The finding is real only when the response returns a field that provably belongs to account B and not account A.

Get Started

Find out if your GraphQL API leaks across accounts

Give Operator your schema and a token per role. It enumerates every operation that accepts an object ID, tests the boundary between identities, and proves any object one user can reach that belongs to another.