Return 401 Unauthorized when the request has no valid credentials: no token, an expired session, a bad signature. Logging in could fix it, and the response must carry a WWW-Authenticate header. Return 403 Forbidden when you know who the caller is and they still may not do this, so signing in again changes nothing.
| 401 Unauthorized | 403 Forbidden | |
|---|---|---|
| Really means | Not authenticated | Not permitted |
| Will logging in help? | Yes | No |
| Required header | WWW-Authenticate | None required |
| Typical cause | Missing or expired token or session | Role or ownership check failed |
| Client should | Sign in or refresh the token, then retry | Stop and show an access message |
Updated
Answer four questions about the request your server is refusing. The picker returns the status to send, the header that goes with it, and a one-line reason you can put in a code comment. It also covers the case where you would rather not admit the resource exists.
Did the request carry credentials (session cookie, token, Authorization header)? Yes
Were those credentials valid and unexpired? Yes
Is this caller allowed to perform this action on this resource? No
Should callers without access be kept from learning the resource exists? No
Send this
HTTP/1.1 403 Forbidden
Content-Type: application/json
{"error": "forbidden"}The caller is known and lacks permission. Signing in again will not help.
verifyToken stands in for your own session or JWT check. The order matters: authenticate, then authorise.
function requireRole(role) {
return async (req, res, next) => {
const header = req.get("Authorization") || "";
const token = header.startsWith("Bearer ") ? header.slice(7) : null;
if (!token) {
res.set("WWW-Authenticate", 'Bearer realm="api"');
return res.status(401).json({ error: "authentication_required" });
}
const user = await verifyToken(token).catch(() => null);
if (!user) {
res.set("WWW-Authenticate", 'Bearer realm="api", error="invalid_token"');
return res.status(401).json({ error: "invalid_token" });
}
if (!user.roles.includes(role)) {
return res.status(403).json({ error: "forbidden" });
}
req.user = user;
next();
};
}One code path sends the user to sign in; the other explains the refusal.
const res = await fetch("/api/reports", { credentials: "include" });
if (res.status === 401) {
window.location.assign("/login?next=/reports");
} else if (res.status === 403) {
showMessage("Your account does not have access to reports.");
} else {
render(await res.json());
}
Every protected request answers two questions in order. Who is calling? That is authentication, and failing it gives 401. Is this caller allowed to do this? That is authorisation, and failing it gives 403.
Keeping them apart helps clients. A 401 tells a front end to send the user to sign in. A 403 tells it that signing in again will not help and an access message belongs on screen instead.
When a request already carried credentials and still gets a 401, the server is saying those credentials were refused. For a 403 the server may explain the reason in the body, or say nothing about it at all.
401 says the server does not know who you are, or did not accept the proof you sent. 403 says it knows who you are and the answer is still no.
Yes. RFC 9110 says a server generating a 401 response must send at least one WWW-Authenticate header field with a challenge, such as Bearer for token APIs.
401. The credentials are no longer valid, and fresh ones would fix it. A Bearer challenge can say so with error="invalid_token".
Yes. The specification lets a server send 404 instead of 403 to hide whether the resource exists. It is a common choice for ids that belong to other users.
It can. Servers send 403 for any refusal they consider final for this request: IP rules, missing file permissions, a firewall rule or a failed role check. The response body or server log usually says which.
Related: HTTP status codes reference, Authentication guide, What is a JWT, OAuth explained, HTTP 405 Method Not Allowed.
Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.