What's Inside a JWT? Decode It, Verify It, and Avoid the alg:none Trap
You paste a token mid-incident to read one claim — is it expired? which alg? whose sub? — and a wall of eyJ... stares back. A JWT looks encrypted. It isn't. It's three base64url-encoded chunks anyone can read, and understanding that distinction is the difference between reading a token and trusting one.
The three parts
A JSON Web Token (JWT, defined by RFC 7519) is three base64url strings joined by dots. The compact serialization comes from JWS, RFC 7515:
base64url(header) "." base64url(payload) "." base64url(signature)
Split on the two dots and you have:
- Header — JSON metadata describing the token. Which signature algorithm (
alg) and token type (typ). - Payload — the JSON claims: who the token is about, when it expires, what it's allowed to do.
- Signature — a MAC or digital signature over
header.payload, the only part that proves the token wasn't forged or tampered with.
Only the third part involves a key. The first two are plain, reversible encoding.
base64url, not base64
JWT uses base64url (RFC 4648 §5), a URL-safe variant: + becomes -, / becomes _, and the trailing = padding is dropped. That's why a JWT survives being placed in a URL or an Authorization header without escaping. If you feed a JWT segment to a standard base64 decoder that expects padding, it may choke — handle the missing = and the -/_ alphabet.
Reading the payload: the standard claims
The payload carries registered claims (from the IANA JWT registry) plus whatever custom claims your app adds. The ones you'll see constantly:
| Claim | Name | Meaning |
|---|---|---|
iss | Issuer | Who minted the token |
sub | Subject | Who/what the token is about (a user id) |
aud | Audience | Who the token is intended for |
exp | Expiration | Token invalid at or after this time |
nbf | Not Before | Token invalid before this time |
iat | Issued At | When the token was created |
jti | JWT ID | Unique identifier for the token |
The one that trips people up: exp, nbf, and iat are NumericDate values — Unix epoch seconds, not milliseconds. Paste an exp into a converter that assumes milliseconds and you'll land in 1970.
Worked example
Take a payload that decodes to:
{ "sub": "1234567890", "name": "Jane", "iat": 1516239022, "exp": 1516242622 }
iat 1516239022 is 2018-01-18 01:30:22 UTC. exp 1516242622 is exactly one hour later, 02:30:22 UTC. So this token had a 60-minute lifetime. If the current time is past exp, the token is expired — regardless of whether its signature is valid. Expiry and authenticity are two separate checks.
HS256 vs RS256: symmetric vs asymmetric
The header's alg names the signing algorithm (RFC 7518 defines them). The two you'll meet most:
- HS256 — HMAC with SHA-256. Symmetric: the same shared secret both signs and verifies. Simple, but everyone who can verify can also forge, so the secret must stay server-side.
- RS256 — RSA signature with SHA-256. Asymmetric: a private key signs, and a public key verifies. The verifier only needs the public key (often published as a JWKS), which can't be used to mint tokens.
ES256 (ECDSA on the P-256 curve) is the elliptic-curve equivalent of RS256 — asymmetric, smaller keys. As a rule: HS256 when one service both issues and checks tokens; RS256/ES256 when an identity provider issues and many independent services verify.
Decoding is not verifying
Here's the part that matters most. Decoding a JWT only base64url-decodes it. The payload is plaintext to anyone who holds the token — there is no confidentiality. Never put a password, a card number, or any secret in a JWT payload expecting it to be hidden.
Trust comes only from verifying the signature with the correct key:
HS256: HMAC-SHA256(secret, header"."payload) == signature // needs the shared secret
RS256: RSA-PKCS1-v1_5 verify with the public key // needs the public key / JWKS
ES256: ECDSA P-256 verify with the public key
A decoder that shows you the claims has proven nothing about who issued the token. That's fine for debugging — reading exp or scope during an incident — but a server accepting a token for auth must verify the signature, check exp/nbf, and confirm iss/aud match what it expects.
The alg:none trap
Because the header carries alg, an early and now-classic vulnerability is alg: none. The attacker takes a valid token, edits the payload (say, "role":"admin"), sets the header to {"alg":"none"}, drops the signature entirely, and submits it. A naive library that "verifies using whatever algorithm the header says" sees none, performs no verification, and accepts the forged token.
The rule: an unsigned token must never be treated as verified. Reject alg: none outright.
The HS/RS key-confusion attack
The subtler cousin exploits the same "trust the header's alg" mistake. A service verifies RS256 tokens with an RSA public key. An attacker takes a token, flips the header from RS256 to HS256, and signs it using the RSA public key as the HMAC secret. If the verifier reads alg: HS256 and dutifully runs HMAC with its configured RSA public key as the secret — the signature matches. And that public key is, by definition, public. Anyone can forge a "valid" token.
Both attacks share one root cause: letting the token decide how it gets verified. The fix is the same for both — pin the expected algorithm to the key type on the server side. If you expect RS256, verify only as RS256; never let the incoming header choose the algorithm, and never accept none.
Debug safely, in your browser
When you need to read a token, do it somewhere the token never leaves your machine — pasting a production JWT into a random web form is exactly the kind of leak these tokens are designed to prevent. Our JWT decoder decodes the header and payload, computes a live expired / valid / not-yet-valid badge from exp and nbf, and can verify HS256/RS256/ES256 signatures entirely client-side using the browser's Web Crypto API — no server round-trip.
Read the claims to debug. Verify the signature to trust. Never confuse the two.
Sources: RFC 7519 (JWT), RFC 7515 (JWS compact serialization), and RFC 7518 (JWA / signature algorithms).