Broken Object-Level Authorization in GraphQL Resolvers
Authenticating a GraphQL caller is not the same as authorizing them for a specific object. When resolvers trust their id arguments, one logged-in user can read and mutate another user's data. Here is how BOLA reaches through resolver arguments, and how to enforce checks at every object.
{ user(id: 1043) { email } }On this page
The most common serious GraphQL vulnerability is also the least dramatic to look at: a resolver that fetches an object by id and returns it without asking whether this caller is allowed to see that object. The request is authenticated, the user is logged in, everything looks correct — and that is exactly the trap. Authenticated is not authorized. Knowing who is calling tells you nothing about which records they may touch.
This is broken object-level authorization (BOLA), the GraphQL form of IDOR, and it sits at the top of the OWASP API Security list for good reason. GraphQL makes it especially easy to introduce, because authorization has to live inside every resolver, and a graph of nested resolvers gives you many places to forget it.
How it happens
A resolver receives an id as an argument and trusts it. The query checks that the caller is logged in — often in middleware, far from this code — but never checks that the caller owns the object being fetched:
const resolvers = {
Query: {
user: (_, { id }) => db.users.findById(id),
node: (_, { id }) => db.findByGlobalId(id),
},
};
Nothing here asks "may the current caller read user id?" The argument is treated as a lookup key, not as an untrusted claim about which object to return. The same gap appears in mutations, where the cost is higher because the attacker is writing rather than reading.
GraphQL adds a second failure mode that REST does not have: nested resolvers. A top-level field might be authorized correctly, but the fields it returns are resolved independently, and a nested resolver that skips its own check can hand back data the entry point would have blocked.
A concrete attack
The attacker is authenticated as their own account and simply asks for an id that is not theirs. Sequential, guessable ids make this trivial:
{ user(id: 1043) { id email passwordResetToken } }
If the resolver returns account 1043, that is an IDOR straight through the API — another user's email and reset token, readable by anyone with a valid session. The Relay-style node field is an even broader lever, because it resolves any object by its global id from a single entry point:
{ node(id: "VXNlcjoxMDQz") { ... on User { email } } }
Nested resolvers leak in a subtler way. Here the top-level me correctly returns only the caller's own record, but the messages field underneath resolves without re-checking ownership of each message's recipient:
{ me { messages(boxId: "other-users-box") { subject body } } }
Mutations are the most damaging, because a missing per-object check turns reading into writing — overwriting a record that belongs to someone else:
mutation { updateUser(id: 1043, input: { email: "[email protected]" }) { id } }
An authenticated session answers "who is calling?". It never answers "may this caller touch this object?". Only a check inside the resolver, against the specific id, can answer that — and it has to run on every object the query reaches, not just the first.
Why it matters
BOLA needs no special tooling and no injection payload. A normal, authenticated client iterating ids can read or rewrite records across the entire user base, and because the traffic is well-formed and authenticated, it rarely trips a naive alarm. When the vulnerable resolver backs a mutation, a single request can hijack an account by changing its email or reset token. Guessable, sequential ids make the whole class cheap to exploit, because the attacker never has to discover a valid id — they just count.
The fix: authorize every resolver at the object level
Authorization belongs in every resolver that returns data, checked against the specific object, not only at the entry point and not only as "is the caller logged in". Pair that with identifiers an attacker cannot enumerate.
const resolvers = { Query: { user: (_, { id }, ctx) => { if (!ctx.user) throw new ForbiddenError('auth required'); if (!ctx.can('read', 'user', id)) throw new ForbiddenError('denied'); return db.users.findById(id); }, }, User: { messages: (parent, { boxId }, ctx) => { if (!ctx.can('read', 'mailbox', boxId)) throw new ForbiddenError('denied'); return db.messages.byBox(boxId); }, }, Mutation: { updateUser: (_, { id, input }, ctx) => { if (!ctx.can('update', 'user', id)) throw new ForbiddenError('denied'); return db.users.update(id, input); }, }, };
The essential controls:
- Enforce authorization in every resolver at the object level — each field that returns or mutates a record must verify the current caller may act on this object, not merely that they are authenticated.
- Do not authorize only at the top level — nested resolvers run independently, so the access check has to repeat on every object the query traverses.
- Cover mutations explicitly with the same per-object check as queries, since a missing check there lets an attacker overwrite another user's data.
- Avoid guessable or sequential ids — use UUIDs or other non-enumerable identifiers so an attacker cannot walk the id space, treating this as defence in depth, never as the authorization itself.
How SelfSec finds it
SelfSec detects GraphQL endpoints during the crawl and introspects the schema to find object-returning queries and mutations that take an id argument, including Relay-style node fields. Authenticated as the session it is given, it issues object-id requests for identifiers outside the caller's own scope and keys findings on the response: a resolver that returns another object's data, or a mutation that reports success against an id the caller should not control, confirms broken object-level authorization rather than guessing from the schema shape. It exercises nested resolvers too, where the top-level field is guarded but the fields beneath it are not. The whole scan runs locally on 127.0.0.1, your schema and scan data stay on your machine, and each finding ships with the exact request to reproduce it.
SelfSec is intended strictly for authorized security testing of systems you own or are explicitly permitted to assess.
Do both things about GraphQL
SelfSec covers this class from both sides — the scanner confirms it in your own app, the firewall blocks it in front of your origin while the fix ships.