IDOR Vulnerability — Stop Users Accessing Other Data
IDOR lets users access other users' data by changing IDs in requests.
20+ years shipping production backend systems. Lessons pulled from things that broke in production.
- ✓A REST API with authenticated endpoints you can test
- ✓Two test user accounts with distinct data
- ✓Basic familiarity with HTTP status codes and sessions
- IDOR means the server returns data for any ID in the request without checking whether the caller owns that record
- Predictable IDs like /invoices/1001 make probing trivial, but random UUIDs alone don't fix a missing authorization check
- Enforce ownership on the server for every read, write, and delete using the authenticated session — never trust a client-sent user ID
- Replace raw database keys in URLs with indirect per-session references so attackers can't guess or enumerate object addresses
- Test every endpoint with a two-account matrix: owner access, stranger access, anonymous access, and direct URL guessing
Picture an apartment building where every mailbox opens with the same key, and the numbers run in order: 101, 102, 103. Anyone who rents box 101 can slide the key into 102 and read a neighbor's mail. That's IDOR. The office (your server) hands out mail based on the number written on the request slip without checking whether the person asking actually rents that box. The fix isn't renumbering the boxes — it's making the clerk verify each person's lease before handing anything over.
Your API works perfectly in every demo. Login succeeds, the dashboard loads, invoices render. Then someone changes /api/invoices/1001 to /api/invoices/1002 in the address bar and sees another customer's bill. No hacking tools. No stolen password. Just a number, incremented by one.
That's Insecure Direct Object Reference — IDOR — and it has topped access-control failure lists for over a decade because it's so easy to ship. The endpoint authenticates the user (it knows who's asking) but never authorizes the request (it never checks whether this user may see that object). Authentication without per-object authorization is a locked front door with every interior door wide open.
IDOR hides in plain sight because functional tests never look for it. Your test suite logs in as one user and confirms their data loads. It never logs in as user B and requests user A's records, so the missing check ships with a green build. Meanwhile every object ID your app exposes — in URLs, API responses, even HTML source — becomes a handle anyone can pull.
This guide shows you what IDOR looks like in real endpoints, why predictable IDs make it worse (and why UUIDs alone don't fix it), how to enforce ownership on the server for every operation, and how to build an authorization test matrix that catches the bug before strangers browse your database.
What IDOR Looks Like in a Real API
IDOR rarely looks like an attack. It looks like normal app usage with one digit changed. A user views their receipt at /orders/4819, wonders what 4820 holds, and the server answers. There's no injection, no crafted payload, no anomaly for a WAF to flag — just a legitimate request for a legitimate object by a legitimate user who happens not to own it. That's why IDOR survives code review: the handler code reads sensibly (fetch by ID, return result) and the vulnerability is the absent line, not a wrong one.
The flaw spans all HTTP methods. GET leaks data, but PUT and PATCH rewrite it — imagine updating someone else's email address and triggering a password reset. DELETE destroys what you never owned. POST-based batch endpoints are a quieter variant: send {ids: [1..500]} and harvest records in bulk. File downloads, avatars, and exports deserve extra suspicion because they're frequently built as separate handlers that skip the authorization logic the main page has.
Learn to spot the pattern during review: any handler that converts a client-supplied identifier into a database lookup without an intervening permission check. The snippet below shows the shape of the missing check in its most common form — a Flask handler that trusts the URL — so you can grep your own codebase for siblings. The fix section later shows the corrected version with the ownership decision restored.
Predictable IDs: Why Sequential Keys Invite Probing
Sequential IDs are the difference between a theoretical flaw and a browsable one. When invoices run 1001, 1002, 1003, discovery is free: any customer knows their own ID and can walk in both directions. Dates, short numeric codes, and guessable slugs (spring-promo-2) behave the same way. Predictability doesn't create the vulnerability — the missing check does — yet it decides whether exploitation needs luck or just curiosity.
Random UUIDs are the standard upgrade, and they're worth doing: 128 bits of randomness make enumeration infeasible, turning mass harvesting into targeted guessing. But teams routinely oversell the change. UUIDs leak constantly — shared links, browser history, proxy logs, referer headers, screenshots in support tickets — and every leak reopens the exact same missing check. Treat UUIDs as enumeration resistance, one layer among several, and keep them out of your threat model's authorization column.
The stronger posture pairs unguessable external references with real checks. Per-session download tokens that expire in minutes mean a leaked URL dies on its own. And regardless of identifier style, the ownership check stays mandatory: it's the only control that works after the reference is known. Migrate identifiers to reduce blast radius, but schedule the authorization fix first — it's the one that actually closes the hole.
Server-Side Ownership Checks on Every Request
The fix for IDOR is a decision the server makes on every request: does this authenticated principal own — or have explicit permission for — this specific object? The check belongs in the handler (or a shared authorization layer), runs after authentication, and uses only server-side data: the object loaded from the database and the principal from the verified session. Anything the client sends about identity — user_id fields, role flags, signed-looking parameters — is input, not evidence.
Write the check as an explicit comparison, not framework magic you can't see. Load the object, compare owner to session user, deny by default. For shared resources (team documents, multi-tenant records), check membership or ACL rows rather than simple ownership, but keep the shape identical: load, compare, decide. Centralize the logic in one helper per resource type so new endpoints inherit it instead of reimplementing it — most IDOR regressions come from a new handler that forgot what the old one knew.
Choose your denial status deliberately. Return 404 when object existence is itself private (invoices, medical records) so strangers can't probe which IDs are real. Return 403 when the resource is known-shared but the action is forbidden (a team member attempting an admin action), since hiding existence there buys nothing. The snippet below shows the corrected handler: same fetch, plus the three lines that close the vulnerability.
Indirect References: Masks That Shrink Exposure
Indirect references replace raw database keys in URLs with opaque tokens the server maps back internally. Instead of /invoices/1002, the user sees /downloads/a3f9c1e7 — a random token stored server-side alongside the real invoice ID, the owning user, and an expiry time. The handler resolves the token, verifies the session user matches the token owner, then serves the record. Guessing stops working because tokens are random; sharing stops working because they expire and bind to one user.
This pattern shines for downloads, shared previews, and password-reset-style flows — anywhere a URL travels through email, chat, or logs. Scope each token narrowly: one object, one user, minutes of life, single-purpose. A token minted for viewing shouldn't authorize deletion, and a token minted for user A should die rather than serve user B, even if B somehow receives the link.
Be honest about what this buys. Indirect references are exposure control, not authorization — they must sit in front of the ownership check, not replace it. The mapping table itself needs care: random tokens from a cryptographic generator, hashed storage if tokens grant sensitive access, and cleanup jobs for expired rows. Done right, a leaked URL becomes a 15-minute inconvenience instead of a permanent backdoor.
An Authorization Test Matrix for Every Endpoint
IDOR keeps shipping because test suites ask one question — can the owner access their data — when the vulnerability answers a different one: can a stranger? An authorization matrix makes the second question mandatory. For every endpoint that touches an object, assert four cases: the owner succeeds, a different authenticated user is denied, an anonymous caller is denied, and a forged identity field in the request is ignored. Four assertions per endpoint, run on every build.
Seed your test database with two users who each own known objects, so the stranger case uses real IDs rather than 404-prone guesses. Test the full method set: GET, PUT, PATCH, DELETE, plus batch and export variants that teams habitually forget. Include nested resources explicitly — /users/5/documents/9 needs checks on both the parent and the child, since verifying only the parent lets attackers pivot across children.
Run the matrix in CI against a seeded staging-like database, not mocks that bypass the handler's data path. The script below drives the stranger case with plain HTTP so it works regardless of framework: swap in any session token and object ID. When this test fails, it fails loudly on the exact endpoint and method — which is precisely the signal that prevents the next breach notification.
Logging and Reviewing Access Decisions
Authorization decisions should leave traces. Log every denied access with the authenticated user, the requested object ID, the endpoint, and the timestamp — denials are your early-warning radar for probing. A single 404 is noise; forty sequential IDs from one account in a minute is someone walking your invoice range, and only denial logs make that pattern visible. Alert on velocity, not just volume.
Review these logs on a schedule, not just after incidents. A weekly scan of denied-access spikes catches both attackers and broken clients (a mobile release requesting stale IDs looks similar at first). Retain enough history to scope a breach: when a customer reports exposure, you need to answer which records were touched, by whom, and when — the notification and the fix both depend on it.
Keep the logs themselves safe: record object IDs and user IDs, never full record contents, so the audit trail can't become a second leak. Restrict read access to the security team, and make sure success-path logging exists too for sensitive objects — knowing who legitimately viewed a record matters when you're reconstructing an incident. Authorization you can't see is authorization you can't trust. Schedule the review alongside your other access-control hygiene so it survives busy quarters.
Incrementing an Invoice ID Exposed 4,300 Customer Bills in 6 Days
- Login-required is not authorization. Every endpoint that touches an object needs an explicit ownership or permission check against the server-side session.
- Sequential IDs turn a missing check into mass exposure. Treat predictable references as public and either randomize access tokens or enforce checks that make guessing useless.
- Single-user functional tests can't catch IDOR. Only multi-account tests that request other users' objects will fail when the check is missing.
| File | Command / Code | Purpose |
|---|---|---|
| reviews | @app.get("/api/invoices/<int:invoice_id>") | What IDOR Looks Like in a Real API |
| app | from flask import abort | Server-Side Ownership Checks on Every Request |
| app | from datetime import datetime, timedelta, timezone | Indirect References |
| A_INVOICE=1001 | An Authorization Test Matrix for Every Endpoint |
Key takeaways
Common mistakes to avoid
5 patternsTreating login-required middleware as authorization
Switching to UUIDs and declaring IDOR fixed
Reading identity from request parameters instead of the session
Securing the page but not its export or download twin
Returning 403 with verbose errors that confirm object existence
Interview Questions on This Topic
What's the difference between authentication and authorization in the context of IDOR?
Frequently Asked Questions
20+ years shipping production backend systems. Lessons pulled from things that broke in production.
That's Access Control. Mark it forged?
6 min read · try the examples if you haven't