You paste the token into a decoder, the header and payload look perfect, and the API still answers 401. The token is not lying to you. You are asking it the wrong question.
Decoding reverses Base64URL encoding: it needs no key and proves nothing. Verifying recomputes the signature with the signing secret or public key, then checks the claims against what the API expects. The decoder tells you what the token says; only the verifier tells you whether the API should believe it.
1. The expiry bug that looks like a valid token
JWT timestamps (exp, nbf, iat) are NumericDate values: whole seconds since 1 January 1970 UTC (RFC 7519). JavaScript's Date.now() returns milliseconds, so an issuer storing it directly as exp gets the lifetime wrong by a factor of a thousand — while the payload still looks normal.
The test is trivial: a 13-digit exp is milliseconds — divide by 1000. The same bug in nbf or iat makes a token look "not yet valid" or impossibly old.
2. Clock skew, not a broken token
A token issued by one server and verified by another lives or dies by their clocks: a verifier five minutes ahead expires a short-lived token before it arrives.
Sync both clocks with NTP and allow a 30–60 second leeway in the verifier. A token failing on only one server in a fleet is almost always a clock problem.
3. The signature is fine, but the token is for someone else
A valid signature only proves whoever holds the key signed the token. Two checks libraries do not do for you:
Audience (aud). The token must name this API; a staging token sent to production has the right signature and the wrong audience. aud can be a string or an array.
Issuer (iss). Compare it against the exact issuer URL you trust, character for character, including trailing slashes. Most such rejections are staging-versus-production mix-ups.
4. The key rotated and nobody told the verifier
The kid (key ID) header tells the verifier which key signed the token. After a rotation, tokens signed with the new key arrive while the verifier still holds the old one, or before the new key propagates — a sudden wave of "invalid signature" errors with no code change.
If the failure correlates with a deploy or rotation, check kid first, then which keys the verifier has loaded. The same symptom appears when issuer and verifier use different secrets.
5. The algorithm check you must never skip
Two algorithm mistakes are security holes, not just bugs. First, never accept alg: none: a token with no signature decodes perfectly and must be rejected outright. Second, pin the algorithm your verifier accepts — never let the token's header choose between an HMAC secret and a public key. An attacker who flips an RS256 token to HS256 can get a verifier using the public key as the HMAC secret to "verify" a token they signed themselves.
Automate the five checks
This zero-dependency script runs all five checks:
// jwt-debug.mjs — usage: node jwt-debug.mjs <token> [secret]
// Decodes a JWT, reports the checks APIs actually fail on, and
// optionally verifies an HS256 signature. No dependencies.
import { createHmac, timingSafeEqual } from 'node:crypto';
const [token, secret] = process.argv.slice(2);
if (!token) { console.error('usage: node jwt-debug.mjs <token> [secret]'); process.exit(1); }
function decodePart(part) {
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
}
const parts = token.replace(/^Bearer\s+/i, '').trim().split('.');
if (parts.length !== 3) { console.error(`parts: ${parts.length} (expected 3; 5 parts means a JWE, not a JWT)`); process.exit(1); }
const [header, payload] = parts.slice(0, 2).map(decodePart);
console.log('alg:', header.alg, '| typ:', header.typ ?? '(absent)', '| kid:', header.kid ?? '(absent)');
if (header.alg === 'none') console.log('WARNING: alg=none — no signature; a correct API must reject this');
const now = Math.floor(Date.now() / 1000);
for (const claim of ['exp', 'nbf', 'iat']) {
const v = payload[claim];
if (v === undefined) { console.log(`${claim}: (absent)`); continue; }
if (v > 1e12) console.log(`${claim}: ${v} — looks like MILLISECONDS, not seconds; divide by 1000`);
else console.log(`${claim}: ${v} = ${new Date(v * 1000).toISOString()} (${v <= now && claim === 'exp' ? 'EXPIRED' : v > now && claim === 'nbf' ? 'NOT YET VALID' : 'ok'})`);
}
console.log('iss:', payload.iss ?? '(absent)', '| aud:', JSON.stringify(payload.aud ?? null), '| sub:', payload.sub ?? '(absent)');
if (secret && header.alg === 'HS256') {
const expected = createHmac('sha256', secret).update(`${parts[0]}.${parts[1]}`).digest('base64url');
const ok = parts[2].length === expected.length && timingSafeEqual(Buffer.from(parts[2]), Buffer.from(expected));
console.log('HS256 signature:', ok ? 'VALID' : 'INVALID (wrong secret, rotated key, or edited token)');
}
Verified against a freshly generated HS256 token: correct secret reports VALID, a wrong secret INVALID, a millisecond exp gets flagged, and a Bearer prefix is stripped. Note it decodes only the first two parts — mapping over all three crashes trying to JSON-parse the signature, which is raw bytes, not JSON — and it uses timingSafeEqual, not ===.
Where to paste a token safely
One last rule: a live production token is a bearer credential, usable by anyone holding it until it expires, and a JWT is only encoded, not encrypted — anyone who gets it can read every claim without a key. Never put secrets in a payload; debug with a test token or an expired one.
For a quick read without a script, I use this free JWT decoder — it runs entirely in the browser, so the token never leaves my device, and converts exp, nbf, and iat to readable times with an expiry status. That client-side property is what to check in any decoder: if a tool asks for a signing secret, close the tab. Verification belongs in your API, with a maintained library, on every request.
The takeaway
"Decodes fine" is the beginning of the investigation, not the end. Convert the timestamps, check the clock, compare issuer and audience, confirm the key behind kid, and pin the algorithm — one of those five explains almost every 401 a well-formed token produces.
Top comments (0)