Insecure Direct Object References (IDOR)
The simplest vulnerability to understand and the easiest to miss
The injection and XSS sections covered attacks where the attacker crafts special payloads — SQL code, shell commands, JavaScript. The defenses were about encoding or separating code from data. This section is different. The attacks here require no special characters and no injected code. The attacker just changes ordinary values in the request.
The vulnerable code
Look at the note detail route from the project setup:
route.get("/notes/:id", {
resolve: (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
const note = db.prepare("SELECT * FROM notes WHERE id = ?").get(c.params.id);
if (!note) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json(note);
},
}); The route authenticates the user (checks they are logged in) but does not check that the note belongs to them. It finds the note by ID, regardless of who owns it.
The attack
Alice is logged in. She views her note: GET /notes/note-1. Then she changes the URL to GET /notes/note-3 — the admin’s note. The server returns it.
# Alice views the admin's note
curl -b cookies.txt http://localhost:3000/notes/note-3
# Returns: { "id": "note-3", "user_id": "admin-1", "title": "Admin Notes", "body": "Secret admin content" } Alice sees the admin’s secret content. No injection, no scripts, no special payload. Just a different ID.
This is an insecure direct object reference: the application uses a user-supplied reference (the note ID) to access an object directly, without checking authorization.
Why it happens
Developers often think authentication equals authorization:
- Authentication: Who are you? (Alice, verified by session cookie)
- Authorization: What can you do? (Alice can access her own notes, not the admin’s)
The route checks authentication (is the user logged in?) but skips authorization (does this user own this note?).
The fix: always check ownership
Add a user_id check to the query:
route.get("/notes/:id", {
resolve: (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
// SAFE: includes user_id in the WHERE clause
const note = db
.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?")
.get(c.params.id, user.id);
if (!note) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json(note);
},
}); Now GET /notes/note-3 as Alice returns 404 because note-3 belongs to admin-1, not user-1. The query finds no matching row.
Notice we return 404 (“Not found”), not 403 (“Forbidden”). Returning 403 would tell the attacker that the note exists but they cannot access it. Returning 404 tells them nothing — the note might not exist, or it might belong to someone else. They cannot tell the difference.
Where IDOR hides
IDOR appears in any route that takes an ID from the request and uses it to look up data:
GET /notes/:id— reading a notePUT /notes/:id— updating a noteDELETE /notes/:id— deleting a noteGET /users/:id— viewing a profileGET /invoices/:id— downloading an invoice
Every one of these needs an ownership check. The pattern is always the same: include the authenticated user’s ID in the query.
Exercises
Exercise 1: Fix the GET /notes/:id route. As Alice, try accessing note-3. Verify you get 404.
Exercise 2: Check the POST /notes route. The mass assignment vulnerability (which we fix in Section 5) also has IDOR implications: the attacker can set user_id to create a note owned by another user. Fixing mass assignment fixes this too.
Exercise 3: Write a DELETE /notes/:id route with proper ownership checking. Verify that Alice can delete her own notes but not the admin’s.
Why does the fix return 404 instead of 403 when the note belongs to another user?