You're staring at a string that looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Something in your auth flow is broken. The API is returning 401s, the user session isn't persisting, or someone handed you a token and asked why it isn't working. You need to see what's inside it — right now, without installing a library or setting up a project.
This guide shows you how to decode any JWT instantly in the browser, what every part of the token means, and how to diagnose the most common JWT errors from the decoded contents alone.
The anatomy of a JWT
A JWT is three Base64URL-encoded strings separated by dots. That's it. There's no encryption happening at the decoding stage — the payload is readable by anyone who has the token. The signature at the end is what makes tampering detectable, but decoding the contents requires no secret key.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← header
.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ ← payload
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← signature
The header
The header tells you which algorithm was used to sign the token. Decode the first segment and you'll see something like:
{
"alg": "HS256",
"typ": "JWT"
}
Common algorithm values: HS256 (HMAC-SHA256, symmetric — same secret signs and verifies), RS256 (RSA-SHA256, asymmetric — private key signs, public key verifies), ES256 (ECDSA, also asymmetric). If you see "alg": "none", that's a serious red flag — it means the token has no signature and should be rejected by any properly configured server.
The payload
The payload is the interesting part. It contains claims — key-value pairs that assert things about the user or session. Decoded, it looks like:
{
"sub": "1234567890",
"name": "John Doe",
"email": "john@example.com",
"role": "admin",
"iat": 1516239022,
"exp": 1516242622,
"iss": "https://auth.example.com",
"aud": "https://api.example.com"
}
The signature
The signature is a hash of the header and payload, signed with the server's secret or private key. You cannot verify it without that key — but you don't need to verify it to read the payload. Decoding and verifying are two separate operations.
Standard claims and what they mean
The JWT spec defines a set of registered claim names. Not all tokens use all of them, but these are the ones you'll encounter most:
sub (Subject) — the principal this token is about, typically a user ID. This is what your backend uses to identify who the token belongs to.
iss (Issuer) — who created and signed the token. Often a domain or auth service URL. Your server should validate that this matches the expected issuer.
aud (Audience) — who the token is intended for. A token issued for api.example.com should be rejected by other-api.example.com. Mismatched audience is a common source of 401 errors.
exp (Expiration) — a Unix timestamp (seconds since epoch) after which the token must be rejected. This is the most common reason for 401 errors in production — tokens expire and clients don't handle the refresh correctly.
iat (Issued At) — when the token was created, as a Unix timestamp. Useful for calculating how old the token is.
nbf (Not Before) — the token must be rejected before this time. Less common, but used when tokens are issued in advance of when they should become valid.
jti (JWT ID) — a unique identifier for this specific token, used to prevent replay attacks. If your server maintains a blocklist of invalidated tokens, this is the field used to look them up.
How to decode a JWT in the browser right now
Paste the token into DevCrate's JWT Debugger and you'll see the header and payload decoded instantly — no account, no install, nothing sent to a server. The tool also calculates the human-readable expiry time so you don't have to convert Unix timestamps manually.
If you prefer to do it in code, decoding is straightforward:
// JavaScript — decode without verification
function decodeJwt(token) {
const parts = token.split('.');
if (parts.length !== 3) throw new Error('Invalid JWT format');
const decode = (str) => {
// Base64URL → Base64 → decode
const base64 = str.replace(/-/g, '+').replace(/_/g, '/');
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, '=');
return JSON.parse(atob(padded));
};
return {
header: decode(parts[0]),
payload: decode(parts[1]),
// Note: signature is not decoded here — it's binary data
};
}
const { header, payload } = decodeJwt(token);
console.log(payload.exp); // expiry timestamp
console.log(payload.sub); // user ID
Note what this does and doesn't do: it reads the payload without verifying the signature. This is fine for debugging — you're just reading what's in the token. Never use this in place of server-side verification for actual auth decisions.
Diagnosing common JWT errors from the decoded payload
401 Unauthorized — "Token expired"
The most common JWT error by a wide margin. Check the exp claim. Convert it to a human-readable date:
new Date(payload.exp * 1000).toLocaleString()
// → "4/30/2026, 11:42:00 PM"
If that date is in the past, the token is expired. The fix is on the client — it needs to refresh the token before it expires, or request a new one after receiving a 401. If tokens are expiring faster than expected, check the iat claim to see when the token was issued and calculate the actual lifetime. A token issued with a 15-minute lifetime that's expiring in 2 minutes isn't broken — it's working correctly and the client needs to handle refresh.
401 Unauthorized — "Invalid audience"
Check the aud claim. It needs to exactly match what your server expects. Common mismatches:
- Token issued for
https://api.example.com, server expectsapi.example.com(no scheme) - Token issued for staging, hitting production
- Token issued for one service being used against a different service
audis an array and the server is checking for a single string match
401 Unauthorized — "Invalid issuer"
Check the iss claim. Same class of problem as audience mismatch — the issuer in the token doesn't match what the server is configured to trust. Common in multi-environment setups where a dev token gets used against a prod server, or when an auth provider URL changes.
403 Forbidden — "Insufficient permissions"
The token is valid but the user doesn't have access to the requested resource. Look for custom claims in the payload — things like role, permissions, scope, or groups. These are application-specific and not part of the JWT spec, but they're where most authorization logic lives.
{
"sub": "user_123",
"role": "viewer", // not "admin"
"permissions": ["read"], // no "write"
"scope": "openid profile" // missing required scope
}
If the claims look correct and the 403 is still happening, the problem is server-side — either the permission check logic or the role/permission data in your database doesn't match what's in the token.
Token present but user isn't authenticated
Check whether the token is being sent correctly. The most common pattern is Bearer authentication:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Common mistakes: the word "Bearer" is misspelled or missing entirely, there's a double space between Bearer and the token, or the token has been URL-encoded somewhere in transit (look for %2B or %3D in the token string — a sign it's been encoded when it shouldn't be).
The token looks valid but the server rejects it
If the header and payload decode cleanly and all the claims look correct, the problem is the signature. This means either:
- The token was signed with a different secret than the server is using to verify
- The token was modified after signing (even a single character change invalidates the signature)
- The server is using the wrong algorithm —
HS256vsRS256is a common mismatch when switching auth libraries - There's a clock skew issue — the server's system time is off enough that
expandnbfchecks fail even for tokens that should be valid
Reading tokens from real-world locations
Tokens show up in several places during debugging. Here's how to get to them quickly:
From browser storage: Open DevTools → Application → Local Storage or Session Storage → look for keys like token, access_token, auth_token, or jwt.
From browser cookies: DevTools → Application → Cookies → look for session, auth, or anything with a value that starts with ey.
From a network request: DevTools → Network → click a failing request → Headers tab → find the Authorization header → copy the value after "Bearer ".
From a curl command:
curl -s https://api.example.com/me \
-H "Authorization: Bearer $TOKEN" | jq .
# Or grab the token from a login response:
TOKEN=$(curl -s -X POST https://api.example.com/login \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"secret"}' \
| jq -r '.access_token')
echo $TOKEN
One thing to never do with a JWT debugger
Never paste a production JWT containing real user data into a third-party website. Most online JWT tools send the token to their servers — your token contains claims about real users, and depending on your setup, a valid token can be used to make authenticated API calls.
DevCrate's JWT Debugger decodes entirely in the browser using JavaScript — the token never leaves your machine. You can verify this by opening DevTools → Network while using the tool and confirming no requests are made when you paste a token.