(TO THE GOD OF ABRAHAM, ISAAC AND JACOB. I DEDICATE THIS WORK TO YOU MAY YOU BLESS IT AND MAY IT BLESS THOSE YOU USE IT, MORESO MAY THEY KNOW YOU BY NAME, REPENT AND BE LED TO YOUR WILL AND KINGDOM.) Our Father who is in the heavens, let Your Name be set-apart,let Your reign come, let Your desire be done on earth as it is in heaven. Give us today our daily bread. And forgive us our debts, as we for- give our debtors. And do not lead us into trial, but deliver us from the wicked one because Yours is the reign and the power and the esteem, forever. Amen.
JWT decoded: a complete reference for inspecting tokens safely in 2026 | devformat.tools Blog
jwtsecurityauthentication

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.

By devformat.tools · · 6 min read

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 .:

  1. Header{"alg":"HS256","typ":"JWT"} after base64url decode.
  2. Payload — the claims.
  3. 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:

  1. Decode — base64url the segments, parse the JSON. No keys needed.
  2. 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 exp to 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

Try our free developer tools

51+ tools that run in your browser. No data sent anywhere.

Browse Tools