JWT tokens explained for developers
Paste almost any JWT into a decoder and its contents appear instantly: user IDs, roles, expiry times, occasionally email addresses. That transparency surprises people who assumed tokens were encrypted. They are not. A JWT is simply three chunks of text joined by dots, readable by anyone who holds it — including whoever is watching your screen during a support call. That is by design, not accident. The security of a JWT never depended on hiding its contents; it depends entirely on the signature. Understanding exactly where that boundary falls is what separates developers who debug authentication confidently from those who paste production tokens into random websites and hope for the best.
Three parts, separated by dots
A JWT follows an unforgiving layout: header.payload.signature. The header is a small JSON object describing how the token was signed — typically {"alg":"HS256","typ":"JWT"}, meaning HMAC-SHA256 keyed with a secret shared between issuer and verifier. The payload carries the claims: statements about the subject, such as sub naming whom the token describes, iss identifying who issued it, and exp marking when validity ends. The third chunk, the signature, is computed over the exact bytes of the first two parts using a key only the legitimate issuer holds. Change a single character anywhere in the header or payload — even one letter inside a username — and the recomputed signature will not match. That asymmetry is the entire security model: contents are public by design, integrity is enforced cryptographically.
Base64URL, not Base64
The segments look familiar because they nearly are Base64 — but JWTs use Base64URL, a URL-safe variant built for life inside HTTP headers and query strings. Standard Base64 can emit + and / characters, both of which carry awkward meanings inside URLs, so Base64URL substitutes - and _ in their places. Trailing = padding is dropped entirely, since it serves no purpose in this context and upsets certain strict header parsers. You can spot the encoding at work by decoding any segment back to readable JSON; run it through an encoder again and you may notice the swapped characters in the output. The difference sounds pedantic until a generic Base64 decoder rejects a perfectly valid segment because it happened to contain a dash, or silently mangles values containing underscores. Good developer tools auto-detect the alphabet; hand-written scripts frequently do not. If your decoding script succeeds on some tokens and fails on others, switch to a URL-safe decoder and tolerate missing padding before concluding the token itself is malformed.
Decoding is not verifying — say it twice
Here is the most common misconception in JWT handling, and it deserves repeating twice: decoding a token does not verify it. Anyone who holds the token can read every claim; only the holder of the signing secret — or the public key, in asymmetric schemes such as RS256 — can produce a token whose signature checks out against those claims. Your browser console, your log files, your curl pipeline all decode tokens happily; none of them has verified anything. An attacker who wants admin privileges simply writes them into a payload and attaches either no signature or one made with their own key. Until your backend recomputes the signature with the trusted key and compares, the payload is unauthenticated text — useful for inspection, worthless for authorization decisions.
The registered claims worth knowing
The JWT specification reserves a handful of claim names with precise meanings, summarized below.
| Claim | Stands for | Meaning |
|---|---|---|
| iss | Issuer | Who created and signed the token |
| sub | Subject | Whom the token describes — usually a user ID |
| aud | Audience | Which service or client the token is intended for |
| exp | Expiration time | Epoch seconds after which the token must be rejected |
| iat | Issued at | Epoch seconds when the token was created |
| nbf | Not before | Epoch seconds before which the token is not yet valid |
Debugging expiry with epoch timestamps
When a user reports being logged out at random moments, convert the time claims before touching anything else. exp, iat and nbf hold Unix epoch values: whole seconds elapsed since midnight UTC on 1 January 1970 — not milliseconds, not ISO strings. A classic bug compares Date.now() in JavaScript, which returns milliseconds, directly against exp and concludes that perfectly valid tokens expired years ago. A value such as 1755676800 decodes to 08:00 UTC on 20 August 2025; paste it into any epoch converter to see it in your own timezone instantly. Then compare against the server clock rather than your laptop, because skew between machines produces intermittent failures that mimic flaky networks. Most verification libraries accept a small leeway window for exactly this reason. One further subtlety: tokens minted by different services in the same ecosystem may carry clocks minutes apart, so when a fresh token is rejected immediately, compare its iat against the verifier's clock — a negative age means someone's system time drifted into the future.
The alg=none history lesson
In the mid-2010s, researchers demonstrated that several popular JWT libraries accepted tokens whose header declared alg none — meaning unsigned. An attacker took a legitimate token, rewrote its claims to grant elevated privileges, switched the algorithm to none, stripped the signature entirely, and the library returned the forged payload as verified truth. Closely related confusion attacks swapped RS256 tokens for HS256 so that a public key ended up abused as an HMAC secret. Modern libraries reject these patterns out of the box, but the lesson permanently reshaped best practice: never allow the token itself to dictate how it gets verified. Pin the expected algorithm in server-side configuration and reject everything else before examining the signature at all.
Where the token travels — and lives
On every authenticated request the client presents the token in the Authorization header: the word Bearer, a space, then the full token string. Where the client stores that token between requests is a genuine engineering tradeoff with no perfect answer. localStorage is convenient and survives browser restarts, but any successfully injected script can read it, so a single XSS flaw exposes the session completely. HttpOnly cookies shield the token from script access, which blunts XSS theft, yet reintroduce CSRF exposure and demand SameSite attributes plus CSRF defenses in exchange. Short-lived access tokens paired with refresh tokens soften both failure modes, which is why the pattern dominates current practice. What remains indefensible is inheriting the choice by accident — know which tradeoff your stack made and mitigate it deliberately.
A quick debugging checklist
When a token misbehaves, walk this list before blaming your framework:
- Decode all three segments separately and confirm none throws — intermittent failures usually mean standard-alphabet decoding.
- Read the header's alg and confirm it matches exactly what your verifier expects, nothing more permissive.
- Convert exp, iat and nbf from epoch seconds into real dates and compare against the server clock, allowing small leeway.
- Confirm aud refers to this service, not a sibling API sharing the same auth server.
- Remember that successful decoding proves readability alone — acceptance still depends on server-side signature verification.
Try the tools from this article
Free, no sign-up, and everything runs inside your browser — nothing is uploaded.