Authentication & Authorization
Prove who the user is (authn) and decide what they may do (authz) — correctly, on every request.
Why
Authn and authz are the most security-critical code in any application and the most common source of breaches. A single endpoint that skips the authz check (because it was added late and the dev copied a public route) lets any user read any other user's data. The two are often conflated — "the user is logged in, so they can access this" — but login (authn) only proves identity; it says nothing about whether this user may access this specific resource. Correct authz is per-request and per-resource: the authenticated user must own or have permission for the specific object they are requesting. Getting this wrong at scale means a single bug exposes every user's data, not just one.
When
Design the authn/authz model before the first endpoint. Review every new route for correct authz before merge. Audit the full authz matrix quarterly — it is the code most likely to have silently drifted.
How
1. Separate authn (who) from authz (what) — never conflate them - Authn: verify the JWT/session cookie, load the user identity - Authz: check that this identity has permission for this specific resource 2. Authn: use a battle-tested library, do not roll your own - NextAuth/Auth.js, Passport, Keycloak, or your cloud's IAM (Cognito, Auth0) - Store password hashes with bcrypt(cost=12) or argon2id — never MD5/SHA1 - Issue short-lived JWTs (15min) + refresh tokens, never permanent sessions 3. Authz: check per-request, per-resource, on the server - Every endpoint: load the resource, check resource.ownerId === user.id (or role check) - Never trust the client to filter — the client can be bypassed with a curl call - Use a middleware or decorator so the check cannot be forgotten 4. Test the authz matrix exhaustively - For each endpoint, test: owner can access, non-owner cannot, anonymous cannot - Use a parameterized test that iterates every route × every role - A single "non-owner gets 403" test per endpoint catches most IDOR bugs 5. Audit and rotate - Quarterly: review every route's authz, look for drift (a route that lost its check) - Rotate keys and secrets regularly; revoke sessions on password change - Log every authz denial — a spike in 403s may indicate an attack or a bug
Example
// src/middleware/authz.ts — per-request, per-resource authorization
import type { Request, Response, NextFunction } from "express";
export function requireOwnership(getResourceId: (req: Request) => string) {
return async (req: Request, res: Response, next: NextFunction) => {
const userId = req.user?.id; // set by authn middleware
if (!userId) return res.status(401).json({ error: "Unauthorized" });
const resourceId = getResourceId(req);
const resource = await db.resources.findById(resourceId);
if (!resource) return res.status(404).json({ error: "Not found" });
if (resource.ownerId !== userId && req.user.role !== "admin") {
return res.status(403).json({ error: "Forbidden" }); // non-owner denied
}
req.resource = resource;
next();
};
}
// route: only the owner can delete their resource
router.delete("/resources/:id", requireOwnership((r) => r.params.id), async (req, res) => {
await db.resources.delete(req.resource.id);
res.status(204).end();
});
// test: non-owner gets 403
it("denies non-owner", async () => {
const res = await request(app).delete("/resources/42").set("auth", tokenForUser(99));
expect(res.status).toBe(403); // 42 is owned by user 1, not 99
});Common Problems
- ⚠Checking only that the user is logged in (authn) and not that they own the specific resource (authz) — this is the classic IDOR vulnerability that lets user A read user B's data by changing an ID in the URL.
- ⚠Doing authz on the client (hiding the edit button if the user is not the owner) but not on the server — the server endpoint is unprotected, and anyone with a curl command can edit any resource.
- ⚠Using a role check (isAdmin) where an ownership check is needed — an admin-only gate blocks non-admins but lets any admin edit any resource, which is wrong if admins should only read, not mutate.
- ⚠Forgetting the authz check on a new endpoint because it was added by copying a public route — the route ships unprotected, and the bug is found by an attacker or a lucky user, not by the team.
Best Practices
- ✓Enforce authz in a single middleware or decorator that every protected route must use — make it impossible to add a route without the check by linting for unprotected handlers.
- ✓Test every endpoint with three cases: owner succeeds, non-owner gets 403, anonymous gets 401 — a parameterized test over all routes catches the one route that lost its check.
- ✓Load the resource and check ownership on the server, never trust the client — the client is an optimization (hide the button), the server is the security boundary.
- ✓Log every 403 with the user ID, resource ID, and action, and alert on spikes — a sudden increase in denials may indicate an attack probing for IDOR or a bug in a new route.