Sessions and authorization

Authorize resource ownership

Enforce ownership in server-side queries so one authenticated user cannot read or modify another user’s books by changing an ID.

Being logged in doesn’t make every book yours. A user who owns book 17 can change the URL to /books/18 and try their luck. If the handler only checks “is there a valid session?”, it works. This bug has a name, insecure direct object reference, and it’s one of the most common vulnerabilities in real applications.

The fix is small. An ID is not an authorization token. Every query includes the owner.

Put the owner in the query

Here’s the update for a book title:

UPDATE books
SET title = ?
WHERE id = ? AND owner_id = ?

The owner_id comes from the AuthContext the middleware built, never from the request. Then check the affected-row count. Zero rows means the book doesn’t exist for this owner. Whether it exists for someone else is none of the caller’s business.

Resist the urge to run a second, unrestricted query just to produce a nicer “this book belongs to someone else” error. That message tells an attacker the ID is valid. Return 404 unless the product truly needs the distinction.

Reads need the same predicate

The same rule applies to reading:

SELECT id, title
FROM books
WHERE id = ? AND owner_id = ?

A common mistake is to SELECT by ID alone and then check book.ownerId === userId in JavaScript. By then the private data has already left the database and sits in your process memory. One forgotten if and it goes out in the response. Put the boundary as close to the data as you can, which means in the query.

The paths you’ll forget

The single-book routes are the easy part. The ownership check also belongs in:

  • search and filtering
  • exports and downloads
  • attachments and cover images
  • counts and statistics
  • batch updates and bulk deletes
  • background jobs that process books
  • WebSocket or SSE subscriptions

And one more thing. Hiding the “Delete” button in the UI for books you don’t own is not authorization. Anyone can open DevTools and call DELETE /books/18 directly. The server decides, always.

Administrators

Admins need to see everything, and the temptation is to sprinkle isAdmin || book.ownerId === userId through every handler. Don’t. Centralize the policy in one function, canAccessBook(user, book), and make elevated access explicit and logged. Then test both sides: the admin can, the regular user can’t.

Try this: create two users in the Books API, Alice and Bob. Log in as Bob and hit every route with the ID of Alice’s book. List, read, export, update, delete. Each one must fail, and after the run Alice’s book must be unchanged in the database. Check the response and the table. A 403 that still ran the UPDATE is a bug.

Lesson completed