TL;DR: When JWT auth fails, decode the token and check these things in order: Is it expired? Does it have the right claims? Is it being sent correctly? Is the algorithm correct? You don't need a secret key to read the header and payload — they're just Base64.
Something's Wrong and You Don't Know Why
We've all been there. You just logged in — literally two seconds ago — and the API slaps you with a 401 Unauthorized. Or maybe the app lets a regular user access admin features. Or maybe everything works on your machine but breaks in staging.
Nine times out of ten, the answer is hiding inside the JWT. And the beautiful thing about JWTs is that you don't need a secret key to read them. The header and payload are just Base64-encoded JSON — anyone can decode them. Only the signature verification requires the key.
How to Decode a JWT By Hand
A JWT has three parts separated by dots. You can decode each part yourself:
const parts = token.split(".");
function decodeJWTPart(part) {
const base64 = part.replace(/-/g, "+").replace(/_/g, "/");
return JSON.parse(atob(base64));
}
const header = decodeJWTPart(parts[0]);
// { "alg": "HS256" }
const payload = decodeJWTPart(parts[1]);
// { "sub": "user_123", "role": "admin", "exp": 1774227600 }
From the command line:
# Decode the payload (the second part)
echo "YOUR_JWT" | cut -d. -f2 | base64 -D 2>/dev/null | jq .
Dev Joke: Debugging is like being the detective in a crime movie where you're also the murderer. With JWTs, at least you can read the victim's diary (the payload) without a warrant.
The JWT Debugging Checklist
When a JWT isn't working, go through these checks in order. The first one catches the problem about 60% of the time.
Check 1: Is It Expired?
This is the most common cause of 401 errors. Hands down. Look at the exp claim:
const exp = payload.exp;
const now = Math.floor(Date.now() / 1000);
console.log(`Expires: ${new Date(exp * 1000).toISOString()}`);
console.log(`Now: ${new Date(now * 1000).toISOString()}`);
console.log(`Expired: ${now > exp}`); // true = that's your problem
Watch out for these gotchas:
- Clock skew — If the server that created the token and the one verifying it have slightly different clocks, tokens near expiration might fail unexpectedly. Most JWT libraries have a "clockTolerance" setting (usually 30 seconds).
- Timezone confusion — The
expvalue is always UTC Unix seconds. If you're creating it with the wrong timezone, you might accidentally set tokens to expire at the wrong time.
Check 2: Does It Have the Right Claims?
Getting a 403 Forbidden? The token is valid but doesn't have the permissions needed:
// Your API expects: role = "admin"
// But the token has:
{ "sub": "user_123", "role": "viewer" } // Not enough permission!
// Or the claim name is different:
{ "roles": ["admin"] } // Array vs string, "roles" vs "role"
Mismatched claim names are sneakier than you'd think. One service uses "role" (singular string), another uses "roles" (plural array). Decode the token and compare exactly.
Check 3: Is the Token Being Sent Correctly?
Open your browser's DevTools, go to the Network tab, and check the actual request headers:
// Correct
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIi...
// Common mistakes
Authorization: eyJhbGciOiJIUzI1NiJ9... // Missing "Bearer " prefix
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9... // Double space after Bearer
Check 4: Is the Algorithm Right?
The server has to accept the algorithm specified in the token's header:
// Token header says RS256
{ "alg": "RS256", "typ": "JWT" }
// But the server only accepts HS256
jwt.verify(token, secret, { algorithms: ["HS256"] });
// Error: algorithm RS256 is not allowed
Check 5: Issuer or Audience Mismatch?
Some servers validate iss (issuer) and aud (audience). A token from staging won't work in production if the issuer is different:
// Token says:
{ "iss": "auth.staging.myapp.com" }
// Production expects:
// iss: "auth.myapp.com"
// Rejected even though the signature is valid!
Quick debug function: Paste this into your browser console to inspect any JWT instantly:
function inspectJWT(token) {
const [h, p] = token.split(".").slice(0, 2).map(part => {
const b64 = part.replace(/-/g, "+").replace(/_/g, "/");
return JSON.parse(decodeURIComponent(
atob(b64).split("").map(c =>
"%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)
).join("")
));
});
console.log("Header:", h);
console.log("Payload:", p);
if (p.exp) {
const expired = Date.now() > p.exp * 1000;
console.log(`Expires: ${new Date(p.exp * 1000).toISOString()} (${expired ? "EXPIRED" : "valid"})`);
}
}
Common Error Messages Decoded
"Token is malformed"
The token string itself is corrupted. Check for extra whitespace, newlines (common when copying from emails), or truncation (the full token didn't get copied).
"Signature verification failed"
The token was signed with a different key. This happens when the secret was rotated, or you're using a token from a different environment (staging vs. production).
"Token used before issued"
The nbf (not before) claim is in the future. Usually a clock sync issue between servers.
Security warning: Be careful where you paste JWTs! A production JWT is a live credential — anyone who has it can impersonate the user until it expires. Only use tools that decode entirely in the browser (client-side) with no server communication. Our tool below runs 100% in your browser.
Try It Yourself
Paste your JWT into our decoder to instantly see the header, payload, and expiration status. Everything runs in your browser — your token is never sent to any server.
Open JWT Decoder →