How to Decode a JWT (and Why Decoding Isn't Verifying)
A JWT is three base64url-encoded strings joined by dots. Anyone can decode the first two parts and read the contents — that's by design. The third part is the signature, and verifying it is the only thing that tells you whether to trust what you read. This is the most common JWT misconception, and it has shipped real auth bugs.
The shape of a JWT
A JSON Web Token (RFC 7519) is three base64url-encoded segments separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Split on the dots and base64url-decode each segment, and you get:
- Header:
{"alg":"HS256","typ":"JWT"} - Payload:
{"sub":"1234567890","name":"John Doe","iat":1516239022} - Signature: 32 bytes of HMAC-SHA-256 (or whatever
algwas)
That's it. The first two parts are JSON. The third is binary signature data, base64url-encoded for transport. The whole token is URL-safe text you can paste into headers, query strings, or cookies.
What "base64url" means
Base64url is base64 with two substitutions and padding stripped:
+becomes-/becomes_- Trailing
=padding is removed
That makes the encoded string safe to drop into a URL without further escaping. To decode it manually, restore the padding ('='.repeat((4 - input.length % 4) % 4)) and substitute back, or use a base64url-aware decoder. Our base64 tool handles both variants.
Decoding ≠ verifying
This is the most important section. The header and payload are not encrypted. They are not signed-into-the-bytes. They are public, plaintext JSON behind a thin base64 wrapper. Anyone with the token can read everything in the payload.
The third segment, the signature, is what proves the token wasn't forged. To verify it:
- Take the bytes
header_b64 + "." + payload_b64(the literal bytes of the first two segments joined by a dot). - Compute the signature of those bytes using the algorithm specified in
header.algand the issuer's secret (HS256) or private key (RS256/ES256). - Compare your computed signature to the third segment.
If they match, the token wasn't tampered with after the issuer signed it. If they don't, somebody changed the header or payload — reject the token.
Verification requires the secret (HS*) or the public key (RS*/ES*). If you don't have either, you cannot verify. You can still decode and read the payload, but reading is not authentication.
Why this matters in practice
Real bugs have shipped because developers confused decoding with verifying. Two common patterns:
- Trusting the payload after decoding it. Server-side code that decodes the JWT, reads
userIdfrom the payload, and uses it for authorization — without verifying the signature. An attacker can craft any payload they want and the server will believe it. - Accepting
alg: "none". A historic bug in many JWT libraries: the issuer specifiesalg: "none", the library skips signature verification entirely, and any payload is accepted. Modern libraries refusenoneby default, but check yours.
Our JWT decoder deliberately does not verify signatures — it's a debugging tool. Real verification belongs on your auth server with the actual key material.
Standard payload claims
RFC 7519 defines several "registered claim names" that have agreed-upon meanings:
| Claim | Name | Type | Meaning |
|---|---|---|---|
iss | Issuer | string | Who issued this token (your auth server's identifier) |
sub | Subject | string | Who the token is about (typically a user ID) |
aud | Audience | string or array | Who the token is intended for (your API's identifier) |
exp | Expiration | number (Unix seconds) | Token is invalid after this time |
nbf | Not Before | number (Unix seconds) | Token is invalid before this time |
iat | Issued At | number (Unix seconds) | When the token was issued |
jti | JWT ID | string | Unique token identifier (for revocation) |
You'll also see custom claims like email, roles, scope, etc. These are not standard, but parties that share a JWT format will agree on their semantics.
The exp claim is in Unix seconds (not milliseconds, despite some libraries getting this wrong). To check if it's in the past, compare against Math.floor(Date.now() / 1000). To convert to a human date, paste it into our Unix timestamp converter.
What's safe to put in a JWT
Because the payload is readable by anyone with the token, never put secrets in it. No passwords, no API keys, no PII you don't want exposed. The payload is for identity and authorization signals — user ID, role, scope, expiration. The opacity comes from the signature, not from any encryption.
If you need encrypted payload, use JWE (JSON Web Encryption, RFC 7516) instead of JWT. JWE wraps a JWT in an encryption layer; only parties with the decryption key can read the payload. This is rare in practice — most use cases are fine with signed-but-readable JWT.
Common debugging workflow
You're debugging an auth issue. The flow:
- Copy the JWT from the failing request (browser DevTools, server log, etc.).
- Paste into our JWT decoder. Read the payload.
- Check the standard claims: is
expin the past? Doesaudmatch what your API expects? Doesissmatch your auth server? - Check custom claims: does the user actually have the role/scope they need?
- If the payload looks right but verification still fails, check the algorithm in the header against what your verifier expects. RS256 vs HS256 mismatches are common.
Decoding tells you what the token says. Verifying tells you whether to trust it. Both have their place; don't confuse them.