The check that is not enough
The earlier security lesson gave you the one-line fix: AND user_id = $2, with the id from the session. That is the right answer for a note only its author reads. Most real rows are not like that.
A booking belongs to the customer who made it and to the business that will serve them. An invoice is visible to the client, to the accountant the client added, and to the firm that issued it. A document in a shared folder is visible to everyone in the folder. In each case the ownership check is either wrong (it blocks people who should see the row) or, once the agent has been asked to make the page work, deleted, so that everybody sees everything.
The pattern that survives is a named function.
One function, asked everywhere
// lib/can.ts
export async function can(user: User, action: "read" | "cancel" | "edit", booking: Booking) {
if (booking.userId === user.id) return action !== "edit"; // the customer
const member = await db.query.memberships.findFirst({
where: and(eq(memberships.userId, user.id), eq(memberships.businessId, booking.businessId)),
});
if (member) return true; // staff of the business
return false;
}Every route that touches a booking loads it, calls can, and returns 404 if the answer is no. The rules live in one file, the tests live beside it, and when the rule changes (accountants can now read, staff cannot cancel) it changes in one place rather than in the eleven routes where an agent scattered slightly different conditions.
Membership is a table: memberships(user_id, business_id, role). Not a column on the business, not a list of ids in a JSON field, a table with a foreign key each way. It is how “the business” becomes “the people who work at the business”, and it is how a person leaves without their access lingering.
404, not 403
When the answer is no, return the same response you would for a row that does not exist. A 403 tells the caller that the id is real and belongs to somebody; a 404 tells them nothing. The difference matters for enumerable ids from the data block, where a script trying ids in sequence learns from every 403 exactly which ones are worth attacking later. Log the refusal on your side, so you can see the script; give it nothing on the wire.
Three accounts, not two
The earlier test was two accounts: log in as B and try to reach A’s things. With shared resources, the test needs three.
- A, the customer who made the booking.
- B, a member of the business.
- C, a member of a different business.
A and B should see it. C should not. Then the actions: A can cancel but not edit; B can edit; C can do nothing. Write this as a script that runs against the preview deployment, one request per cell, checking the status code, and run it before every release. It is thirty lines and it is the test that catches the agent making the page work by removing the check.
Lists are routes too
The single-row check is where people look. The list is where the leak usually is. GET /api/bookings returning every booking, with the filtering done on the page, is the same hole with a wider mouth; the Network tab shows the whole table. Every list query starts from the user: their bookings, or the bookings of businesses they are a member of, joined through the membership table. Never the full table filtered afterwards in JavaScript.
Actions the model wires wrongly
Three requests, each reasonable, each producing a hole when built quickly.
“Let staff manage bookings.” Built as a role column on users, and every check becomes if (user.role === "staff"), which makes staff at one business able to manage bookings at all of them. The role belongs on the membership, scoped to the business.
“Add an admin who can see everything.” Built as isAdmin on the users table, set from the profile form, which is the mass-assignment hole in the lesson after next. Admin belongs in an environment allowlist, not in a column a form can reach.
“Let users share a document by link.” Built as a public route keyed by the document id, the guessable id again. A share link is a separate random token in a shares table, revocable, with its own expiry.
The question for the agent
Not “is this secure”, which produces reassurance. For each route: “which line decides whether this user may perform this action on this row, and what does it return when they may not”. If the answer for any route is a line that compares only user_id, ask whether anyone else should see that row. If the answer is that the front end hides the button, there is no check.
Every lesson here is open. Your progress is recorded once you pass the Building with AI exam.
Check your understanding
A booking is visible to the customer who made it and to the business it was made with. The route checks booking.userId === session.userId. The business owner opens the booking and gets 404. The agent proposes removing the check so the page works. What is the right change?
Pick the one you would defend. Your answer stays private.
Stuck on something?Ask Beatriz, your AI tutor
