GraphQL Mutation Authorization Bugs in Bug Bounty
GraphQL puts many actions behind one URL, so endpoint-based access rules have little to work with. The authorization decision must survive operation selection, resolver calls, nested objects, and every identifier accepted in the input. One missed resolver can expose an owner-only action to an ordinary member.
Create two users in separate organizations when the product supports tenancy. Plant recognizable canaries in both. Introspection is helpful, but a disabled schema browser does not end the test; operation names live in JavaScript, mobile clients, errors, and proxy history.
Make a mutation inventory
Group mutations by consequence: profile edits, invitations, role changes, exports, billing, token creation, deletion, and support actions. Record required UI role and input object. Compare ordinary and privileged clients if you have authorized roles.
Start with a valid mutation generated by the UI. Change one variable at a time. Hand-written queries often fail on syntax or missing fields before reaching authorization, which produces noisy false negatives.
Test three authorization layers
Operation-level authorization asks whether your role may call promoteMember at all. Object-level authorization asks whether the memberId belongs to your organization. Field-level authorization asks whether sensitive input such as role: OWNER or isVerified: true is accepted from this role.
A common flaw protects the mutation name but trusts nested identifiers:
mutation UpdateAddress($input: AddressInput!) {
updateAddress(input: $input) { id city }
}
Swap only input.id with the second account's address. If the response hides the object but the other account's city changes, authorization failed even though GraphQL returned an error or null.
Global IDs may be base64 wrappers around type and database ID. Decode your own IDs to understand the format, then use IDs from your second account. Never enumerate real records.
Resolver edges people miss
Aliases can call the same mutation several times in one request. They are useful for checking whether authorization state changes after the first resolver, but keep the batch tiny. Mutation batching over JSON arrays may pass through a gateway rule that checks only the first operation.
Nested creation inputs can smuggle protected fields past top-level validation. Deprecated mutations, alternate names used by mobile clients, and node(id:) helpers may invoke older resolver paths. Variables and inline arguments should receive the same checks; persisted-query gateways sometimes treat them differently.
Race conditions deserve a separate test only when the action is reversible. Two concurrent invitation acceptances or ownership transfers can violate assumptions even when each resolver has an auth check.
Read the result in the database state
GraphQL often returns HTTP 200 with an errors array. Conversely, it may return the requested object while silently ignoring a protected field. Re-query both controlled accounts after every attempt. State change is the evidence.
Your report should include the operation, variables, attacker role, target object's ownership, control request, and before/after query. Avoid massive schema dumps. Explain the broken boundary in product language: “workspace member can revoke an owner,” not “GraphQL IDOR.”
Fix authorization inside every resolver or shared domain service, derive tenant context from the authenticated principal, and reject client-supplied ownership fields. PortSwigger's GraphQL security material covers discovery and attack surfaces. The payout comes from checking what each resolver is allowed to touch.