"The token is fine, it works in Postman, but the API returns 401." A 401 with a JWT is one of two problems: the token never reached the verifier in the right shape, or it arrived and failed a check. These are the causes I check first. Every error message below was run on Node.js 24 with jsonwebtoken 9.0.2.
1. Read the response header first
A good API says why it rejected you:
HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer realm="api", error="invalid_token", error_description="token expired" No error attribute usually means the server found no token at all. error="invalid_token" means it found one and verification failed.
2. The header is not exactly Bearer <token>
All of these fail inside the library with the same unhelpful message:
jwt.verify("Bearer " + token, secret); // JsonWebTokenError: invalid token jwt.verify(JSON.stringify(token), secret); // JsonWebTokenError: invalid token (quotes from localStorage) jwt.verify(token + "\n", secret); // JsonWebTokenError: invalid token (copied from a terminal) jwt.verify("", secret); // JsonWebTokenError: jwt must be provided Bearer undefined on the client ends up as the last one.
3. The verifier and the token disagree
invalid signature -> different secret/key, often another environment jwt expired -> exp is in the past jwt audience invalid. expected: api://orders -> token issued for another API jwt issuer invalid. expected: https://auth.example.com -> staging token sent to production invalid algorithm -> RS256 token, HS256 verifier Decode the token (no secret needed) and compare alg, aud, iss and exp with your verify options.
4. It only fails in the browser
If curl works and the browser shows a CORS error, look for the OPTIONS preflight. Browsers never attach the token to it, so an auth middleware that runs first answers 401 and the browser reports a CORS failure. Put CORS before auth:
app.use(cors({ origin: "http://localhost:5173" })); app.use(requireAuth); 5. A redirect dropped the header
fetch (browsers and Node) removes Authorization when a redirect goes to another origin, so an http to https or a host redirect can silently strip it.
One trap that does not give a 401: an exp set with Date.now() (milliseconds) passes verification, because the token now expires tens of thousands of years from now.
The full checklist, with a tested Express middleware that returns a useful WWW-Authenticate header, is here: https://devpenh.com/guides/debug-401-unauthorized-jwt/
Disclosure: I run that site. Its JWT decoder (https://devpenh.com/tools/jwt-decoder/) decodes tokens in the browser, so you can inspect a real token from a failing request without sending it anywhere.
This post was written with AI assistance; the code and error messages were run and checked before publishing.