Crown Jewel Targets
GraphQL vulnerabilities are high-value because the attack surface is both broad and deep — a single endpoint can expose entire data models, privilege escalation paths, and cross-API state confusion. Highest payouts occur in:
- Platform APIs (GitHub, Shopify, Stripe-tier targets) where GraphQL mutations interact with REST APIs managing the same resources
- Race conditions between GraphQL mutations and REST endpoints where state synchronization is non-atomic — these hit medium-to-high severity reliably
- Authorization persistence bugs where team/org/repo membership state is controlled by one API but readable/writable by another
- B2B SaaS platforms where one tenant affecting another via schema traversal = critical
- Internal admin GraphQL endpoints accidentally exposed to lower-privilege users
The GitHub reports demonstrate the crown jewel pattern: privilege that should be revoked persists because two APIs disagree on ground truth.
Attack Surface Signals
URL Patterns:
/graphql
/api/graphql
/v1/graphql
/query
/gql
/graph
/api/v2/graphql
/internal/graphql
Response Headers:
Content-Type: application/json (with query body)
X-Request-Id + no REST-style path params = likely GraphQL
JavaScript Source Patterns:
// grep for these in JS bundles
"query {"
"mutation {"
"__typename"
"apollo"
"ApolloClient"
"graphql-tag"
"gql`"
"operationName"
"GRAPHQL_URI"
Tech Stack Signals:
- Apollo Server/Client in JS bundles
- Relay in React apps
grapheneorstrawberry(Python),graphql-ruby,gqlgen(Go),Lighthouse(Laravel)- POST requests with
{"query": "..."}body shape in Burp history __schemaor__typein any response = confirmed GraphQL
Recon Sources:
github.comsearch:"graphql" site:target.com- Wayback Machine for
/graphqlpaths - JS bundle scanning with
LinkFinderorgetallurls
Step-by-Step Hunting Methodology
-
Discover the endpoint — spider JS bundles, check
/graphql,/api/graphql, review Burp passive scan hits forapplication/jsonPOST with query fields -
Test introspection — send the full introspection query. Even if blocked, try field-level enumeration:
{ __typename }If that returns, introspection may be partially blocked but the schema is discoverable
-
Map the full schema — use
InQL(Burp extension) orgraphql-voyagerto visualize relationships. Specifically look for:- Mutations that modify ownership, permissions, or membership
- Mutations that mirror REST API functionality
-
Identify REST/GraphQL overlap — document every resource that can be modified via BOTH REST and GraphQL. These dual-write surfaces are your RC targets.
-
Test authorization boundaries per mutation — replay mutations as lower-privilege users. Does the server enforce the same authz as the equivalent REST call?
-
Hunt cross-API state desync — find sequences where:
- REST action should revoke access
- GraphQL mutation re-grants or preserves it
- Test the ordering: REST first → GraphQL → check state; then GraphQL first → REST → check state
-
Test for persistent privilege after role/membership changes — remove a user via REST, then call the corresponding GraphQL mutation for that resource. Query current state via both APIs and compare.
-
Probe for IDOR in node IDs — GraphQL global IDs often encode object type + ID. Swap IDs across object boundaries and across account contexts.
-
Check batch query abuse — send arrays of operations to bypass rate limiting or amplify enumeration.
-
Document the exact reproduction chain — for RC bugs, time-based steps must be reproducible deterministically.
Payload & Detection Patterns
Full Introspection Query:
{
__schema {
types {
name
fields {
name
type {
name
kind
}
}
}
}
}
Minimal Introspection Probe (bypass attempt):
{ __typename }
curl introspection test:
curl -s -X POST https://target.com/graphql \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"query":"{ __schema { queryType { name } } }"}' | jq .
Field suggestion probe (bypass blind introspection blocks):
{ unknownField }
If response returns "Did you mean: [realFieldName]?" — schema is enumerable despite introspection being disabled.
Batch query amplification:
[
{"query": "{ user(id: 1) { email } }"},
{"query": "{ user(id: 2) { email } }"},
{"query": "{ user(id: 3) { email } }"}
]
RC desync test pattern (pseudo-sequence):
# Step 1: Grant access via REST
curl -X PUT https://api.target.com/repos/ORG/REPO/teams/TEAM \
-H "Authorization: token ADMIN_TOKEN" \
-d '{"permission":"admin"}'
# Step 2: Revoke via REST
curl -X DELETE https://api.target.com/repos/ORG/REPO/teams/TEAM \
-H "Authorization: token ADMIN_TOKEN"
# Step 3: Re-assert via GraphQL mutation
curl -X POST https://api.target.com/graphql \
-H "Authorization: bearer ATTACKER_TOKEN" \
-d '{"query":"mutation { updateTeamsRepository(input: {repositoryId: \"REPO_ID\", teamId: \"TEAM_ID\", permission: ADMIN}) { clientMutationId } }"}'
# Step 4: Verify persistent access
curl https://api.target.com/repos/ORG/REPO/teams \
-H "Authorization: token ADMIN_TOKEN"
Grep for GraphQL in JS bundles:
grep -Eo '(query|mutation|subscription)\s+\w+\s*[\({]' bundle.js
grep -Eo '"(/[a-z0-9/_-]*graphql[a-z0-9/_-]*)"' bundle.js
InQL / clairvoyance for blind schema enumeration:
python3 clairvoyance.py -u https://target.com/graphql \
-H "Authorization: Bearer TOKEN" \
-w wordlist.txt -o schema.json
Common Root Causes
-
Dual-write without atomic locking — developers implement the same resource modification in both REST and GraphQL independently. Neither system is aware the other exists for that resource. State updates aren't serialized or compared.
-
Inconsistent authorization middleware — REST endpoints go through one auth layer (e.g., middleware chain), GraphQL resolvers go through a different resolver-level check. The same action, different enforcement.
-
GraphQL as "new REST" migration — teams add GraphQL mutations that mirror REST functionality without auditing the permission model. The GraphQL version is less mature and skips checks the REST version accumulated over time.
-
Introspection left on in production — default framework settings (Apollo, Graphene) enable introspection in all environments. Developers forget to disable it, treating it as "just documentation."
-
Node ID trust without re-authorization — GraphQL global IDs (
base64("ObjectType:123")) are decoded and trusted without verifying the requesting user has access to that specific object. -
Mutation side effects not mirrored — when a REST action triggers cascading effects (e.g., team removal cascades to permission revocation), the GraphQL equivalent mutation doesn't trigger the same cascades.
Bypass Techniques
Defense: Introspection disabled
- Bypass via field suggestion errors — send invalid field names and parse "did you mean X?" responses
- Use
clairvoyanceto brute-force field names against a wordlist - Check JS bundles for hardcoded query strings that reveal the schema
Defense: Depth limiting
- Fragment spread to increase effective depth without hitting the limiter:
fragment F on User { repos { teams { members { ...F } } } }
Defense: Rate limiting per IP
- Use batch operations (array of queries in one POST)
- Distribute across authenticated sessions
Defense: Auth checks on mutations
- Test with tokens at different privilege tiers (viewer, member, admin)
- Test unauthenticated — some mutations don't check session at all
- Test with tokens from *different organiz