JWT decoded: a complete reference for inspecting tokens safely in 2026
Anatomy, claims, algorithms, decoding without verification, and the security pitfalls — alg:none, kid confusion, audience mismatch — covered in depth.
JWT decoded: a complete reference for inspecting tokens safely in 2026
JWTs are not complicated. They are three base64url strings joined by dots. The complications come from what people do with them.
This is the reference I'd hand to a developer joining an auth team. It covers the format, what's actually in a token, which algorithms to pick in 2026, how to decode safely, and the four security mistakes that keep showing up in incident postmortems.
Anatomy
A JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4iLCJpYXQiOjE3MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Three segments separated by .:
- Header —
{"alg":"HS256","typ":"JWT"}after base64url decode. - Payload — the claims.
- Signature — bytes that prove the first two haven't been altered.
Each segment is base64url-encoded (no padding, - and _ instead of + and /). That's it. There's no encryption — a JWT is signed, not secret. Anyone holding it can read it.
You can hand-decode in the shell:
echo 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' | base64 -d 2>/dev/null
# {"alg":"HS256","typ":"JWT"}
Or paste the whole token into the JWT Decoder — it splits, decodes, parses, and highlights expired tokens. Runs entirely client-side, which matters more than people realize (more on that at the end).
The claims you'll see
Per RFC 7519, registered claims are three-letter abbreviations:
| Claim | Meaning | Required? |
|---|---|---|
iss |
Issuer | recommended |
sub |
Subject (user ID) | recommended |
aud |
Audience (who this token is for) | recommended |
exp |
Expiration (unix seconds) | strongly recommended |
nbf |
Not-before time | optional |
iat |
Issued-at | recommended |
jti |
Unique token ID | for revocation lists |
Everything else is custom. OIDC adds email, email_verified, name, picture. Auth0, Okta, and Cognito add vendor-prefixed claims like https://yourdomain.com/roles.
A sane access token payload:
{
"iss": "https://auth.example.com",
"sub": "user_8c2b1f4a",
"aud": "api.example.com",
"exp": 1717512000,
"iat": 1717508400,
"scope": "read:profile write:profile",
"jti": "01HZQK9V8R3JFXAY9P2W6N7T1B"
}
The jti here is a ULID, which I prefer to a UUID — sortable by creation time, smaller to log.
Algorithm choices in 2026
There are three families:
HMAC (HS256, HS384, HS512) — symmetric. Same secret signs and verifies. Use for monolith apps where the signer and verifier are the same service.
RSA (RS256, RS384, RS512) — asymmetric. Private key signs, public key verifies. Use when verifiers shouldn't be able to forge tokens. 2048-bit minimum.
ECDSA (ES256, ES384, ES512) — same use case as RSA, smaller signatures.
EdDSA (Ed25519) — the modern default. RFC 8725bis recommends EdDSA for new deployments. Deterministic signatures (no nonce reuse risk), small keys, fast verification.
My pick in 2026: EdDSA for new public-key deployments, HS256 for internal services where you already have a shared secret distribution problem solved.
What about alg: none? RFC 8725 says libraries MUST NOT accept it unless the caller explicitly opts in. The only legitimate case is when transport-layer crypto already protects the token end-to-end. In practice: never.
Decoding without verifying (and when not to)
There are two things you can do with a JWT:
- Decode — base64url the segments, parse the JSON. No keys needed.
- Verify — recompute the signature with the correct key and compare.
You decode without verifying when:
- You're a client receiving a token from your own auth server and you want to read the
expto schedule a refresh. - You're a human in a debugger figuring out why auth is broken.
- You're displaying a token in a UI you control.
You verify when:
- You're a server deciding whether to grant access. Always. No exceptions.
In Node, [email protected]:
const jwt = require('jsonwebtoken');
// Decode only — no key needed, do NOT trust the result
const decoded = jwt.decode(token, { complete: true });
console.log(decoded.header.alg, decoded.payload.exp);
// Verify — actually trust the result
const verified = jwt.verify(token, publicKey, {
algorithms: ['EdDSA'], // pin the algorithm
issuer: 'https://auth.example.com',
audience: 'api.example.com'
});
The algorithms array is not optional. I'll explain why next.
The four security mistakes
1. alg: none confusion
In 2015, several libraries accepted a token with {"alg":"none"} and an empty signature as valid. The fix is to pass an algorithms allowlist to your verifier and never include none in it. Modern libraries default-reject none. Older ones don't. Pin your library version and audit.
2. Algorithm confusion (HS256 vs RS256)
The classic: your server expects RS256, holds a public RSA key, and uses it to verify. An attacker sends a token signed with HS256, using the public key as the HMAC secret. A naive verifier sees "HMAC with this key" and accepts.
Fix: pass algorithms: ['RS256'] to the verifier. Pin to one family.
3. kid header injection
The kid (key ID) header tells the verifier which key to use. If your code does loadKey(token.header.kid) and loadKey reads from disk or a database without sanitization, an attacker can supply kid: "../../../../dev/null" or kid: "' OR 1=1--".
Fix: treat kid as an opaque identifier. Look it up in a fixed map of known keys. Never use it as a file path or SQL value.
4. Audience mismatch
Service A issues tokens for Service B (aud: "service-b"). Service C also trusts the same issuer but doesn't check aud. A token meant for B is now accepted by C.
Fix: every verifier checks aud. Always. The audience option in your library is not decorative.
A minimal verifier checklist
[ ] Pin algorithm allowlist
[ ] Verify signature with correct key family
[ ] Check `exp` (not just trust the library — verify it's actually being checked)
[ ] Check `nbf` if present
[ ] Check `iss` against expected issuer
[ ] Check `aud` against this service
[ ] Reject tokens older than some sane bound, even if `exp` is far in the future
[ ] Have a revocation strategy (short expiry + refresh, or `jti` blocklist)
Print this. Tape it to the wall next to your auth code.
Try it
- JWT Decoder — paste a token, see header + claims + expiry, all in your browser
- Hash Generator — for HMAC secret rotation and key fingerprinting
- Base64 Encode — when you want to inspect a single segment by hand