Course outline · 0% complete

0/29 lessons0%

Course overview →

Ownership: Listing and Deleting Safely

lesson 9-3 · ~12 min · 29/29

The bug that ships most often

The capstone handler you just built checks who is asking. This lesson finishes the job, making sure users can only see and delete their own notes.

Skipping that comparison is one of the most common real-world API vulnerabilities, the IDOR (insecure direct object reference). A user changes the id in the URL from /notes/17 to /notes/18 and reads or deletes a stranger's data.

Security scanners hunt for it automatically and bug-bounty programs pay for it weekly. Unlike a broken login it hides inside endpoints that look protected, because the request is authenticated and it is only the ownership check that is missing.

That is why it survives code review so often. The route has auth middleware on it, the tests pass, and the one missing line is a comparison nobody thinks to look for.

Two rules, mapped onto the lesson 9-1 endpoints:

  • GET /notes never returns all notes. It filters by the session's user, and the username comes from the server-side session, never from the request, because the client does not get to declare who it is.
  • DELETE /notes/:id loads the note first, then compares its author to the session user. No such note gives 404, and someone else's note gives 403. That is your lesson 3-3 problem, finally as running code.

Note the ordering inside that second rule, since you cannot check ownership before you have the note. Existence check first, then ownership, which is the sequence the handler below follows literally.

DELETE /notes/:id 1. who are you 401 no session unauthorized 2. does the note exist 404 no such id note_not_found 3. is it yours 403 someone else's note forbidden, the IDOR gate 4. do the work 204 deleted, no body skip gate 3 and the endpoint still looks protected, which is why IDOR ships so often
The four gates every mutating endpoint passes through, in order, with the status code each one returns on failure.

Listing only your own notes

handleListNotes authenticates, then filters the notes down to the session user's own, so Grace's note never appears in Ada's list.

const sessions = new Map([["s1", { username: "ada" }]]);

const notes = [
  { id: 1, author: "ada", text: "ship it" },
  { id: 2, author: "grace", text: "review PR" },
  { id: 3, author: "ada", text: "buy milk" },
];

function handleListNotes(req) {
  const session = sessions.get(req.sessionId);
  if (!session) {
    return { status: 401, body: { error: { code: "unauthorized" } } };
  }
  const mine = notes.filter((n) => n.author === session.username);
  return { status: 200, body: { data: mine, meta: { total: mine.length } } };
}

console.log(JSON.stringify(handleListNotes({ sessionId: "s1" })));
console.log(JSON.stringify(handleListNotes({ sessionId: "s9" })));

Output

{"status":200,"body":{"data":[{"id":1,"author":"ada","text":"ship it"},{"id":3,"author":"ada","text":"buy milk"}],"meta":{"total":2}}}
{"status":401,"body":{"error":{"code":"unauthorized"}}}

Stage one is identical to lesson 9-2, with sessions.get and an early return of 401 when it is missing. Repeating the same two lines in every handler is exactly why real apps lift them into middleware.

notes.filter((n) => n.author === session.username) keeps only the requester's notes, and note 2 is absent from the output. The comparison uses session.username, which came from the server-side session, so there is no input a client could send to widen the result.

meta.total counts my notes with mine.length, not the whole table. Reporting the global count would leak how much data other users have, which is a small version of the same information leak the next block is about.

Filtering after fetching everything works here and does not scale, which is the honest caveat. Against a real database the filter belongs in the query, so the rows never leave the database in the first place.

That version pairs with pagination from the previous lesson, since a user with 50,000 of their own notes still needs a bounded response. Filter first, then slice.

Why some APIs answer 404 instead of 403

Because a 403 confirms that a note with that id exists, which leaks information, and a 404 reveals nothing about other users' data.

403 is the textbook-truthful answer, and it tells the requester that the id exists and is simply off limits. With guessable numeric ids that lets an attacker map how much data exists and where the interesting records are.

Answering 404 for both the missing and the forbidden case means "no such note for you". The response is identical either way, so probing ids returns no signal at all.

Either choice is defensible, so pick one and stay consistent. Mixing them is the worst option, since a 403 on one endpoint and a 404 on another still hands over the existence signal.

There is a related fix worth knowing, which is not using sequential ids for anything user-facing. A random identifier is not guessable, so the enumeration attack loses its starting point regardless of which status you return.

What is never defensible is skipping the ownership check itself. The status code is a disclosure question, and the check is the actual security boundary.

The last handler of the course

handleDeleteNote walks four outcomes in order: unknown session, missing note, someone else's note, and finally the delete.

const sessions = new Map([["s1", { username: "ada" }]]);

let notes = [
  { id: 1, author: "ada", text: "ship it" },
  { id: 2, author: "grace", text: "review PR" },
];

function handleDeleteNote(req) {
  const session = sessions.get(req.sessionId);
  if (!session) {
    return { status: 401, body: { error: { code: "unauthorized" } } };
  }
  const note = notes.find((n) => n.id === req.id);
  if (!note) {
    return { status: 404, body: { error: { code: "note_not_found" } } };
  }
  if (note.author !== session.username) {
    return { status: 403, body: { error: { code: "forbidden" } } };
  }
  notes = notes.filter((n) => n.id !== req.id);
  return { status: 204 };
}

console.log(JSON.stringify(handleDeleteNote({ sessionId: "s9", id: 1 })));
console.log(JSON.stringify(handleDeleteNote({ sessionId: "s1", id: 99 })));
console.log(JSON.stringify(handleDeleteNote({ sessionId: "s1", id: 2 })));
console.log(JSON.stringify(handleDeleteNote({ sessionId: "s1", id: 1 })));
console.log("notes left: " + notes.length);

Output

{"status":401,"body":{"error":{"code":"unauthorized"}}}
{"status":404,"body":{"error":{"code":"note_not_found"}}}
{"status":403,"body":{"error":{"code":"forbidden"}}}
{"status":204}
notes left: 1

Each stage is an early return, in the exact order listed: auth, existence, ownership, work. Reordering any two of them produces a bug, since checking ownership before existence would read note.author on undefined and crash.

notes.find((n) => n.id === req.id) returns undefined for a missing id, which is the 404. Using find rather than filter here is deliberate, because the handler needs the note itself to inspect its author.

The third call is the IDOR attempt from the top of the lesson. Ada asks to delete note 2, the ownership comparison catches it, and the last output line confirms Grace's note is the one still standing.

The 204 return has no body key at all, which is success with nothing to say and matches the lesson 3-3 rule. A client that sees 204 knows the delete worked and knows not to parse a response.

notes is declared with let precisely so the filter reassignment works. A real database would issue a DELETE ... WHERE id = ? instead, and the four-stage structure above it would be unchanged.

That structure is the shape of nearly every mutating endpoint you will write. Who are you, does the thing exist, is it yours, and only then do the work.

Filtering the query down to one user's rows

The SQL keyword is WHERE.

The handler runs SELECT id, text FROM notes WHERE author = ? with the session's username as the placeholder value. Filtering in the query rather than in JavaScript is also the fix for the caveat in the listing handler, since other users' rows never leave the database.

That username comes from the server-side session, never from client input, so nobody can request someone else's rows. This is the ownership rule enforced at the data layer instead of after the fact.

The placeholder is doing the second job here. Passing the username as a parameter rather than concatenating it keeps the lesson 6-3 injection defense intact, so ownership and safety come from the same one-line query.

Enforcing it in the query is stronger than enforcing it in the handler, and the reason is that there is no path around it. A handler check can be forgotten on a new endpoint, and a WHERE clause in the repository method protects every caller of that method.

With that, the notes API is complete. Every endpoint, status code, and check in it came from a lesson in this course, and the same four questions will structure the next API you build from scratch.