How to Decode JWT Tokens Safely
Inspect headers and claims without paste-leaking the token, and understand what decoding can and cannot tell you.
Last updated
Decoding a JWT is just Base64 decoding two strings. The risky part is where you paste the token while you do it. A token in the wrong text box can be logged, emailed, or stored in a third-party service for as long as that service keeps records.
1. Understand the three parts
A JWT is three Base64-URL-encoded segments separated by dots: header, payload, and signature. The first two are JSON. The third is a cryptographic signature over the first two.
// header.payload.signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkphbmUgRG9lIn0.
AxwiNyXkqkz4cRFnK8aP_5wKR3...2. Decode locally, not in a service that logs paste data
You can decode in a few lines of JavaScript:
function decodeJwt(token) {
const [header, payload] = token.split('.').slice(0, 2);
return {
header: JSON.parse(atob(header.replace(/-/g, '+').replace(/_/g, '/'))),
payload: JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/'))),
};
}The character substitutions convert from Base64-URL to standard Base64 so atob can handle it.
3. Decoding is not validation
Decoding tells you what the token claims. It does not prove the token is real. Validation requires the signing secret (HS algorithms) or the issuer's public key (RS, ES algorithms). Without that, anyone could mint a token with any claims they want, and your decoder would happily print them.
4. Watch the expiration
Always read exp when you debug authentication issues. A token that decodes cleanly but is hours past exp still gets rejected by the server.
5. What the registered claims actually mean
Most of a payload is application-specific, but seven claim names are reserved by RFC 7519 and mean the same thing everywhere. Knowing them turns a decoded blob into something you can debug.
| Claim | Meaning | What goes wrong |
|---|---|---|
iss | Issuer — who minted the token. | Pointing at the wrong environment's issuer; staging tokens sent to production. |
sub | Subject — who or what the token is about, usually a user ID. | Assumed to be an email or username when it is an opaque ID. |
aud | Audience — the recipient the token is intended for. | Not validated, so a token minted for one service is accepted by another. |
exp | Expiry, as seconds since the Unix epoch. | Unit confusion in either direction: writing milliseconds into exp makes a token valid for ~50,000 years, while comparing a seconds value against a millisecond clock makes every token look expired. |
nbf | Not before — the token is invalid until this time. | Clock skew between services rejects a token that was just issued. |
iat | Issued at. | Used as an expiry check, which it is not. |
jti | Unique token ID, used for replay prevention and revocation lists. | Absent, so individual tokens cannot be revoked. |
All three time claims are NumericDate values: seconds, not milliseconds. Multiplying by 1000 before feeding them to new Date() in JavaScript is the single most common JWT debugging mistake.
6. Base64-URL is not Base64
JWT segments use Base64-URL encoding, which swaps two characters so the token is safe in a URL, and drops the trailing = padding. Feeding that straight into a standard Base64 decoder either throws or returns mangled bytes for a subset of tokens — which is why a naive decoder appears to work until it suddenly does not.
function decodeSegment(segment) {
// Base64-URL -> Base64, then restore the padding atob() requires.
let b64 = segment.replace(/-/g, '+').replace(/_/g, '/');
while (b64.length % 4) b64 += '=';
// Handle multi-byte UTF-8 correctly; atob alone mangles non-ASCII claims.
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
return JSON.parse(new TextDecoder().decode(bytes));
}
The TextDecoder step matters as much as the padding. atob returns a binary string, so a name with an accent or any non-ASCII claim value decodes to mojibake unless the bytes are reinterpreted as UTF-8.
7. The failure modes worth knowing
Algorithm confusion. The alg header is chosen by whoever produced the token, not by you. Historically, libraries that trusted it could be handed {"alg":"none"} and would accept an unsigned token, or handed HS256 where RS256 was expected so the public key was used as an HMAC secret. Always pin the expected algorithm on the verifying side rather than reading it from the header.
Nothing in a JWT is secret. The payload is encoded, not encrypted — anyone holding the token can read every claim. Never put anything in it you would not put in a URL. Encryption is a separate specification (JWE); a plain JWT does not provide it.
Decoding proves nothing about validity. A decoder will happily print claims from a token that was fabricated wholesale. Only signature verification against the issuer's secret or public key establishes that the claims are genuine, and only an exp check establishes that they are current.
When to switch to the browser tool
For one-off inspections, use JWT Decoder. It runs entirely in the browser, so the token is not transmitted anywhere. Pair it with Timestamp Converter to read iat, exp, and nbf as readable dates.
For more utility entry points, browse Developer Utility Tools.
Frequently asked questions
Is it safe to paste a JWT into an online decoder?
Only if the decoding happens in your browser. A server-side decoder receives the token, and it may be logged or retained. Treat any token you paste into a service that transmits it as compromised and rotate it. This decoder runs entirely client-side.
Does decoding a JWT verify it?
No. Decoding is Base64 and shows what the token claims. Verification requires the signing secret for HS algorithms or the issuer's public key for RS and ES algorithms. Without that, anyone can mint a token with any claims and a decoder will print them as though they were real.
Why does my token decode but the API still rejects it?
Usually exp has passed, nbf is in the future because of clock skew, or aud does not match the service you are calling. Check those three claims first — remembering they are in seconds, not milliseconds.
Can I edit a JWT payload?
You can change the encoded text, but the signature will no longer match and any correctly configured server will reject it. Editing a payload and having it accepted means the server is not verifying signatures, which is a serious vulnerability.
Why does my decoder fail on some tokens but not others?
Almost always Base64-URL padding. JWT segments strip the trailing = characters, and whether that matters depends on the segment length — so a naive decoder works for some tokens and throws for others. Pad the string to a multiple of four before decoding.