Guarding Routes and Rows
Logging in tells Sprout who you are. On its own, it does not stop you from reading someone else's plants. Real protection takes two checks on the server, "are you logged in?" and "is this yours?", while the browser's part is only to make the experience smooth.
The rule
The client hides; the server forbids.
Guard the route. Every private route runs requireUser first. No valid token means 401.
app.delete('/api/plants/:id', requireUser, async (req, res) => {
const { rowCount } = await pool.query(
'DELETE FROM plants WHERE id = $1 AND owner_id = $2',
[req.params.id, req.user.id]
);
if (rowCount === 0) return res.status(404).json({ error: 'Plant not found' });
res.sendStatus(204);
});Guard the row. Every query on owned data also matches the owner, and the owner always comes from req.user.id, which the server read from a verified token. It never comes from the request body, a hidden field or the URL, because the client controls all of those. If nothing matches, answer 404: that way Sprout does not even confirm that someone else's plant 31 exists.
Two different refusals.
401 Unauthorizedmeans "I don't know who you are": no token, a bad one, or an expired one. The client should send the user to log in.403 Forbiddenmeans "I know who you are, and you may not do this", such as a regular user calling an admin-only route. Logging in again will not help.
The client's part is comfort, not protection. It hides buttons the user cannot use, and it reacts to 401 in one place:
export async function api(path, options = {}) {
const res = await fetch(path, options);
if (res.status === 401) {
window.location.assign('/login');
throw new Error('Not logged in');
}
return res;
}Every screen calls api(...) instead of fetch(...), so an expired login anywhere in the app leads calmly to the login page.
The shortcut
For every route, ask two questions: "Who is asking?", answered by requireUser from the token, and "Is this theirs?", answered by the owner in the WHERE. A route that cannot answer both from facts the server checked itself is a hole.
The request
DELETE /api/plants/31arrives with Asha's valid cookie. Plant 31 belongs to Ben.
The trap to spot in code review is owner_id = $2 filled from req.body.ownerId or from a URL like /api/users/5/plants. That lets the client choose whose data it gets, and it is one of the most common security bugs on the web.
Drills
Answer fast — trust the rule.
A request arrives with an expired token. Which status fits?
Where should the handler take the owner id for WHERE owner_id = $2?
A logged-in regular user calls an admin-only route. Which status fits?
The React app hides the Delete button on plants the user does not own. Is Sprout protected?
Remember
- The client hides; the server forbids.
- Shortcut: every route answers "Who is asking?" from the token and "Is this theirs?" in the
WHERE. - Trap: taking the owner from the body or URL. 401 means "log in"; 403 means "not allowed, even logged in".
