#Authentication vs authorization
Two questions get asked on every protected request, and they are different questions. Authentication asks "who are you?" — it turns a credential (a password, a session cookie, a signed token) into an identity like "user 42". Authorization asks "may this identity do this action on this resource?" — can user 42 read the orders of user 100? Delete this invoice? See this tenant's data?
A useful mental model is an airport. Passport control is authentication: it establishes that you are Rohit. Your boarding pass is authorization: it lets you onto one flight, in one seat — not into the cockpit, and not onto every plane at the gate. Being identified is necessary, but it never grants anything by itself.
A related source of confusion is format vs purpose. "JWT" names a token format; "access token" names a token's job. A JWT can be an access token, an ID token, or something else entirely, and an access token can also be an opaque random string. It's like "PDF" vs "invoice": one is the file format, the other is what the document is for. And OAuth is an authorization framework, not a token format at all.
In Express, the two questions usually become two middleware functions: one that establishes req.user (or answers 401), and one per route or resource that checks permission (or answers 403).
const express = require('express');
const jwt = require('jsonwebtoken');
const request = require('supertest');
const JWT_SECRET = 'demo-secret-not-for-production';
const app = express();
// Authentication: who is calling? Sets req.user or answers 401.
function authenticate(req, res, next) {
const [scheme, token] = (req.get('authorization') ?? '').split(' ');
if (scheme !== 'Bearer' || !token) {
return res.status(401).set('WWW-Authenticate', 'Bearer').json({ error: 'missing token' });
}
try {
const claims = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'], issuer: 'study-auth', audience: 'orders-api' });
req.user = { id: claims.sub, role: claims.role };
next();
} catch (err) {
res.status(401).set('WWW-Authenticate', 'Bearer error="invalid_token"').json({ error: 'invalid token' });
}
}
// Authorization: may THIS user do THIS action on THIS resource? Answers 403 if not.
function canReadOrders(req, res, next) {
if (req.user.id === req.params.userId || req.user.role === 'admin') return next();
res.status(403).json({ error: 'not your orders' });
}
app.get('/users/:userId/orders', authenticate, canReadOrders, (req, res) => {
res.json({ userId: req.params.userId, orders: ['#1001', '#1002'] });
});
const tokenFor = (sub, role) =>
jwt.sign({ sub, role }, JWT_SECRET, { expiresIn: '15m', issuer: 'study-auth', audience: 'orders-api' });
(async () => {
const show = (label, r) => console.log(label.padEnd(22), r.status, JSON.stringify(r.body), r.headers['www-authenticate'] ?? '');
show('no token', await request(app).get('/users/42/orders'));
show('garbage token', await request(app).get('/users/42/orders').set('Authorization', 'Bearer abc.def.ghi'));
show('user 42 → own', await request(app).get('/users/42/orders').auth(tokenFor('42', 'user'), { type: 'bearer' }));
show('user 42 → user 100', await request(app).get('/users/100/orders').auth(tokenFor('42', 'user'), { type: 'bearer' }));
show('admin → user 100', await request(app).get('/users/100/orders').auth(tokenFor('7', 'admin'), { type: 'bearer' }));
})();
// no token 401 {"error":"missing token"} Bearer
// garbage token 401 {"error":"invalid token"} Bearer error="invalid_token"
// user 42 → own 200 {"userId":"42","orders":["#1001","#1002"]}
// user 42 → user 100 403 {"error":"not your orders"}
// admin → user 100 200 {"userId":"100","orders":["#1001","#1002"]}- The route lists two middleware functions before the handler:
authenticate, thencanReadOrders. Express runs them in order, and each either callsnext()to continue or sends a response to stop the chain (see Middleware and the request pipeline). - No token: the
Authorizationheader is missing, soschemeis''andauthenticateanswers401withWWW-Authenticate: Bearer— the standard way to tell the client "this resource needs a Bearer credential".canReadOrdersnever runs. - Garbage token:
abc.def.ghihas three parts but the first one isn't valid JSON once decoded, sojwt.verifythrows (invalid token) and we answer401again. The client's identity is unknown, so it's an authentication failure. We return a fixed message rather thanerr.messageso we don't hand attackers verification details. - User 42 asking for their own orders: verification succeeds,
req.userbecomes{ id: '42', role: 'user' }, andcanReadOrdersseesreq.user.id === req.params.userId('42' === '42', both strings —subis always a string) →next()→200. - User 42 asking for user 100's orders: the token is perfectly valid — authentication passes — but the ownership check fails and the role isn't
admin, so we answer403. The server knows exactly who you are; you just aren't allowed. - Admin: same check, but the role branch lets them through. This is the whole point: a valid token proves identity; only an explicit rule on the server grants access to a specific resource.
| Status | Meaning | Typical cause |
|---|---|---|
401 Unauthorized | "I don't know who you are" — authentication missing or failed | No token, expired token, bad signature. Should carry WWW-Authenticate. |
403 Forbidden | "I know who you are, and the answer is no" | Valid token, but wrong owner, role, tenant or scope. Re-authenticating won't help. |
404 Not Found | Sometimes used instead of 403 on purpose | Hides whether user 100's invoice exists at all. |
A request arrives with a valid, unexpired access token for user 42 and calls DELETE /users/100. Only admins may delete other users. The token's role claim is user. What should the API answer, and why not the other code?
Show answer
403 Forbidden.
- Authentication succeeds: the signature, issuer, audience and expiry all check out, so the server knows the caller is user 42. A
401would be wrong — it tells the client "get a (new) credential", and a fresh token for user 42 would be refused just the same. - Authorization fails: the rule is "admins only", and the token's role is
user, so the action is refused →403. - Some APIs would answer
404instead, to avoid confirming that user 100 exists. That's a deliberate design choice, not a different meaning.