Published Updated
A JSON Web Token is three Base64URL segments joined by dots. Any of them can be read by anyone who holds the token, which is exactly what makes a decoder useful for debugging and exactly what makes decoding worthless as a security check. Verification is a separate operation that needs a key, an expected algorithm, and a clock.
Three segments, two of which are public
A signed JWT — formally a JWS Compact Serialization — has the shape header.payload.signature. The header and payload are JSON objects encoded with Base64URL: the URL-safe alphabet that replaces + with - and / with _, and drops the = padding. That encoding is not a cipher and carries no key. Any language with a Base64 decoder can turn the first two segments back into readable JSON in one line.
The third segment is the only part that carries a security property, and only one: it proves that whoever produced the token held the signing key at the time, and that the first two segments have not changed since. It says nothing about whether the token has expired, whether it was meant for your service, or whether the user it names still exists.
What the header actually controls
The header carries alg, which names the signing algorithm, and usually typ, which is conventionally the string JWT. Two more parameters matter in practice. kid is a key identifier that lets an issuer rotate keys without breaking tokens in flight; your verifier looks up the key by kid and must treat the value as untrusted input, because it arrives from the caller. jku and jwk are, respectively, a URL pointing at a key set and an inline public key.
The critical mental model is that the header is attacker-controlled data that describes how to check attacker-controlled data. Every dangerous JWT failure mode in the last decade follows from a library that read the header and did what it said. A verifier that fetches a key from the jku URL in the token, or trusts the jwk embedded in the token, is asking the caller to supply the key that will validate the caller's own signature.
The safe configuration is boring: the application decides the algorithm and the key source before it ever looks at the token. The header's alg is compared against that expectation and the token is rejected on mismatch, rather than being used to select the verification routine. jku and jwk are disabled or restricted to an allowlist of issuer-controlled origins.
Registered claims and how they are meant to be enforced
RFC 7519 defines seven registered claims. All of them are optional, none of them are enforced by the token format, and a decoder that displays them has done nothing to check them. Enforcement is your verifier's job, and each claim implies a specific comparison.
The time-based claims are where most production bugs live. exp and nbf are compared against the current time with a small skew allowance — sixty seconds is a common choice, and anything much larger starts to defeat the point of short-lived tokens. iat is informational and should not be used as an expiry substitute. Note that all three are NumericDate values: seconds since the Unix epoch, not milliseconds. Passing a JavaScript Date.now() value straight into exp produces a token that expires in the year 56000 and will be silently accepted by a correct verifier.
The third column is the check your server must perform. If it is not in your code, the claim is decoration.
| Claim | Meaning | What the verifier must do |
|---|---|---|
| iss | Issuer — who minted the token | Compare against the exact issuer string you trust; use it to select the key set. |
| sub | Subject — who the token is about | Treat as an opaque identifier. Unique only within an issuer, so key your records on (iss, sub). |
| aud | Audience — who the token is for | Reject unless your own service identifier is present. May be a string or an array. |
| exp | Expiration time (NumericDate, seconds) | Reject if now is at or past exp, allowing a small clock skew. |
| nbf | Not before (NumericDate, seconds) | Reject if now is before nbf. Frequently omitted, which is fine. |
| iat | Issued at (NumericDate, seconds) | Optional freshness check — reject tokens older than a policy maximum. Never a substitute for exp. |
| jti | JWT ID — unique token identifier | Only useful with server-side state: a replay cache or a revocation list keyed on jti. |
Worked example: decoding a token by hand
The token below is the long-standing public demonstration token used across JWT documentation. It is signed with HS256 using the well-known non-secret string your-256-bit-secret, so it is safe to reproduce in full. Split it on dots, Base64URL-decode the first two segments, and you have the JSON.
Notice what the payload lacks. There is no aud and no exp, so a verifier configured for this token would have no audience to check against and no expiry to enforce — it would accept the token forever. A token is only as strong as the claims your verifier insists upon.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Segment 1 (header), Base64URL-decoded:
{"alg":"HS256","typ":"JWT"}
Segment 2 (payload), Base64URL-decoded:
{"sub":"1234567890","name":"John Doe","iat":1516239022}
iat 1516239022 = 2018-01-18T01:30:22Z
no exp -> never expires
no aud -> no audience restriction
no iss -> no way to pick a key set by issuer
Segment 3 (signature) is raw bytes, not text. It is
HMAC-SHA256(secret, segment1 + "." + segment2), Base64URL-encoded.
Decoding it yields 32 bytes of digest, which tell you nothing on
their own — you can only recompute and compare.alg: none and algorithm confusion
The first failure mode is the unsecured JWT. RFC 7519 permits alg to be the string none, in which case the signature segment is empty and the token is by definition unverified. This exists for cases where integrity is guaranteed by a surrounding layer. The danger is a verifier that reads alg from the header and dispatches accordingly: handed a token whose header says none, it takes the no-signature path and returns success. Every maintained library now refuses none unless you opt in explicitly, but the opt-in still exists and old pinned versions are still deployed.
The second is algorithm confusion, and it applies whenever a service verifies asymmetric tokens. RS256 verification takes a public key; HS256 verification takes a shared secret. A library with a single verify(token, key) entry point that picks the algorithm from the header will, if handed a token whose header says HS256, treat the RSA public key bytes as an HMAC secret. Since the public key is public, the token's signature can be produced by anyone. The defect is not in RSA or in HMAC; it is in letting the token choose which one is used.
The defence for both is identical and should be in your code today: pass the expected algorithm explicitly to the verification call, as an allowlist of one or two values, and load the key from your own configuration rather than from anything in the token. In most libraries this is a named parameter — algorithms: ['RS256'] or its equivalent. Anything that verifies without being told which algorithm to expect is misconfigured, however recent the library.
Where tokens should live in a browser
No storage location is safe against everything; each option trades one attack class for another, and you choose based on which one you can actually mitigate.
localStorage and sessionStorage are readable by any JavaScript running on the origin. That includes every third-party script, every analytics snippet, and every dependency in your bundle. If cross-site scripting is possible anywhere on the origin, the token is gone. In exchange, it is immune to cross-site request forgery, because nothing attaches it automatically.
A cookie marked HttpOnly, Secure and SameSite is not readable from JavaScript, so an XSS payload cannot exfiltrate it — though it can still make authenticated requests from the victim's browser, which is a smaller but real loss. The cost is that cookies are attached automatically, so you now need CSRF protection: SameSite=Lax or Strict covers most cases, with an anti-CSRF token for the rest.
The pattern that has held up best is a short-lived access token kept only in a JavaScript variable — never persisted — paired with a long-lived refresh token in an HttpOnly, Secure, SameSite cookie scoped to the token endpoint. A page reload loses the access token and silently gets a new one. It costs a round trip and buys a much smaller exposure window. Whatever you choose, keep access-token lifetimes short, because a bearer token is a password until it expires, and you have no way to un-issue it without server-side state.
What to paste into a decoder, and what never to
Our JWT tool decodes in the page, in your browser, and does not transmit the token. That is a real property and it is worth having. It is also not the same thing as safe, and we would rather say so than let a privacy badge do the reasoning for you.
A signing key is a completely different object. A private RSA or ECDSA key, or an HMAC secret, does not expire, is not scoped to one user, and mints tokens for everyone. It should never be pasted into a web page — ours, anyone's, local processing or not. The moment it is in a text field it is in the page's memory, in your clipboard history, possibly in an extension's DOM observer, and one autocomplete away from a form submission somewhere else. Verify signatures on a machine you control, with a command-line tool or your own test suite.
What to remember
- Decoding a JWT requires no key and proves nothing; only signature verification with a key you configured proves anything.
- Pass an explicit algorithm allowlist to every verification call and load keys from your own configuration, never from the token's alg, jku or jwk header.
- Enforce exp, aud and iss in code — a decoder that displays them has checked nothing, and exp is in seconds, not milliseconds.
- Choose token storage by which attack you can mitigate: localStorage loses to XSS, cookies need CSRF defences, in-memory plus a refresh cookie is usually the best trade.
- Never paste a signing key or private key into any web tool, including ours; decode tokens freely, verify signatures on a machine you control.