Authentication vs Authorization
Two different questions, two different failure codes, two different places in your code.
They get shortened to the same four letters in conversation, which is why they get confused in code. They are not the same check and they do not happen in the same place.
| Authentication (AuthN) | Authorization (AuthZ) | |
|---|---|---|
| Question | Who are you? | Are you allowed to do this? |
| Input | A credential | An identity + the resource |
| Runs | Once, at the edge | On every resource access |
| Failure | 401 Unauthorized | 403 Forbidden |
| Fix | Log in again | Ask an admin — logging in again changes nothing |
401 or 403?
The HTTP spec named 401 badly: it means unauthenticated. Send it when the credential is missing, malformed or expired, and pair it with a WWW-Authenticate header so the client knows how to fix it. Send 403 when the credential was perfectly valid but the identity behind it is not permitted.
Getting this backwards causes a real bug: a client that sees 401 will typically try to refresh its token and retry. If you return 401 for a permission problem, it retries forever.
Authorization models you will meet
- RBAC — roles carry permissions (
admin,editor,viewer). Simple, coarse, good enough for most apps. - ABAC — decisions from attributes (department, region, time of day, record owner).
- Ownership checks — the humble and most-often-forgotten one: does this row belong to this user?
- Scopes — what the client application was allowed to ask for, which is a separate ceiling from what the user may do.
The check nobody writes
Authenticated users attacking each other is the common case. GET /api/invoices/1043 with a perfectly valid token still has to prove that invoice 1043 belongs to the caller. Do that check in the query, not after it.
Example
# Missing or bad credential → 401, and tell the client what to send
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api", error="invalid_token"
Content-Type: application/json
{"error": "invalid_token", "message": "Access token has expired"}
# Good credential, wrong person → 403, retrying will not help
HTTP/1.1 403 Forbidden
Content-Type: application/json
{"error": "insufficient_permissions", "required": "orders:delete"}When to use it
- A support agent logs in successfully but is blocked from the refunds endpoint — a 403, not a 401, so the client does not pointlessly refresh the token.
- A third-party app authenticates a user via OAuth but only requested read scope, so writes are rejected even though the user personally has write permission.
- An expired access token returns 401 with WWW-Authenticate, which is exactly the signal the SDK needs to run its refresh flow and retry once.
More examples
Two middlewares, two responsibilities
Splitting them keeps the status codes honest and lets you compose permissions per route instead of writing one giant if-statement.
// AuthN: who are you? → 401
function authenticate(req, res, next) {
const claims = verifyBearer(req);
if (!claims) return res.status(401)
.set('WWW-Authenticate', 'Bearer realm="api"')
.json({ error: 'authentication_required' });
req.user = claims;
next();
}
// AuthZ: may you do this? → 403
const requireScope = (scope) => (req, res, next) =>
req.user.scopes.includes(scope)
? next()
: res.status(403).json({ error: 'insufficient_scope', required: scope });
app.delete('/api/orders/:id',
authenticate, // 401 if not logged in
requireScope('orders:delete'), // 403 if not permitted
deleteOrder // ...and ownership is still checked inside
);Ownership belongs in the query
Returning 404 rather than 403 for someone else's record also avoids confirming that the record exists, which is itself a small information leak.
// ❌ Fetch first, check later — leaks existence and invites forgotten checks
const invoice = await db.invoice.findById(req.params.id);
if (invoice.userId !== req.user.id) return res.sendStatus(403);
// ✅ Scope the query to the caller: an invoice they do not own simply is not found
const invoice = await db.invoice.findOne({
id: req.params.id,
userId: req.user.id, // ← the authorization check IS the query
});
if (!invoice) return res.sendStatus(404);
Discussion