Changing the Number in the URL: What IDOR Actually Is

RedHub AI Editorialupdated October 4, 20266 min read

In an unattended hotel luggage room a guest looks down into another guest's open suitcase, lit red.
Jump to a section10

IDOR explained in one sentence: a route takes a record's identifier from something the requester controls, such as the URL or a form field, and returns the matching record without checking who owns it. The letters stand for insecure direct object reference. When the identifier is a number that counts up, 1, 2, 3, 4, anyone with an account can walk the whole table by changing one digit.

TL;DR: IDOR happens when a record's id comes from the request and nothing checks that the record belongs to the person asking. OWASP lists it under A01, Broken Access Control, in its Top 10:2025. A sequential id makes it trivial to find, and a login in front doesn't help, because signing up is free. Random ids make records harder to find but add no ownership check, which is why OWASP calls them defense in depth rather than the fix.

What the three words mean

A direct object reference is an identifier that points straight at one record, such as a document id, an invoice number or an order id, used as-is to fetch that row. It becomes insecure when it arrives from somewhere the requester controls and the app trusts it without asking who may have the record.

OWASP's IDOR prevention cheat sheet (opens in a new tab) boils it down to three ingredients: an object, a reference to it, and a missing object-level authorization check. The reference can be a number, a UUID, a filename or a token. Nothing exotic is involved. The route does what it was told: here's an id, go fetch it.

Why the id's format decides how easy it is to find

A common way to give a new row its id is a counter. Postgres serial and identity columns, MySQL AUTO_INCREMENT and SQLite's rowid all hand each new row the next whole number. Where a schema uses one of those, the primary key is a counter, and a counter puts every row in order.

Put that id in a URL, /invoices/104, and the number tells anyone reading it that 103 and 105 probably exist too. Nobody has to guess. They count.

Built with an AI tool? Don't assume either way. Some builders create tables with counting ids and some use long random UUIDs, and the same builder can do both in one project. Open your schema and look at each primary key's type before you decide which case you're in.

Two routes, one document, one difference

The worked example that ships with Logged-In Is Not Allowed has two routes on the same saved document. R-05 deletes it. It requires a signed-in session, never compares the document's owner to the caller, and takes the document's id from the request as a countable number. That combination trips the kit's enumeration gate, and the route reads NOBODY IS CHECKING despite the login in front of it. It is the only route of the ten where that gate fires.

R-04 edits the same document. It has the same login and the same missing ownership compare, but its id is a long random string, and it reads LOGGED-IN ONLY. Both routes share one real gap. Only one of them can be found by counting, which is why IDOR gets its own name instead of being folded into "no ownership check."

Why a login in front doesn't help

A signed-in check on a route whose id can be counted through doesn't raise the cost of reaching other people's records, because signing up costs nothing. Make an account, change the number, and the table opens one invoice at a time. However carefully the login screen was built, on this route it's a formality standing in front of an open door.

A random id is defense in depth, not the fix

Once you see this, the instinct is to swap the counter for something unguessable, a UUID or a long random string, and call the route safe. OWASP's cheat sheet does recommend complex ids, but as a defense-in-depth measure. It also says access control checks stay essential with complex ids: if someone obtains the URL of a record that isn't theirs, the app should still block them.

And ids leak. A link gets forwarded in an email, pasted into a support ticket, or saved in a browser history or a log file. When that happens, the random id stops nothing, because nothing was checking whose record it was. The fix is the comparison itself, made on the server on every request: does the record's owner match the signed-in user? The ownership check post shows that comparison in code.

Three things to check about any id in a request

  1. Is the id predictable? Test: open your schema. If the primary key is an integer the database fills in for you, it's countable, whether or not you ever thought about it.
  2. Does the route compare owners? Whatever the id looks like, this is the check that matters. Test: find the line near the fetch that checks the record's owner against the current user. If that line doesn't exist, the id format is a distraction from the real gap.
  3. Where does the caller's id come from? Test: the user id in that comparison must come from the session. If the request body carries a user_id field and the check trusts it, anyone can send someone else's.
Scope: this is about reading your own schema and your own route code. Nothing here is an instruction to test any app you don't own.

Where IDOR sits in the bigger picture

IDOR is one shape inside the larger category of broken access control: the shape where the object reference is both supplied by the requester and predictable. The post on broken access control covers the other shapes, including routes with no check at all, and where OWASP ranks the category.

Find the countable ids before anyone else does

Logged-In Is Not Allowed has a column for where each route's record id comes from: the session, the request as a random string, or the request as a number that runs 1, 2, 3. A signed-in-only route with a countable id drops to NOBODY IS CHECKING, and one such route sets the whole app to ANYONE CAN WALK IN. 80 rows, offline, nothing uploaded.

Get Logged-In Is Not Allowed — $59

Pairs well with

An ownership check needs an owner to compare against, and The Data Model Sanity Bench ($69) reads the shape of your tables: a records table with no owner column reads NOTHING TO ENFORCE AGAINST. If the caller's id in your check comes from the browser, The Browser Line Read-Off ($59) will read that check TRUSTS THE BROWSER even when it runs on your server. And if you're on Supabase, The Supabase RLS Read-Off ($69) reads what each table's policies let a stranger reach, one row per table and operation. A route that queries as the signed-in user, not with the service-role key, can still be stopped by a policy where its own check is missing.

More in this guide

Frequently asked questions

What does IDOR stand for?

Insecure direct object reference. A direct object reference is an identifier, such as a document id or an order number, used straight to fetch one specific record. It's insecure when that identifier comes from the requester and nothing checks whether they're allowed to have the record it points to.

Does switching to random ids (UUIDs) fix IDOR?

No. It raises the cost of guessing a specific record, but it doesn't add the missing ownership check. A route with a random id and no comparison between the record and the caller still hands the record to anyone who gets hold of a valid link. Random ids are a useful extra layer, not a replacement for the check.

Is IDOR only about URLs?

No. The identifier can show up in a URL path, a query parameter, a hidden form field or the body of an API request, anywhere the requester supplies it. The mechanism is the same wherever it's carried.

If my app has no public sign-up, am I safe from IDOR?

Not automatically. The risk is about whether a signed-in user can reach another person's record, not about how accounts get created. If more than one person has an account, invited or not, the same gap applies. Closed sign-up shrinks who can try. It doesn't add the check.

How is IDOR different from broken access control generally?

IDOR is one specific shape of broken access control: the case where the record's identifier is supplied by the requester and nothing checks it. Broken access control also covers routes with no check at all, admin pages a regular user can open, and checks that only run in the browser.

How it decides
Diagram of Logged In Is Not Allowed: ten routes, an enumeration gate, and a sample reading ANYONE CAN WALK IN.

The gate this post refers to, drawn from the tool’s own logic. See the tool.