A 401 Unauthorized response means the server did not accept the credentials it received. With a JWT, that covers two very different problems: the token never arrived in the form the server expects, or it arrived and failed verification. This checklist goes through both, in the order that finds most bugs fastest.
Every example and error message below was run on Node.js 24.21.0 with jsonwebtoken 9.0.2, express 5.1.0 and cors 2.8.5. Other libraries word their errors differently, but the causes are the same.
401 or 403?
401 means "I don't know who you are": the credentials are missing or invalid. 403 means "I know who you are, and you may not do this": the token is valid but lacks the role or scope. The Bearer token specification (RFC 6750) maps this onto error codes: invalid_token goes with 401 and insufficient_scope with 403. If you see 403, skip this list and check the token's roles or scopes instead.
Step 1: read the response, not just the status
A 401 response should carry a WWW-Authenticate header that says which check failed. Look at it with curl -i or the browser's Network tab:
$ curl -i -H "Authorization: Bearer $TOKEN" http://localhost:3000/orders
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api", error="invalid_token", error_description="token expired"
{"error":"invalid_token","reason":"token expired"}- No
errorattribute (justBearer realm="api"): the server did not find a token at all. Go to step 2. error="invalid_token": a token arrived and failed verification. Go to step 3.
If your own API returns a bare 401 with no detail, add the header (an example middleware is at the end). It turns guessing into reading.
Step 2: the token is missing or malformed
These all happen before any signature check, and they are the most common cause of a 401 that "should work".
No Authorization header at all
Check the request in the Network tab. Common causes: the request is sent before the token is loaded, a different HTTP client instance without the interceptor is used, or a proxy or redirect drops the header. The Fetch standard, which browsers and Node's built-in fetch follow, drops Authorization when a redirect goes to a different origin, so an http to https or api.example.com to www.api.example.com redirect can silently strip it.
Missing "Bearer " prefix
The header must be the scheme, a space, then the token:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Sending the bare token makes most servers report a missing token. The reverse mistake, passing the whole header to the verifier without removing Bearer , fails inside the library:
jwt.verify("Bearer " + token, secret);
// JsonWebTokenError: invalid tokenQuotes, spaces or a newline around the token
A token saved with JSON.stringify keeps its quotes when read back as a string. A token copied from a terminal often ends with a newline. Each of these fails with the same message:
jwt.verify(JSON.stringify(token), secret); // JsonWebTokenError: invalid token
jwt.verify(token + "\n", secret); // JsonWebTokenError: invalid token
jwt.verify(token + " ", secret); // JsonWebTokenError: invalid tokenOn the client, the quoted version looks like this:
const stored = JSON.stringify(token); // what was saved
await fetch("/orders", {
headers: { Authorization: `Bearer ${stored}` }, // 401: quotes included
});
await fetch("/orders", {
headers: { Authorization: `Bearer ${JSON.parse(stored)}` }, // 200
});Paste the exact header value into the JWT decoder: if it cannot split the token into three Base64URL parts, the string is not a bare JWT.
An empty token
jwt.verify("", secret); // JsonWebTokenError: jwt must be provided
jwt.verify(undefined, secret); // JsonWebTokenError: jwt must be providedThis usually means the header was Bearer undefined or Bearer null on the client, or the server read the wrong header name.
Step 3: the token arrived but failed verification
Once a well-formed token reaches the verifier, the error message names the failed check. The verifier in these examples uses:
jwt.verify(token, secret, {
algorithms: ["HS256"],
audience: "api://orders",
issuer: "https://auth.example.com",
});| Error message | Usual cause |
|---|---|
invalid signature | Signed with a different secret or key: another environment, a rotated key, or a secret loaded from the wrong env file. A truncated token gives the same error. |
jwt expired | exp is in the past (TokenExpiredError). |
jwt audience invalid. expected: api://orders | The token was issued for a different API (aud). |
jwt issuer invalid. expected: https://auth.example.com | Issued by another auth server, often staging vs production (iss). |
invalid algorithm | The token's alg header is not in algorithms, for example an RS256 token sent to an HS256 verifier. |
Decode the token to compare its claims with what the verifier expects. Decoding needs no secret and proves nothing about validity, but it shows you alg, aud, iss and exp at a glance:
jwt.decode(token, { complete: true }).header;
// { alg: 'RS256', typ: 'JWT' }Wrong algorithm or key type
A token signed with RS256 must be verified with the issuer's public key and algorithms: ["RS256"], not with a shared secret. Always pass algorithms explicitly so the server, not the token, decides which algorithms are acceptable.
Expired, or expiry in the wrong unit
exp is in seconds. The JWT expiration guide covers exp, iat, nbf and clock skew in detail. One trap is worth repeating here because it does not cause a 401: a token whose exp was set with Date.now() (milliseconds) passes verification, because the expiry is tens of thousands of years away. If a token never seems to expire, convert its exp with the timestamp converter.
Step 4: 401 only in the browser (CORS preflight)
If curl works but the browser fails with a CORS error, look for an OPTIONS request before the real one. A request with an Authorization header from another origin triggers a preflight, and the browser never attaches the token to it. If the auth middleware runs first, the preflight gets a 401:
app.use(requireAuth); // runs for OPTIONS too
app.get("/orders", handler);
// OPTIONS /orders -> HTTP/1.1 401 Unauthorized {"error":"missing_token"}The browser reports this as a CORS failure, not as a 401. Handle CORS before authentication:
const cors = require("cors");
app.use(cors({ origin: "http://localhost:5173" }));
app.use(requireAuth);
// OPTIONS /orders -> HTTP/1.1 204 No Content
// Access-Control-Allow-Headers: authorizationNote that a real 401 from a different origin also needs the Access-Control-Allow-Origin header, or the browser hides its body and status from your code. The cors middleware adds it to every response when it runs first.
A middleware that explains its 401s
This Express middleware accepts Bearer in any letter case (the scheme name is case-insensitive in HTTP), pins the algorithm, audience and issuer, and says why it rejected a token without leaking details an attacker could use:
const jwt = require("jsonwebtoken");
function requireAuth(req, res, next) {
const match = /^Bearer\s+(\S+)$/i.exec(req.headers.authorization ?? "");
const token = match?.[1];
if (!token) {
res.set("WWW-Authenticate", 'Bearer realm="api"');
return res.status(401).json({ error: "missing_token" });
}
try {
req.user = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ["HS256"],
audience: "api://orders",
issuer: "https://auth.example.com",
});
next();
} catch (error) {
const reason =
error.name === "TokenExpiredError" ? "token expired" : "invalid token";
res.set(
"WWW-Authenticate",
`Bearer realm="api", error="invalid_token", error_description="${reason}"`,
);
res.status(401).json({ error: "invalid_token", reason });
}
}Log error.message on the server, where it helps you, but keep the response generic apart from "expired", which the client needs in order to refresh. Tested against this middleware: no header, a bare token and Authorization: bearer with a lowercase scheme behave as described above, and a valid token returns 200.
Checklist
- Read
WWW-Authenticateand the response body. - Confirm the request actually carries
Authorization: Bearer <token>, with no quotes, spaces orundefined. - Decode the token and compare
alg,aud,issandexpwith the verifier's options. - Check that the secret or public key belongs to the same environment as the auth server that issued the token.
- If only the browser fails, check the
OPTIONSpreflight and put CORS before authentication.
The JWT decoder runs entirely in your browser, so you can inspect a real token from a failing request without sending it anywhere. For what decoding can and cannot prove, see JWT decoding vs signature verification.