Home › Security › IDOR Vulnerability — Stop Users Accessing Other Data
Intermediate 6 min · September 23, 2026
Insecure Direct Object Reference (IDOR)

IDOR Vulnerability — Stop Users Accessing Other Data

IDOR lets users access other users' data by changing IDs in requests.

N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Lessons pulled from things that broke in production.

Follow
✓ Production
production tested
September 26, 2026
last updated
2,085
articles · all by Naren
Before you start⏱ 16 min
  • ✓A REST API with authenticated endpoints you can test
  • ✓Two test user accounts with distinct data
  • ✓Basic familiarity with HTTP status codes and sessions
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • 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
✦ Definition~90s read
What is Insecure Direct Object Reference (IDOR)?

IDOR (Insecure Direct Object Reference) is an access-control flaw where an application exposes a direct reference to an internal object — a database key, filename, or account number — and fails to verify that the requesting user is allowed to access it. The user is authenticated, the reference is valid, and the server happily returns someone else's data.

★
Picture an apartment building where every mailbox opens with the same key, and the numbers run in order: 101, 102, 103.

It tops API security risk lists because every object URL needs its own authorization decision.

Typically, GET /api/orders/4819 returns order 4819 to whoever asks, as long as they're logged in. The handler looks up the order by ID and never compares its owner against the session user. Change 4819 to 4820 and you read a stranger's purchase history.

The same flaw hits writes: PUT /api/profile/77 with a different ID can rewrite another user's settings, and DELETE endpoints can destroy records the caller never owned.

Predictable identifiers make IDOR trivially exploitable. Sequential integers invite counting up and down; dates and short codes invite guessing. Random UUIDs raise the bar yet remain obscurity, not authorization. A UUID leaks through shared links, browser history, logs, and referer headers, and the moment it's known, the missing check is missing again.

Indirect references (per-session tokens mapped server-side to real keys) are stronger for the same reason: they shrink the window where a reference is useful.

The real fix is server-side authorization on every endpoint: load the object, compare its owner or ACL against the authenticated principal, and deny by default when anything is unclear. That check must use the server-side session, never a user_id the client sends along, because clients lie.

Applied to reads, writes, deletes, and batch endpoints, IDOR disappears as a class rather than one URL at a time.

Plain-English First

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.

reviews/idor_shape.pyPYTHON
1
2
3
4
5
6
7
8
9
10
# VULNERABLE pattern for recognition during review (do not ship this).
@app.get("/api/invoices/<int:invoice_id>")
@login_required
def download_invoice(invoice_id):
    invoice = Invoice.query.get_or_404(invoice_id)
    # BUG: no check that invoice.customer_id == current_user.id
    return send_file(render_pdf(invoice))

# What to grep for: query.get / filter_by(id=...) with no owner
# comparison before the return in the same handler.
📊 Production Insight
An export endpoint had full authorization while its CSV-download twin had none — built by different developers six months apart. Attackers found the twin in days. Audit every variant of an object, not just the main page.
🎯 Key Takeaway
IDOR is an absent check, not a broken one: fetch-by-ID with no permission decision. Review every method and every export variant, not just GET.

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.

📊 Production Insight
A team migrated all IDs to UUIDs and declared IDOR fixed. Three weeks later a shared support link exposed another customer's records through the same unchecked handler. The identifier changed; the vulnerability never moved.
🎯 Key Takeaway
Sequential IDs make IDOR mass-exploitable; UUIDs only make it harder to find. Migrate identifiers for blast radius, but fix the authorization check first.

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.

app/invoices.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
from flask import abort
from flask_login import current_user, login_required

def assert_owns_invoice(user, invoice) -> None:
    # Single choke point: every invoice endpoint calls this.
    if invoice is None or invoice.customer_id != user.id:
        abort(404)  # hide existence; don't confirm the ID is real

@app.get("/api/invoices/<int:invoice_id>")
@login_required
def download_invoice(invoice_id):
    invoice = Invoice.query.get(invoice_id)
    assert_owns_invoice(current_user, invoice)  # server-side session only
    return send_file(render_pdf(invoice))
📊 Production Insight
A handler compared invoice.customer_id against request.json['user_id'] — client-controlled input. Testers passed their own ID with anyone's invoice and sailed through. Identity must come from the session, never the request body.
🎯 Key Takeaway
Load the object, compare its owner to the session principal, deny by default. Centralize the check so new endpoints inherit it.

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.

app/share_tokens.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import secrets
from datetime import datetime, timedelta, timezone

def mint_download_token(user_id: int, invoice_id: int, minutes: int = 15) -> str:
    token = secrets.token_urlsafe(24)  # unguessable, single-purpose
    db.session.add(ShareToken(
        token=token, user_id=user_id, invoice_id=invoice_id,
        expires_at=datetime.now(timezone.utc) + timedelta(minutes=minutes)))
    db.session.commit()
    return token

@app.get("/downloads/<token>")
@login_required
def download_by_token(token):
    ref = ShareToken.query.filter_by(token=token).first()
    if ref is None or ref.is_expired() or ref.user_id != current_user.id:
        abort(404)
    return send_file(render_pdf(Invoice.query.get(ref.invoice_id)))
📊 Production Insight
A team issued permanent share tokens with no expiry or owner binding. A two-year-old link in a forwarded email still served live financial data. Bound, expiring tokens would have let it die quietly within minutes.
🎯 Key Takeaway
Map random, expiring, single-user tokens to real keys server-side. Indirect references shrink a leak's value but never replace the ownership check.

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.

BASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Stranger-access probe: user B requests user A's invoice. Expect 404.
A_INVOICE=1001
B_TOKEN="$USER_B_BEARER_TOKEN"

CODE=$(curl -s -o /tmp/body.txt -w "%{http_code}" \
  -H "Authorization: Bearer $B_TOKEN" \
  "https://staging.example.com/api/invoices/$A_INVOICE")

if [ "$CODE" = "404" ] || [ "$CODE" = "403" ]; then
  echo "PASS: stranger denied (HTTP $CODE)"
else
  echo "FAIL: stranger got HTTP $CODE for another user's invoice"
  exit 1
fi
⚠ Test as the stranger, not just the owner
A test suite that only logs in as one user will pass with or without any authorization check. Every object endpoint needs a stranger case with a second account, or IDOR ships with a green build.
📊 Production Insight
A suite with 4,000 passing tests missed IDOR on 11 endpoints because every test used the same fixture user. Adding a second user and the stranger assertion caught all 11 in the first run.
🎯 Key Takeaway
Four cases per object endpoint: owner allowed, stranger denied, anonymous denied, forged identity ignored. Seed two users and run it in CI.

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.

📊 Production Insight
The invoice incident's scope came entirely from denial-free success logs — the team could count served PDFs but not probes that hit missing IDs. Denial logging would have revealed the enumeration pattern days earlier.
🎯 Key Takeaway
Log every denial with user, object, endpoint, and time. Alert on probing velocity and review weekly — your logs are the only record of who touched what.
● Production incidentPOST-MORTEMseverity: high

Incrementing an Invoice ID Exposed 4,300 Customer Bills in 6 Days

Symptom
A customer emailed support saying they could see another company's invoices by editing the number in the download URL. Investigation showed GET /api/invoices/<id>/pdf returned any invoice to any authenticated user — no ownership check existed. Access logs revealed the endpoint had served 4,300 invoices to users who didn't own them over 6 days, including one IP that walked sequentially through 1,900 IDs in 40 minutes. Names, addresses, line items, and totals were all exposed. The breach notification process started the same evening.
Assumption
The team believed their middleware handled authorization because every route required a valid session token. They confused authentication (proving who you are) with authorization (proving you may touch this object). Their test suite logged in as a single test user and asserted 200 OK on invoice downloads — a test that passes with or without an ownership check. Sequential invoice IDs were considered harmless because 'the URLs aren't linked anywhere public,' ignoring that any customer sees their own ID and can guess neighbors.
Root cause
The invoice download handler fetched the record by primary key and streamed the PDF without comparing invoice.customer_id to the session's customer ID. The route sat behind login-required middleware, which the team treated as sufficient. Invoice IDs were sequential integers starting at 1000, so enumeration needed no tooling beyond a browser. The exposure window lasted 6 days from the feature's release until the customer report, and log analysis confirmed at least 4,300 cross-customer reads plus one systematic enumeration run.
Fix
The hotfix shipped in 3 hours: the handler now loads the invoice, compares its customer ID to the session principal, and returns 404 (not 403, to avoid confirming existence) on mismatch. The follow-up release replaced sequential invoice URLs with per-session download tokens that expire after 15 minutes, so URLs can't be guessed or shared. A two-account authorization test matrix was added to CI — owner, stranger, and anonymous cases for every object endpoint — and a historical log review plus customer notification completed the response.
Key lesson
  • 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.
Production debug guideFive hands-on checks, from reproducing the flaw to proving the fix in CI.5 entries
Symptom · 01
You suspect an endpoint returns other users' data
→
Fix
Reproduce it with two test accounts: log in as user A, note a real object ID from A's responses, then replay the identical request with user B's session token. If B gets A's data with a 200, you've confirmed IDOR. Use curl with B's bearer token against A's object URL — a 30-second test that settles the question without any scanner.
Symptom · 02
Confirmed IDOR on a read endpoint — you need the smallest safe fix
→
Fix
Add a server-side ownership check in the handler: load the object, compare its owner field to the principal from the verified session (never from request parameters), and return 404 on mismatch. Prefer 404 over 403 for private objects so strangers can't even confirm an ID exists. Deploy this before any cosmetic changes like UUID migration.
Symptom · 03
You need to find every other endpoint with the same flaw
→
Fix
Inventory all routes that take an object identifier — path params, query strings, and request-body IDs — then test each with the two-account swap. Pay special attention to nested resources (/users/5/documents/9), batch endpoints that accept ID arrays, and export/download variants of secured pages, which are often implemented separately and miss the check.
Symptom · 04
IDs are sequential and you want enumeration to stop working
→
Fix
Introduce indirect references for external exposure: map a random per-session token to the real database key server-side, expire it quickly, and accept only the token in URLs. Keep sequential keys internally. This is defense in depth on top of ownership checks — it narrows what a leaked URL is worth — not a replacement for them.
Symptom · 05
You want CI to catch the next missing check automatically
→
Fix
Add an authorization matrix test per object endpoint: owner gets 200, stranger gets 404, anonymous gets 401, and a forged client-sent user_id is ignored. Seed two users with known objects in the test database and assert all four cases. Fail the build on any deviation — this is the test that would have caught the original bug.
IDOR Variants at a Glance
Root CauseHow to ConfirmFixPrevention
Missing ownership check on a read endpointRequest user A's object ID with user B's session; a 200 confirms itCompare object owner to session principal; deny with 404Stranger-case test for every object GET in CI
Sequential IDs enabling enumerationWalk IDs up and down from your own record and count successful hitsAdd ownership checks first, then indirect expiring referencesUnguessable external tokens; never expose raw sequences
Client-sent user ID trusted as identitySend a stranger's object with your own user_id field and watch it succeedTake identity only from the verified server-side sessionReview rule: identity fields in request bodies are always suspect
Unchecked nested or batch endpointsSwap child or array IDs across users on sub-resources and bulk callsAuthorize each parent and every child ID in the batchMatrix tests covering nested routes and batch ID arrays
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
reviewsidor_shape.py@app.get("/api/invoices/<int:invoice_id>")What IDOR Looks Like in a Real API
appinvoices.pyfrom flask import abortServer-Side Ownership Checks on Every Request
appshare_tokens.pyfrom datetime import datetime, timedelta, timezoneIndirect References
A_INVOICE=1001An Authorization Test Matrix for Every Endpoint

Key takeaways

1
IDOR is a missing per-object authorization check
authenticated users can access records they don't own.
2
Sequential IDs make IDOR mass-exploitable; UUIDs slow discovery but never replace the check.
3
Enforce ownership server-side on every method using the session principal, never client-sent identity.
4
Use indirect, expiring, single-user references for URLs that travel through email and logs.
5
Test every object endpoint as owner, stranger, anonymous, and forged identity
in CI.
6
Log denials and alert on probing velocity; your audit trail scopes the next incident.

Common mistakes to avoid

5 patterns
×

Treating login-required middleware as authorization

Symptom
Every endpoint returns 401 for anonymous users but serves any object to any logged-in user, and single-user tests all pass.
Fix
Add an explicit per-object ownership or permission check in each handler, using the session principal.
×

Switching to UUIDs and declaring IDOR fixed

Symptom
Enumeration stops but targeted access via leaked links, logs, and history keeps working through the same unchecked handler.
Fix
Keep UUIDs for enumeration resistance and add the ownership check — the check is the fix, the UUID is the hardening.
×

Reading identity from request parameters instead of the session

Symptom
Attackers pass their own user_id alongside anyone's object ID and the check waves them through.
Fix
Derive identity exclusively from the verified session or token claims; ignore client-sent identity fields.
×

Securing the page but not its export or download twin

Symptom
The UI enforces access while /export, /pdf, or /csv variants of the same data serve everyone.
Fix
Route every variant through the same authorization helper and include all variants in the test matrix.
×

Returning 403 with verbose errors that confirm object existence

Symptom
Strangers can map which IDs are real by distinguishing error messages, aiding targeted attacks.
Fix
Return 404 for private objects so existence stays hidden; reserve detailed errors for the owner's own requests.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What's the difference between authentication and authorization in the co...
Q02SENIOR
Why don't random UUIDs fully fix IDOR?
Q03SENIOR
How would you test an API for IDOR with just two accounts and curl?
Q04SENIOR
When should an IDOR denial return 404 versus 403?
Q05SENIOR
Design an authorization layer that prevents IDOR across a growing API.
Q01 of 05JUNIOR

What's the difference between authentication and authorization in the context of IDOR?

ANSWER
Authentication proves who the caller is; authorization decides whether that caller may touch a specific object. IDOR happens when an app authenticates successfully but skips the per-object decision, so any logged-in user can access any record by ID.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Is IDOR the same as broken access control?
02
Can a WAF or rate limiter prevent IDOR?
03
Should I hide IDs from the frontend entirely?
04
Do I need to check authorization on nested resources too?
05
What's wrong with trusting a signed user_id from the client?
06
How do I prioritize an IDOR audit on a large existing API?
N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Lessons pulled from things that broke in production.

Follow
✓ Verified
production tested
September 26, 2026
last updated
2,085
articles · all by Naren
🔥

That's Access Control. Mark it forged?

6 min read · try the examples if you haven't

←
Previous
OAuth 2.0 Redirect URI Mismatch Error
1 / 1 · Access Control
Next
Server-Side Request Forgery (SSRF) and Cloud Metadata
→