Comprehensive IDOR Guide#
A practitioner’s reference for Insecure Direct Object Reference (IDOR) and Broken Object Level Authorization (BOLA) — attack surface, enumeration patterns, bypass techniques, real-world writeups, detection workflow, and prevention. Compiled from 21 research sources.
Table of Contents#
- Fundamentals
- IDOR vs BOLA vs BFLA
- Attack Surface & Where Identifiers Live
- Horizontal vs Vertical Access
- Identifier Enumeration Patterns
- Parameter Tampering Techniques
- HTTP Method & Verb Tampering
- Content-Type & Format Bypasses
- Path, Version, and Endpoint Tricks
- Mass Assignment Overlap
- UUID & Unpredictable ID Defeats
- Second-Order and Blind IDOR
- GraphQL, WebSocket, and Non-REST Surfaces
- Real-World Writeups & CVEs
- Exploit Chains
- Detection Methodology with Autorize
- Tools & Automation
- Impact & Severity Mapping
- Prevention & Secure Design
- Testing Checklist
- Report Writing
1. Fundamentals#
IDOR occurs when an application uses user-supplied input to reference an internal object (database row, file, resource) and fails to verify whether the current user is authorized to access that specific object. The application trusts the identifier, not the identity.
At its core, IDOR is a missing-authorization bug. Authentication answers “who am I?” IDOR-class bugs live in the failure to answer “what am I allowed to touch?”
The shape of an IDOR:
GET /api/users/12345/orders <-- you own 12345
GET /api/users/12346/orders <-- 12346 belongs to someone else
200 OK => IDOR
Three ingredients are required:
- A direct reference to an object (numeric ID, UUID, hash, slug, filename, path)
- A server-side operation (read, write, delete, invoke) that consumes the reference
- Missing or incomplete ownership/permission check between request identity and target object
Impact spectrum: PII exposure → private data modification → account takeover → privilege escalation → full multi-tenant compromise.
IDOR is a logic bug. There is no single signature, no single framework fix, and no blanket scanner rule. It requires a human eye, two accounts, and systematic replay.
2. IDOR vs BOLA vs BFLA#
The terminology overlaps. All three describe broken access control but at different granularity.
| Term | Meaning | OWASP |
|---|---|---|
| IDOR | Classic — user-supplied reference accesses an object without ownership check | Legacy OWASP Top 10 (A4:2007) |
| BOLA | Broken Object Level Authorization — same bug, API-focused naming | OWASP API Top 10 #1 |
| BFLA | Broken Function Level Authorization — regular user invokes admin-only function | OWASP API Top 10 #5 |
| Mass Assignment | Client submits extra fields (role, isAdmin, accountId) that the server blindly binds | OWASP API Top 10 #6 |
BOLA = “can I read user 999’s data?” BFLA = “can I call /admin/deleteUser as a regular user?” Mass Assignment = “can I add "role":"admin" to my profile update?” All three live under the access-control umbrella and are routinely chained.
3. Attack Surface & Where Identifiers Live#
Build a mental map of where references show up before you start flipping values.
Common identifier locations#
| Location | Examples |
|---|---|
| URL path | /api/invoices/98432, /users/johndoe, /documents/view/a1b2c3d4 |
| Query string | ?userId=5001, ?account=ACCT-112233, ?fileId=xyz789 |
| Request body (JSON/XML/form) | {"recipientId":"user_abc","organizationId":"..."} |
| Custom headers | X-User-ID, X-Account, X-UID, W-User-Id, User-Token |
| Cookies | uid=5001, session tokens that encode user ID |
| Referer / Origin | IDs leaked into referrer on cross-link navigation |
| GraphQL variables | mutation UpdateSound(input:{uuid:"..."}) |
| WebSocket frames | {"action":"subscribe","channelId":"user_5001"} |
| Hidden form fields | <input type="hidden" name="user_id" value="12345"> |
| JWT claims | Server trusts sub or custom claim without re-verifying resource ownership |
High-value feature areas#
These features are where IDOR bounties concentrate:
- Account management — profile update, email change, password reset, 2FA settings
- Billing & payments — stored cards, invoices, subscription management, refunds
- File operations — upload, download, share, copy, move, delete
- Messaging — direct messages, chat invites, group membership, draft auto-save
- Export / reporting — scheduled exports, report generators, downloads
- Org/tenant settings — SSO config, team members, API tokens, role assignments
- New features — anything shipped in the last release cycle (less-tested paths)
4. Horizontal vs Vertical Access#
| Type | Attacker | Target |
|---|---|---|
| Horizontal | User A (same role) | User B’s objects at the same privilege level |
| Vertical | Low-privilege user | Admin/higher-role functions or objects |
| Unauthenticated | Anonymous | Any authenticated user’s objects |
| Cross-tenant | User in org A | Objects inside org B (the common SaaS variant) |
Unauthenticated IDOR is the jackpot — no account required, mass-scrape-ready, and almost always critical. Always retry requests with the Authorization header stripped.
Cross-tenant IDOR is the modern variant. A user inside tenant A changes the organizationId in a PUT body and writes into tenant B’s config. This is exactly the Flowise CVE-2025-XXXX pattern — the endpoint checked authentication but not which org the JWT belonged to.
5. Identifier Enumeration Patterns#
Sequential integers#
The simplest, most paid bug class. Increment, decrement, and probe around your own ID.
- Don’t just go
+1/-1. Try small IDs (1,2,3) — admin accounts often sit at the bottom. - Try
0,-1,00001234(leading zeros),1234.0,1e3. - Scrape press releases, public profiles, and disclosed writeups for real victim IDs.
UUIDs and hashes — still reachable#
UUIDs are not access control. They are defense in depth against guessing. You still win when:
- UUIDs leak in public profile URLs, share links, invite emails, or profile pictures
- UUIDs appear in API responses you already receive (
friendslist,comments,mentions) - The same UUID gets reused across resources (one ID, many endpoints)
- Old Wayback Machine snapshots expose them
- Google/Bing dorks find indexed copies
- Sign-in / password reset flows return UUIDs on error
- In-app sharing links, email unsubscribe links, @mentions
Encoded IDs#
Decode them. Try:
/profile/MTIzNDU2 -> base64("123456")
/file/5d41402abc4b -> MD5("hello")
/account/7541A92F-0101-... -> try substituting numeric 1234
If a portion of the encoded value is static and a portion changes linearly across IDs you own, you have a predictable namespace.
me / current / self aliases#
If the API takes /api/users/me, try swapping me for a numeric ID (/api/users/1234). Apps often support both forms and only one checks authorization.
ID references scraped from the app itself#
Before brute forcing, harvest IDs from within the authenticated app:
- JavaScript bundles (look for hard-coded IDs, admin panels, dev endpoints)
- HTML source of list/index pages
.mapsource maps- API responses that enumerate resources for pagination
- Autocomplete endpoints (
/api/users/search?q=) - Error messages that echo IDs
6. Parameter Tampering Techniques#
When a straight ID swap returns 403 or 404, mutate the parameter shape.
ID value mutation table#
| Technique | Example | Why it works |
|---|---|---|
| Wrap in array | {"id":19} → {"id":[19]} | ORM unwraps array; auth check ran on unwrapped scalar |
| Array of targets | {"id":[1234,1235]} | Check runs on first element only |
| Negative | id=-1 | Backend casts; comparator fails |
| Zero | id=0 | Sometimes returns the “default” / first record |
| Large int / leading zeros | id=00001234 | Parser differential between validator and DB driver |
| Float | id=1234.0 | Type coercion skips check |
| String with delimiter | id="1234,1235" | Comma-split on server side |
| Boolean | id=true | Rarely, maps to record 1 |
| Wildcard | id=*, id=% | Some ORMs interpret literally |
| Null byte | id=1234%00 | Truncates in certain string handlers |
| Extra key | add user_id alongside id | Undocumented override |
| Rename key | album_id → account_id | Unexpected param honored unchecked |
HTTP Parameter Pollution (HPP)#
Supply the same parameter twice and see which side of the stack each copy reaches:
GET /api/account?id=<your id>&id=<admin id>
Different servers pick differently:
- Apache/PHP → first value
- Tomcat/Java → first value (often)
- .NET → concatenated with comma
- Node.js (Express default) → array
- Rails → last value
If the authorization middleware reads the first copy and the data layer reads the last copy, you have HPP-to-IDOR.
Add IDs to requests that don’t have them#
Endpoints that implicitly use your session user are often the juiciest. Try adding explicit IDs:
GET /api/MyPictureList
GET /api/MyPictureList?user_id=<victim_id>
The server may honor the unexpected parameter and skip its “current user” shortcut.
Client-supplied IDs on create#
Some apps let the client specify the primary key on create. Try supplying one:
POST /api/notes {"id":"attacker-chosen","content":"..."}
If accepted, you can choose IDs that collide with other users, trigger reference reuse, or squat on predictable future IDs.
Parameter reuse across endpoints#
If album_id works on /api/albums, try the same name on /api/photos, /api/comments. Paramalyzer (Burp extension) tracks parameter names used on a host and hints at reuse opportunities.
7. HTTP Method & Verb Tampering#
Access control is often wired only to the method the client actually uses. Try every verb against every endpoint.
| Original | Try |
|---|---|
GET /api/users/{id} | POST, PUT, PATCH, DELETE, HEAD, OPTIONS |
POST /api/messages | PUT, PATCH |
PUT /api/profile | POST (create), DELETE |
Intigriti’s “Complete Guide” explicitly calls this out: two endpoints can exist under the same path, and only the documented one has the auth middleware attached. A POST to a GET-only URL may hit an orphaned handler.
Also try:
X-HTTP-Method-Override: PUTheader on aPOSTbody_method=DELETEquery parameter (Rails/Laravel accept this)- Lowercase method (
getvsGET) — rare but seen
8. Content-Type & Format Bypasses#
Servers often route requests to different parsers by Content-Type, and only one parser path has authorization. Try swapping:
| Original | Try |
|---|---|
application/json | application/xml, text/xml, text/x-json, application/x-www-form-urlencoded, multipart/form-data |
application/xml | application/json, text/plain |
application/x-www-form-urlencoded | application/json, text/plain |
Example from the Aon writeup:
POST /api/chat/join/123
Content-Type: application/xml -> Content-Type: application/json
<user>test</user> -> {"user":"test"}
Also try swapping file extensions on resource URLs:
/api/reports/55 -> /api/reports/55.json
-> /api/reports/55.xml
-> /api/reports/55.config
-> /api/reports/55.xlsx
Some frameworks serve the same controller via extension-based content negotiation and skip auth filters on the alternate route.
9. Path, Version, and Endpoint Tricks#
API versioning#
New code lives in /v2/, but /v1/ is still routable and never got the patch:
/api/v1/users/{id} <-- vulnerable, forgotten
/api/v2/users/{id} <-- fixed
Try v0, v1, v2, v3, beta, alpha, internal, admin, legacy.
Sibling endpoint discovery#
If you see /api/albums/{album_id}/photos/{photo_id}, guess neighbors:
/api/albums/{album_id}/share
/api/albums/{album_id}/export
/api/albums/{album_id}/audit
/api/albums/{album_id}/metadata
/api/users/{user_id}/details
/api/users/{user_id}/delete
Wordlists: Burp Intruder, ffuf with API-specific lists (arjun, api-endpoints-res), CeWL for app-specific wordlists.
Hidden documentation#
- Swagger / OpenAPI (
/swagger,/api-docs,/v2/api-docs,/openapi.json) - GraphQL introspection (
__schema) - WSDL (
?wsdl) .jsbundles with route tablesrobots.txt/sitemap.xml- Wayback Machine snapshots of old JS
The hipotermia smuggling+IDOR chain started by finding a hidden Swagger UI exposing the full endpoint catalog.
Trailing slashes, case, path params#
/api/users/123vs/api/users/123//API/Users/123(some servers route case-insensitively, filters don’t)/api/users/123/..(traversal side effects)/api/users/123%2F..(URL-encoded traversal)/api/users/123#(fragment truncation)/api/users/123;admin(path parameters)
10. Mass Assignment Overlap#
Mass assignment is IDOR’s evil twin. Instead of flipping the reference ID, you inject extra fields that the server blindly binds to the target object.
// Normal
PUT /api/users/me {"name":"Alice"}
// Mass assignment
PUT /api/users/me {"name":"Alice","role":"admin","isVerified":true,"accountBalance":999999,"organizationId":"victim-org"}
Fields to probe:
role,isAdmin,admin,permissions,groupsverified,emailVerified,isActive,statusbalance,credit,points,tier,planownerId,userId,accountId,organizationId,tenantIdcreatedAt,updatedAt(time-travel attacks)deleted,deletedAt(un-delete)password,passwordHash(direct password set)
Discovery trick: fetch the resource with GET, copy the full response body into a PUT, and resubmit. Server often accepts every field it returned and applies them all. This trivially exposes hidden fields.
The Flowise CVE bundles IDOR + mass assignment + business logic: the organizationId in the body chooses the target (IDOR), the providers array blindly overwrites SSO config (mass assignment), and missing license enforcement lets free-tier users enable enterprise features (business logic).
11. UUID & Unpredictable ID Defeats#
UUIDs raise the bar but don’t end the game.
Harvesting UUIDs#
| Source | Technique |
|---|---|
| Profile pictures | <img src="...avatars/UUID.jpg"> embedded on public profile |
| Share links | Copy-share function returns link with UUID |
| Password reset | Reset endpoint returns UUID in JSON error/success |
| Invitations | “User UUID not found” error messages |
| In-app search | Autocomplete returns {id: UUID, name: ...} |
| Wayback Machine | Historical API responses with UUIDs cached |
| Search engines | site:target.com "uuid" dorks |
| Email unsubscribe | /unsubscribe?uid=UUID links |
| Error pages | Stack traces echoing object IDs |
Predictable “random”#
- v1 UUIDs encode MAC address + timestamp — you can predict adjacent IDs if you know one
- Incrementing snowflakes (Twitter/Discord-style) are sortable by time
- Sequential within a shard — many “random” IDs are only random per table
Leaked via second endpoint#
The two-endpoint trick: one endpoint requires a UUID you don’t know, but a different endpoint returns a list of them. Intigriti’s example: search/enumerate endpoint spits back UUIDs, target endpoint consumes them.
12. Second-Order and Blind IDOR#
Second-order IDOR#
Your input is stored first, then retrieved later in a privileged context with no re-check. Classic example: scheduled export jobs.
- Create an export job
{"scheduleFor":"tomorrow","userId":"me"} - Job gets stored with attacker-controlled
userId - Scheduler worker runs next day, reads
userIdfrom job metadata, generates export - Second step never re-validates that the scheduler’s caller owns
userId
Also seen in:
- Webhook delivery (stored target URL, later consumed server-side)
- Cron/reminder systems
- Audit/log replay
- “Retry failed job” buttons
Blind IDOR#
The HTTP response is generic (success/error) and doesn’t leak the victim data, but the action still took effect. Detection:
- Check your own email — did the state change produce an out-of-band notification?
- Log into the victim account — did something actually change?
- Look for side channels: push notifications, SMS, audit logs, activity feeds
- Timing differences between “hit” and “miss”
13. GraphQL, WebSocket, and Non-REST Surfaces#
GraphQL IDOR#
GraphQL is underexplored territory. Resolvers frequently attach auth at the query root but not at nested resolvers.
query {
user(id: "victim_id") {
email
orders { total, items }
paymentMethods { last4 }
}
}
Checklist:
- Run introspection (
__schema,__type) — if enabled, the whole attack surface is handed to you - Test every
Query.<resource>(id:)field with victim IDs - Test every
Mutation.update*/delete*with victim IDs - Look for
nodes(ids:[...])batched fetchers — auth check often misses array members - Alias attacks: query the same field N times with different IDs in one request
- Relay-style
node(id:"...")global resolver — often a universal IDOR vector
WebSocket#
Real-time apps ship IDs over WS frames. Intercept with Burp’s WebSockets history:
{"action":"subscribe","channelId":"user_5001"}
{"action":"join","roomId":"private-chat-abc"}
Swap channel/room IDs. Presence, DMs, and notification streams are common blind spots because testers focus on HTTP.
gRPC / protobuf#
Same rules, different wire format. Use grpcurl or Burp with a grpc-web decoder. The ID is still in the request — just binary-encoded.
14. Real-World Writeups & CVEs#
Flowise /api/v1/loginmethod — Cross-tenant SSO takeover#
Class: IDOR + mass assignment + business logic bypass
A PUT to /api/v1/loginmethod accepted organizationId in the request body and updated the matching org’s SSO config. Middleware checked authentication but not whether request.user.organizationId === body.organizationId. A free-tier attacker could overwrite any org’s Google OAuth client ID with their own, hijacking every victim employee login.
Pattern: authenticated-but-unauthorized cross-tenant config write.
Detection heuristic: grep every PUT/POST handler for request-body fields named *Id/*organizationId/*tenantId and verify they’re validated against the caller’s scope.
Nginx UI CVE-2026-33030#
Class: Classic horizontal IDOR (architectural)
The Go base Model struct lacked a user_id field. All resource queries selected by resource ID alone, never joining on ownership. Any authenticated user could read, modify, or delete any other user’s configs. CVSS 8.8.
Lesson: when the ORM base model has no ownership column, every derived model is structurally IDOR-prone. Grep for Model.find(id) vs current_user.Model.find(id).
Dubsmash UpdateSound GraphQL mutation#
Class: GraphQL IDOR on a mutation
The UpdateSound(input:{uuid,name}) mutation updated any soundtrack by UUID without checking ownership. Soundtrack UUIDs were exposed in feed and search responses, so the attacker could rename any track in the library. $3,000 bounty.
Lesson: GraphQL mutations with a resource identifier in the input need explicit ownership checks at the resolver level — the Authorization header tells you who’s calling, not what they own.
HTTP Request Smuggling + IDOR (hipotermia writeup)#
Class: Chained — CL.TE desync escalates a “nobody-cares” IDOR into session hijack
An unauthenticated endpoint POST /addCard/{userId} added a card to whatever user ID you supplied — which alone is useless (why give someone else your card?). Chained with CL.TE request smuggling: attacker smuggles a POST /addCard/<attacker_id> prefix onto the victim’s next request. When the victim’s legitimate POST with their real card details flows through, the smuggled prefix redirects it — the victim’s card lands on the attacker’s account.
Lesson: “low impact IDOR” often becomes critical when chained with a request modifier. Request smuggling, cache poisoning, CSRF, and open redirect are the usual force multipliers.
CVE-2023-4836 — User Private Files WordPress plugin#
Low-privilege users could craft requests to download any user’s uploaded files by manipulating the file reference. Classic file-path IDOR with no user ownership check on the download endpoint.
Reddit password reset / X-User-ID header IDORs#
From the Bugcrowd writeup: debug headers like X-User-ID, X-UID, X-Account-Id left over from test environments were honored in production. Setting the header to a victim’s ID performed actions as that user — full ATO via header injection. Always fuzz X-User-* / X-*-Id headers.
15. Exploit Chains#
IDOR rarely travels alone on a maximum-severity report. Common chains:
| Chain | Escalation |
|---|---|
| IDOR → ATO | IDOR on password reset, email change, or 2FA enrollment endpoint |
| IDOR + Self-XSS | Use IDOR to plant XSS payload in victim’s profile, upgrades self-XSS to stored XSS |
| IDOR + Mass Assignment | IDOR targets the user, mass assignment sets role=admin |
| IDOR + CSRF | Victim’s browser fires the IDOR request to attacker-chosen target |
| IDOR + Request Smuggling | Smuggling prepends IDOR request onto victim connection |
| IDOR + Business Logic | Bypass tier/license checks by writing to org config directly |
| BFLA + BOLA | Call admin function on victim’s object (delete, suspend, impersonate) |
| IDOR on invite → org takeover | Accept someone else’s pending org invitation, become an owner |
| IDOR on webhook URL | Store an attacker webhook on victim’s account → exfil internal calls |
| IDOR + SSRF target config | Modify another tenant’s outbound webhook to an SSRF target |
The rule of thumb: when a finding feels “meh,” ask what the output of this endpoint normally drives downstream. The downstream consumer is usually the real payout.
16. Detection Methodology with Autorize#
Autorize is the canonical workflow. The goal: every request the app sends while you’re logged in as User A is automatically replayed as User B and as unauthenticated — and the response is diffed.
Setup#
- Create two accounts: A (attacker) and B (victim). Use self-registration where allowed.
- Log in as B in a normal browser, log in as A in a separate browsing profile (or incognito).
- In Burp → Extender → BApp Store, install Autorize.
- Grab B’s session cookies and/or
Authorization: Bearertoken from B’s session. - Paste them into Autorize’s config tab as the “low-priv” identity.
- Also add an empty entry for the unauthenticated user.
- Enable Autorize.
- Browse the app as A. Click everything. Load every page. Use every feature.
Autorize replays each request with B’s cookies and with no cookies, and classifies each triple:
| A response | B response | Unauth response | Verdict |
|---|---|---|---|
| 200 | 200 | 401 | IDOR (horizontal) |
| 200 | 200 | 200 | IDOR (unauth — critical) |
| 200 | 403 | 401 | Properly enforced |
| 200 | 302→login | 302→login | Properly enforced |
| 200 | 200 (different body) | 401 | Likely IDOR — diff required |
Triage rules#
- Don’t trust status code alone. A 200 with an “access denied” JSON body is still a blocked request. Configure Autorize with a “Detector” string that marks blocked responses (e.g.
"error":"unauthorized") to filter out false positives. - Response length matters. Same status, different length often means real data leakage.
- Client-side 403s are a lie. Some apps return 200 and the frontend draws the error screen. Read the actual body.
- Retry with headers stripped. Some 403s come from CSRF token checks or content-type enforcement, not authorization. Minimize the request.
Alternatives & supplements#
- Authz — older plugin, similar to Autorize but simpler config
- AutoRepeater — lets you define rewrite rules and auto-replay in the background
- AuthMatrix — role matrix testing across N roles; better for enterprise apps with many personas
- Burp’s “Request in browser in other session” — manual equivalent
- Two browsers side-by-side — always useful as the last sanity check before submitting
Manual diff workflow#
When Autorize isn’t practical (heavy CSRF tokens, signed requests, state-dependent flows):
- Intercept request as A in Repeater.
- Duplicate to a second Repeater tab.
- Swap in B’s cookies/token.
- Swap in B’s CSRF token (grab from B’s last page load).
- Send. Compare with Burp Comparer (“Words” mode highlights diffs).
- Do the same with auth removed entirely.
- Finally, replay B’s request with A’s credentials — check the reverse direction too.
17. Tools & Automation#
Manual / semi-automated#
| Tool | Purpose |
|---|---|
| Burp Suite Pro | Proxy, Repeater, Intruder, Comparer, Logger |
| Autorize | Automatic cross-session replay and diffing |
| Authz / AuthMatrix / AutoRepeater | Alternative auth-testing plugins |
| Paramalyzer | Tracks parameters observed per host, highlights reuse candidates |
| Param Miner | Discovers hidden parameters and headers |
| Logger++ | High-volume request logging with filtering |
| Turbo Intruder | High-throughput attack scripting (Python) |
| Request Smuggler | Detects CL.TE/TE.CL for chain candidates |
Automation / enumeration#
| Tool | Purpose |
|---|---|
| ffuf | ID brute forcing: ffuf -u https://t/api/orders/FUZZ -w ids.txt -fc 403,404 |
| Intruder | Sniper mode on the ID parameter, range payloads |
| Arjun | Hidden parameter discovery |
| kiterunner | API route discovery with wordlists tuned for OpenAPI patterns |
| gau / waybackurls / katana | Historical URL harvesting for leaked IDs |
| CeWL | App-specific wordlist generation |
| jwt_tool | JWT manipulation for identity swap |
| Postman / Insomnia / Bruno | Swagger/OpenAPI replay and mass testing |
| mitmproxy | Mobile app interception when Burp CA is awkward |
Custom scripting#
Quick Python replay loop for known victim IDs:
import requests
BASE = "https://api.example.com"
TOKEN_A = "attacker_token"
VICTIM_IDS = ["id_b_01","id_b_02","id_b_03"]
h = {"Authorization": f"Bearer {TOKEN_A}"}
for vid in VICTIM_IDS:
r = requests.get(f"{BASE}/api/orders/{vid}", headers=h)
if r.status_code == 200 and len(r.text) > 100:
print(f"IDOR HIT: {vid} -> {r.status_code} {len(r.text)}B")
The mark of a good IDOR script is comparing against a known-403 baseline so you filter noise.
18. Impact & Severity Mapping#
Bugcrowd VRT labels IDOR as “varies depending on impact.” Here’s the practical mapping.
| Scenario | Severity | Notes |
|---|---|---|
| Read own old data | Informational | Not IDOR |
| Read another user’s non-sensitive data (display name) | P4 / Low | |
| Read another user’s PII (email, address) | P3 / Medium | |
| Read another user’s private messages / orders | P2 / High | PII + intent exposure |
| Modify another user’s profile or non-critical data | P2 / High | Integrity impact |
| Delete another user’s data | P2 / High | Integrity + availability |
| Read another user’s payment methods / stored cards | P1 / Critical | Financial data |
| Account takeover via IDOR (password reset, email change, 2FA) | P1 / Critical | |
| Admin access via IDOR or BFLA | P1 / Critical | Privilege escalation |
| Cross-tenant read in SaaS | P1 / Critical | Regulatory blast radius |
| Cross-tenant write / config takeover (Flowise SSO pattern) | P1 / Critical | |
| Unauthenticated IDOR at any level | +1 severity vs. authenticated | Mass scrapable |
Multipliers that boost severity:
- Enumerability — sequential IDs enable mass harvest (scale)
- Automation-friendly — no CAPTCHA, no rate limit
- No logging — app doesn’t alert on anomalous cross-user access
- Regulatory data — HIPAA, PCI, GDPR-covered fields
- No account required — unauthenticated access
- Permanent state change — deletion without soft-delete
19. Prevention & Secure Design#
Principle#
Never trust the reference. Always authorize the operation against the current identity and the target object.
Concrete patterns#
1. Scope queries to the current user at the ORM level.
Ruby on Rails:
# vulnerable
@project = Project.find(params[:id])
# secure
@project = current_user.projects.find(params[:id])
Django:
# vulnerable
Order.objects.get(pk=pk)
# secure
Order.objects.get(pk=pk, user=request.user)
Go (GORM):
// vulnerable
db.First(&order, id)
// secure
db.Where("user_id = ?", currentUser.ID).First(&order, id)
This is the single highest-ROI defense. It makes IDOR structurally impossible for that query path: a mismatched ID returns ActiveRecord::RecordNotFound / 404, not data.
2. Architect the base model with ownership.
The Nginx UI CVE happened because the base Model struct had no user_id column. Bake ownership into the foundation: every resource model inherits a tenancy/ownership field, every repository helper joins on it by default. Make the unsafe path the hard one to write.
3. Centralized authorization, not ad-hoc checks.
Inline if (user.owns(obj)) checks scatter, rot, and get forgotten on new endpoints. Prefer:
- Policy objects (Pundit in Rails, cancan, casbin)
- Middleware that resolves the object and runs
authorize!(:read, obj)before the handler runs - Attribute-based access control (ABAC) with a single policy engine
- Declarative rules (OPA / Rego) for multi-service consistency
4. Verify at every layer — UI hiding is not security.
Hiding the admin button client-side is UX, not access control. The handler must re-check.
5. Use unpredictable identifiers as defense in depth.
UUIDv4, ULIDs, or random strings make enumeration expensive. But never ship UUIDs as the only defense — access checks are still mandatory. OWASP explicitly warns against treating identifier complexity as a substitute for authorization.
6. Don’t encrypt IDs as the fix.
Encrypted IDs are hard to do right, leak via padding/length oracles, and the underlying auth gap remains. Add a real auth check instead.
7. Prefer indirect references.
Pass a session-bound token that maps server-side to the actual object. The mapping table lives only for the user’s session. The URL never contains the direct reference. OWASP’s classic IDOR recommendation.
8. Server-side session binding for multi-step flows.
Wizards that carry state across steps: store the state in the session, not in hidden form fields, so the user can’t tamper between steps.
9. Verb-level policy checks.
GET, PUT, DELETE on /resources/:id must each independently run the same ownership check. Framework annotations (@PreAuthorize in Spring, [Authorize] in ASP.NET with a resource-based handler) help, but audit them.
10. Avoid mass-assignment blindness.
Use allowlists (strong params in Rails, DTOs in Java/C#, Zod schemas in Node). Never spread req.body into an ORM model.
11. Logging, monitoring, audit.
Log every cross-user access attempt. Alert on anomalies (user suddenly reading from 50 distinct other-user IDs in a minute). Post-Flowise, audit SSO/config change events to a separate log with alerting.
12. Test access control in CI.
Write tests that hit every endpoint as (a) owner, (b) other user, (c) unauthenticated, and assert 403/404 for the latter two. Snapshot the authorization matrix and fail PRs that drop coverage.
Static analysis hooks#
- Grep for
findBy(Id|Pk)(...)without auser_id/tenant_idclause - Grep for handlers that read
body.userId,body.organizationId,body.accountIdand trace whether they’re compared againstrequest.userbefore use - CodeQL query: taint from
req.body.*Idtodb.update()without an intervening call into an authorization helper
20. Testing Checklist#
Copy this into your test plan for any new target.
Preparation#
- Register two accounts (A and B). Same role by default.
- Register a third account in a different tenant/org if multi-tenant.
- Map all roles (anonymous, user, premium, admin, support, readonly).
- Log in to each role in a separate browser profile.
- Install and configure Autorize with all identity sets.
- Grab Swagger/OpenAPI/GraphQL introspection if available.
Discovery#
- Walk every feature as A. Log every request in Burp.
- Walk every feature as B. Note every object ID that appears.
- Harvest IDs from JS bundles, source maps, HTML, API responses, emails.
- Check
robots.txt,sitemap.xml,.well-known/, Wayback Machine. - List every parameter across every endpoint (Paramalyzer).
Core IDOR tests#
- For each request carrying an ID, replay as B and as unauthenticated.
- Increment/decrement numeric IDs. Try 0, 1, -1, leading zeros, large ints.
- For UUIDs, try B’s UUID harvested from step above.
- Test
me/self/current→ numeric/UUID swaps. - Add
user_idparameters to endpoints that didn’t have one. - Rename parameters (
album_id→account_id) across endpoints. - Supply duplicate parameters (HPP).
- Wrap IDs in arrays:
{"id":[1234]},{"id":[1234,1235]}. - Try string, float, negative, wildcard, null byte, boolean variations.
Method & format#
- Try every verb on every endpoint (GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS).
- Try
X-HTTP-Method-Override: PUT. - Switch
Content-Typebetween JSON/XML/form/multipart/text. - Append
.json,.xml,.xlsx,.configto resource URLs.
Path & version#
- Try
v1,v2,beta,internal,legacyversion prefixes. - Fuzz sibling endpoints under known resource paths.
- Test trailing slash, case variations, URL encoding.
- Check hidden Swagger, GraphQL introspection, WSDL.
Mass assignment#
-
GETthe resource, copy the response into aPUT, resubmit unchanged. - Add
role,isAdmin,verified,balance,ownerId,organizationId. - Try to set
password,passwordHash,2faSecretdirectly. - Try to set
createdAt,updatedAt,deleted.
Headers#
- Fuzz
X-User-Id,X-UID,X-Account-Id,X-Customer-Id,X-Forwarded-User. - Fuzz
X-Original-URL,X-Rewrite-URLfor routing bypass. - Strip
Authorizationand retry — unauthenticated access. - Swap
Referer/Originto see if it gates anything.
Graph / WS / non-REST#
- Run GraphQL introspection.
- Test every
Query.<name>(id:)with a victim ID. - Test every
Mutation.*with a victim ID. - Try alias batching: one request, many IDs.
- Intercept WebSocket frames, swap IDs in subscribe/join actions.
Second-order & blind#
- Create scheduled/async jobs with victim ID in metadata.
- Check email, SMS, push for side-channel confirmation.
- Log into victim account and verify state actually changed.
Chain opportunities#
- Can IDOR on password reset → ATO?
- Can IDOR + self-XSS → stored XSS?
- Can IDOR + mass assignment → role escalation?
- Does request smuggling exist? If yes, chain with any low-impact IDOR.
- Can you pivot to webhook/SSRF targets via config IDOR?
Reporting#
- Two distinct accounts, clearly labeled (A = attacker, B = victim).
- Full reproduction steps including how the victim ID was obtained.
- Raw request/response pairs, not screenshots of JSON.
- Impact statement: at scale, across tenants, PII exposed.
- Remediation recommendation.
21. Report Writing#
A strong IDOR report answers three questions in the first five lines:
- What can I access that I shouldn’t?
- How do I access it? (Minimal reproduction.)
- Why does this matter at scale?
Title formula#
IDOR in <endpoint> allows <action> on any <resource> owned by <other user|org|tenant>
Examples:
IDOR in PUT /api/v1/loginmethod allows any user to overwrite SSO config of any organizationIDOR in GraphQL UpdateSound allows any user to rename any soundtrack in the libraryUnauthenticated IDOR in /api/orders/{id} allows anyone to read any user's order history
Body template#
Summary:
An authenticated user on the Free plan can overwrite the SSO configuration
of any other organization by supplying an arbitrary organizationId in the
body of PUT /api/v1/loginmethod. The endpoint requires authentication but
does not verify that the caller belongs to the target organization.
Steps to Reproduce:
1. Register two free-tier accounts in two different organizations: Attacker
(org_A) and Victim (org_B).
2. As Attacker, obtain org_B's organizationId from <source> (e.g. API
response, enumeration, profile page).
3. As Attacker, send the following request using Attacker's JWT:
PUT /api/v1/loginmethod HTTP/1.1
Host: cloud.example.com
Cookie: token=<attacker_jwt>
Content-Type: application/json
{"organizationId":"<org_B_id>",
"userId":"<org_B_admin_id>",
"providers":[{"providerName":"google",
"config":{"clientID":"ATTACKER_OAUTH",
"clientSecret":"ATTACKER_SECRET"},
"status":"enable"}]}
4. Server responds 200 OK. Victim org's SSO now points at Attacker-controlled
OAuth app.
5. Log in to Victim's app as an org_B employee — authentication flows through
Attacker's OAuth and returns Attacker-captured identity.
Impact:
- Complete account takeover of every employee in any targeted organization
who logs in via SSO.
- Privilege escalation: Free-tier users can enable Enterprise-only SSO
features without a license.
- Regulatory: uncontrolled cross-tenant identity bridging; every affected
org must invalidate sessions and rotate OAuth credentials.
Remediation:
- Add ownership check: require request.user.organizationId === body.organizationId.
- Enforce role check: only org admins may modify loginmethod.
- Add license/tier check: reject enterprise-only provider changes on free plans.
- Audit log every SSO configuration change with alerting.
Common report-killing mistakes#
- Testing with only one account (can’t prove cross-user impact).
- Stopping at 403 — always retry with headers stripped, methods swapped, Content-Type changed.
- Missing the impact statement. “I accessed data” is not impact. Scale + PII + regulatory framing is.
- Screenshotting JSON instead of pasting the raw request/response.
- Confusing IDOR with “I can see my own old data” (not IDOR).
- Not clarifying horizontal vs vertical vs unauthenticated.
- Reporting duplicate IDs that were intentionally public (product pages, press releases).
- Automated scanning in programs that prohibit it — read the rules.
Calibrating severity for triage#
- Always lead with unauthenticated if applicable — that’s the headline.
- Always note scale — “any user” beats “one user.”
- If cross-tenant, say “cross-tenant” explicitly — SaaS programs treat this as its own class.
- If chained, explain the chain concisely and score for the chained impact.
- Call out enumerability — sequential IDs plus no rate limit equals mass scrape.
Appendix: Quick-Reference Payload Cheatsheet#
ID mutations to try in order#
123 your own ID (baseline)
124 +1
122 -1
1 lowest
0 zero
-1 negative
00000123 leading zeros
123.0 float
1.23e2 scientific
[123] array wrap
[123,124] array multi
"123,124" string delimited
123%00 null byte
* wildcard
% SQL wildcard
true boolean
null null
me, self, current, admin aliases
Headers to fuzz for identity override#
X-User-Id: <victim>
X-UID: <victim>
X-User: <victim_email>
X-Account-Id: <victim>
X-Customer-Id: <victim>
X-Tenant-Id: <victim>
X-Forwarded-User: <victim>
X-Forwarded-Email: <victim>
X-Original-User: <victim>
X-Auth-User: <victim>
X-Remote-User: <victim>
X-Impersonate: <victim>
Parameters to add on create/update#
id, uid, user_id, userId, owner, ownerId, account_id, accountId,
organizationId, orgId, tenantId, role, isAdmin, admin, permissions,
verified, emailVerified, status, tier, plan, balance, credit,
created_at, updated_at, deleted, deletedAt, password, passwordHash
Endpoints that commonly fail on ownership#
/api/v1/loginmethod <-- SSO / OAuth config
/api/users/{id}/email <-- email change
/api/users/{id}/password <-- password set
/api/users/{id}/2fa <-- 2FA enroll/disable
/api/accounts/{id}/billing <-- payment methods
/api/orgs/{id}/members <-- team management
/api/orgs/{id}/invites <-- pending invitations
/api/webhooks <-- stored callback URLs
/api/exports <-- scheduled exports
/api/files/{id} <-- file download
/api/sessions/{id} <-- session management
/api/api-keys/{id} <-- API token management
Sources#
This guide synthesizes the following clipped research articles:
- Aon Cyber Labs — Finding More IDORs: Tips and Tricks
- Intigriti — IDOR: A Complete Guide to Exploiting Advanced IDOR Vulnerabilities
- dev.to / Kai — How to Find IDOR Vulnerabilities: The Bug Bounty Hunter’s Practical Guide
- Hadrian — Insecure Direct Object Reference (IDOR): A Deep Dive
- OWASP Cheat Sheet Series — Insecure Direct Object Reference Prevention
- OWASP Foundation — Insecure Direct Object Reference
- MDN / Security — Insecure Direct Object Reference
- Varonis — What is IDOR?
- Bugcrowd — How-To Find IDOR Vulnerabilities for Large Bounty Rewards
- DailyCVE / Flowise — IDOR & Business Logic Flaw (CVE-2025)
- hipotermia.pw — HTTP Request Smuggling + IDOR chain writeup
- thehackerwire — Nginx UI IDOR (CVE-2026-33030)
- Appsecure — Reddit Bug Bounty: Exploiting IDOR in Dubsmash UpdateSound API
- Insecure Direct Object References (IDOR) overview
- What is an example of a real bug bounty report where IDOR was used
- Footstep Ninja blog series on IDOR hunting (multiple entries)
Defensive reference only. Use this guide to test systems you are authorized to test, harden designs you own, and score findings consistently during triage.