TL;DR: An IDOR (insecure direct object reference) is an access-control bug where the server takes an object id from the request — /rest/basket/2 — and returns that object without checking that the logged-in user owns it, so changing the number returns someone else's data. The fix is server-side: look the object up by its id and the caller's user id, and answer 404 (or 403) when nothing matches; random ids and hidden buttons do not fix it.
This article is for developers and learners who want to see an IDOR in real code rather than in a definition. We read the vulnerable basket handler in CyberGym, our deliberately vulnerable training app, reproduce the same bug in a tiny Express app on 127.0.0.1 with two fake users, fix it, and write the two-user test that keeps it fixed. Only test apps you own or have written permission to test; CyberGym and your own localhost are the safe places to practise.
What is an IDOR vulnerability?
An IDOR is a missing authorization check on a specific object. MITRE files the underlying weakness as CWE-639, Authorization Bypass Through User-Controlled Key: the system "does not prevent one user from gaining access to another user's data or record" by changing the key that identifies it. The key is anything the client sends that names a record — a number in the path, an orderId in a JSON body, a file name in a query string.
Three things have to be present, as the OWASP IDOR Prevention Cheat Sheet frames it: an object, a reference to it that the user controls, and no access-control check between the two. The user is usually logged in. That is what makes IDOR easy to miss: the endpoint does check authentication, so it looks protected, but it never asks whether this particular object belongs to this particular user.
OWASP lists the pattern by name in the first category of the OWASP Top 10:2025, A01 Broken Access Control: "permitting viewing or editing someone else's account by providing its unique identifier (insecure direct object references)". In the OWASP Top 10 2013 it had a category of its own, A4. You can also look it up in our glossary entry on IDOR.
What is the difference between IDOR and BOLA?
They describe the same flaw from two angles. IDOR names the mechanism — a direct reference the user can change — while BOLA, Broken Object Level Authorization, names the missing control. BOLA is the term the OWASP API Security Top 10 2023 uses, where it is ranked first as API1:2023: "object level authorization checks should be considered in every function that accesses a data source".
MITRE lists both IDOR and BOLA as alternate terms for CWE-639, and notes that IDOR is a little broader (it can also cover path traversal) and that the exact relationship between the terms is still under review. In practice: say IDOR when talking about a web page or a classic web app, BOLA when talking about an API, and fix both the same way. The API Top 10 separates BOLA from BFLA, Broken Function Level Authorization, where the user should not reach the endpoint at all — for example a regular user calling an admin route. With BOLA, the user is allowed the endpoint, just not that object. Our BOLA glossary entry has the short version.
What does an IDOR look like in real code?
Here is the handler behind GET /rest/basket/:id in CyberGym. CyberGym is a re-branded derivative of OWASP Juice Shop (MIT licence), and this bug is on purpose: it is the "View Basket" challenge in the Broken Access Control category, described as "View another user's shopping basket". Quoted from routes/basket.ts, with the challenge bookkeeping and translation loop removed:
export function retrieveBasket () {
return (req: Request, res: Response, next: NextFunction) => {
const id = req.params.id
BasketModel.findOne({ where: { id }, include: [{ model: ProductModel, paranoid: false, as: 'Products' }] })
.then((basket: BasketModel | null) => {
res.json(utils.queryResultToJson(basket))
}).catch((error: Error) => {
next(error)
})
}
}
And here is how the route is mounted in server.ts:
app.use('/rest/basket/:id', security.isAuthorized())
// ...
app.get('/rest/basket/:id', retrieveBasket())
Read the two together and the whole bug is visible:
security.isAuthorized()verifies the JWT. That answers who are you? — and a request without a valid token is refused before the handler runs.req.params.idcomes straight from the URL. The client chose it.findOne({ where: { id } })filters on that id and nothing else. The authenticated user's id is never consulted, so is this basket yours? is never asked.
Any logged-in user who replaces their own basket number with a neighbouring one gets a basket that is not theirs. The handler itself is not "wrong" in the sense of crashing or returning bad data; the record it returns is correct. It is just someone else's.

The same request, the same valid session: the only difference is whether the lookup includes the caller's user id.
How do I reproduce an IDOR safely?
Build it yourself on 127.0.0.1. You do not need to send anything to anyone's server to watch an IDOR happen: the toy below has two fake users, Alice (basket 1) and Bob (basket 2), a vulnerable route and a fixed route, and it only listens on your own machine. Save it as server.js, then run npm install express@4 and node server.js (we used Express 4.22.3 on Node.js 26).
// A two-user toy that reproduces CyberGym's basket bug and its fix on 127.0.0.1.
const express = require('express')
const app = express()
// Fake data: two users, one basket each.
const sessions = { 'token-alice': { id: 1, email: 'alice@your-app.example' },
'token-bob': { id: 2, email: 'bob@your-app.example' } }
const baskets = [ { id: 1, UserId: 1, items: ['Recon Energy Drink'] },
{ id: 2, UserId: 2, items: ['Lock Picking Kit'] } ]
// Authentication: who is calling? 401 without a valid session.
function authenticate (req, res, next) {
const token = (req.get('Authorization') || '').replace(/^Bearer /, '')
req.user = sessions[token]
if (!req.user) return res.status(401).json({ error: 'Unauthorized' })
next()
}
// Vulnerable: looks the basket up by the id in the URL alone.
app.get('/vuln/basket/:id', authenticate, (req, res) => {
const basket = baskets.find(b => b.id === Number(req.params.id))
if (!basket) return res.status(404).json({ error: 'Not found' })
res.json(basket)
})
// Fixed: looks the basket up by id AND the caller's user id.
app.get('/fixed/basket/:id', authenticate, (req, res) => {
const basket = baskets.find(b => b.id === Number(req.params.id) && b.UserId === req.user.id)
if (!basket) return res.status(404).json({ error: 'Not found' })
res.json(basket)
})
app.listen(3000, '127.0.0.1', () => console.log('listening on http://127.0.0.1:3000'))
The test is the one the OWASP Web Security Testing Guide, WSTG-ATHZ-04 describes: use at least two users who own different objects, take a request that works for one of them, and change only the reference. Here it is against the vulnerable route, with Alice's session — this is the real output from our run:
$ curl -s -w ' %{http_code}\n' -H 'Authorization: Bearer token-alice' http://127.0.0.1:3000/vuln/basket/1
{"id":1,"UserId":1,"items":["Recon Energy Drink"]} 200
$ curl -s -w ' %{http_code}\n' -H 'Authorization: Bearer token-alice' http://127.0.0.1:3000/vuln/basket/2
{"id":2,"UserId":2,"items":["Lock Picking Kit"]} 200
Both are 200. The second response is Bob's basket, served to Alice. Notice that nothing in either response looks like an error; you only see the bug by comparing who asked with what came back. The same requests against the fixed route:
$ curl -s -w ' %{http_code}\n' -H 'Authorization: Bearer token-alice' http://127.0.0.1:3000/fixed/basket/1
{"id":1,"UserId":1,"items":["Recon Energy Drink"]} 200
$ curl -s -w ' %{http_code}\n' -H 'Authorization: Bearer token-alice' http://127.0.0.1:3000/fixed/basket/2
{"error":"Not found"} 404
$ curl -s -w ' %{http_code}\n' -H 'Authorization: Bearer token-bob' http://127.0.0.1:3000/fixed/basket/2
{"id":2,"UserId":2,"items":["Lock Picking Kit"]} 200
$ curl -s -w ' %{http_code}\n' -H 'Authorization: Bearer token-alice' http://127.0.0.1:3000/fixed/basket/3
{"error":"Not found"} 404
$ curl -s -w ' %{http_code}\n' -H 'Authorization: Bearer nope' http://127.0.0.1:3000/fixed/basket/1
{"error":"Unauthorized"} 401
Alice still gets her own basket, Bob still gets his, and Alice asking for Bob's basket gets exactly the same answer as Alice asking for basket 3, which does not exist. That last detail is deliberate: a 404 for "not yours" means the endpoint does not even confirm that basket 2 exists.
How do I fix an IDOR in my own app?
Check ownership on the server, on every request, at the point where the record is loaded. The OWASP cheat sheet and API1:2023 both come down to that. Three patterns cover most code.
Scope the query to the caller
Put the user's id into the lookup, so a record that is not theirs is never loaded. In CyberGym's own codebase the fixed handler would read like this (CyberGym keeps the bug on purpose, so this is the shape of the fix, not a patch we applied):
export function retrieveBasket () {
return (req: Request, res: Response, next: NextFunction) => {
const user = security.authenticatedUsers.from(req)
if (!user) return res.status(401).json({ error: 'Unauthorized' })
BasketModel.findOne({
where: { id: req.params.id, UserId: user.data.id },
include: [{ model: ProductModel, paranoid: false, as: 'Products' }]
})
.then((basket: BasketModel | null) => {
if (!basket) return res.status(404).json({ error: 'Not found' })
res.json(utils.queryResultToJson(basket))
}).catch(next)
}
}
The user id comes from the verified session, never from the request body or a query parameter. If the client can send UserId, you have moved the IDOR, not fixed it.
Load, then authorize, for writes and shared objects
When an object can be reached by more than its owner — a document shared with a team, an order an admin may refund — load it first and then ask a policy function. This is also where 403 is the right answer rather than 404, if you are willing to confirm the object exists:
// Document is your ORM model (Sequelize-style findByPk shown).
const httpError = (status) => Object.assign(new Error(String(status)), { status })
async function getDocumentFor (user, id) {
const doc = await Document.findByPk(id)
if (!doc) throw httpError(404)
if (!canAccess(user, doc)) throw httpError(403)
return doc
}
function canAccess (user, doc) {
return doc.ownerId === user.id || doc.sharedWith.includes(user.id) || user.role === 'admin'
}
Route every read, update and delete of that object type through the same function. IDORs on PUT, PATCH and DELETE are the same bug with worse consequences, and they are the ones a read-only check leaves behind.
Reach objects through the user
For lists and nested resources, start from the user and walk down instead of starting from a global table — the cheat sheet's example is fetching a project through the user's own projects. In an ORM that is user.getBaskets() or Basket.findAll({ where: { UserId: user.id } }), never Basket.findAll() filtered on the client.
What does not fix an IDOR?
- Random ids. UUIDs make guessing harder, and the cheat sheet recommends them as defence in depth, but ids leak through URLs, logs, shared links and other API responses. An unguessable id is still not an authorization check.
- Hiding it in the UI. Removing a button or a link changes nothing for someone who sends the request directly.
- Encrypting the id. The cheat sheet warns against it: doing it securely is hard, and the server still has to check ownership after decrypting.
How do I keep an IDOR from coming back?
Write a test that uses two users, and make it fail the build. The API Security Top 10 says it plainly: write tests for the authorization mechanism and do not deploy changes that make them fail. With the toy server running, this is all it takes, using Node's built-in test runner:
// Run with the server up: node --test two-user.test.js
const test = require('node:test')
const assert = require('node:assert')
const base = 'http://127.0.0.1:3000/fixed'
const get = (path, token) =>
fetch(base + path, { headers: { Authorization: `Bearer ${token}` } })
test('a user can read their own basket', async () => {
assert.strictEqual((await get('/basket/1', 'token-alice')).status, 200)
})
test("a user cannot read another user's basket", async () => {
assert.strictEqual((await get('/basket/2', 'token-alice')).status, 404)
})
Against /fixed both tests pass. Pointed at /vuln, the second one fails with actual: 200, expected: 404 — which is the IDOR, caught by a test instead of by a stranger.
The harder part on a real app is knowing which endpoints need such a test. Every endpoint that takes an id is a candidate, and they are easy to lose track of once an app has a few dozen routes. That inventory is what AppMap produces: it crawls your app into a state graph and lists the API endpoints it reaches with the authentication each one requires, so the endpoints that take an object id and only check for a session stand out as the ones to test first. A map shows where to look; it does not prove an endpoint is safe, and an endpoint it never reached still needs testing. The walkthrough in Map a web app's attack surface before you test it shows the same basket endpoint from the mapping side, and the web security tools reference covers the browser and proxy tools for capturing the requests you will replay.
Key takeaways
- Treat every endpoint that takes an object id as an authorization question, not just an authentication one: a valid session is not proof of ownership.
- Fix IDOR on the server by putting the caller's user id into the lookup, or by loading the object and passing it through one policy function used for reads and writes alike.
- Take the user id from the verified session only; an id the client can send is a new IDOR.
- Use random ids and a tidy UI as extras, never as the fix.
- Keep a two-user test per sensitive endpoint in CI, so a regression fails the build instead of shipping.
FAQ
What is an IDOR vulnerability in simple terms?
It is when an app lets you see or change a record just by changing its id in a request, because the server never checks that the record is yours. For example, changing /orders/1042 to /orders/1043 returns another customer's order. MITRE classifies it as CWE-639, and OWASP lists it under A01 Broken Access Control.
Is IDOR the same as BOLA?
Practically, yes. IDOR is the older web-application name that describes the mechanism, a direct reference the user controls; BOLA, Broken Object Level Authorization, is the name the OWASP API Security Top 10 uses and ranks first in its 2023 edition. MITRE lists both as alternate terms for CWE-639, and the fix for both is a server-side ownership check on every object.
Should a blocked IDOR return 403 or 404?
Both are acceptable as long as no data comes back. A 404 for "not yours" avoids confirming that the object exists, which matters when ids are guessable; a 403 is clearer when the user may legitimately know the object exists, such as a shared document they lost access to. Pick one per resource and use it consistently.
Do UUIDs prevent IDOR?
No. Random ids make guessing harder and are a reasonable extra layer, but they leak through URLs, logs, emails and other API responses. Once someone has the id, only an authorization check stops them.
How do I test my own app for IDOR?
Create two test accounts that each own some data, capture a request that works for the first account, and replay it with the second account's session while changing only the object id. Do it for reads and for writes, and for ids in paths, JSON bodies and headers. Only test apps you own or have written permission to test; CyberGym exists for practice.
Sources
- CWE-639: Authorization Bypass Through User-Controlled Key (MITRE)
- OWASP Top 10:2025 — A01 Broken Access Control
- OWASP Top 10 2013 — A4 Insecure Direct Object References
- OWASP API Security Top 10 2023
- OWASP API1:2023 Broken Object Level Authorization
- OWASP Insecure Direct Object Reference Prevention Cheat Sheet
- OWASP WSTG-ATHZ-04: Testing for Insecure Direct Object References
- OWASP Juice Shop
Practise the two-user test on CyberGym, or map the endpoints of your own app with HackerGPT.