JSON Web Tokens, Explained
What's actually inside that long dotted string — how it's built, how it's verified, and the mistakes that turn JWTs into security holes.
Three parts, two dots
A JSON Web Token is that intimidating string you see in Authorization: Bearer … headers. It looks cryptic, but it is deliberately simple: three pieces joined by dots — header.payload.signature. The first two are just JSON objects that have been Base64URL-encoded, and the third is a cryptographic signature over them. Paste any token into theJWT Decoderand you will see all three laid bare instantly.
The header declares the token type and the signing algorithm (for example {"alg":"HS256","typ":"JWT"}). The payload holds the "claims" — statements about the user or session. The signature is what makes the whole thing trustworthy: it proves the header and payload have not been tampered with, provided you have the right key to check it.
The claims inside the payload
Claims are just key–value pairs, and a handful are standardised (the "registered" claims). Knowing them turns a wall of JSON into a readable story about the token.
| Claim | Meaning |
|---|---|
| sub | Subject — who the token is about (a user id) |
| iss | Issuer — who created the token |
| aud | Audience — who it is intended for |
| exp | Expiry time (Unix seconds) — reject after this |
| iat | Issued-at time |
| nbf | Not-before — token is invalid until this time |
The time claims are Unix timestamps, which is why the decoder converts exp, iat and nbf into readable dates and flags whether a token has already expired. Beyond these, an application can add any custom claims it likes — a role, a plan, an email — but every one of them is readable by anyone holding the token.
Signed, not secret
This is the single most important thing to understand about JWTs, and the source of most security incidents: a JWT is signed, not encrypted. The payload is merely Base64-encoded, which is not encryption — it is trivially reversible, as the decoder demonstrates. That means you must never put anything secret in a JWT payload: no passwords, no card numbers, no private data. Assume the user, and anyone who intercepts the token, can read every claim.
What the signature does provide is integrity. If someone changes a single character of the payload — say, flipping "role":"user" to "role":"admin" — the signature no longer matches, and a server that verifies properly will reject it. So a JWT lets a client carry claims that a server can trust as long as it checks the signature, without the server having to store session state.
HS256 vs RS256
The alg in the header tells you how the signature was made, and the two you will meet most are HS256 and RS256.
HS256 (HMAC with SHA-256) uses a single shared secret. The same secret both signs and verifies, so whoever can verify a token can also forge one. That is fine when one service both issues and checks tokens. The Verify box in the decoder tests HS256 tokens: enter the secret, and the Web Crypto API recomputes the signature in your browser to see if it matches.
RS256 (RSA with SHA-256) uses a key pair: a private key signs, and a matching public key verifies. This is safer at scale — you can hand the public key to any number of services so they can verify tokens, while only the issuer holds the private key that can create them. If you see RS256, verification needs the issuer's public key rather than a shared secret.
Common JWT mistakes
- Trusting without verifying. Decoding is not validation. Always verify the signature and check
exp/nbfon the server before trusting any claim. - Putting secrets in the payload. Anyone can read it. Keep sensitive data server-side and reference it by id.
- Accepting
alg: none. Some libraries historically allowed an unsigned "none" algorithm; a strict verifier must reject it, or an attacker can forge any token. - No expiry. A token without
expis valid forever if leaked. Always set a sensible, short lifetime and refresh. - Weak HS256 secrets. A short, guessable secret can be brute-forced offline, letting an attacker forge tokens. Use a long, random secret.
- Storing tokens carelessly. A JWT in an insecure place (or logged in plaintext) is a live credential until it expires.
When to reach for a JWT
JWTs shine for stateless authentication — a server can trust a token's claims without a database lookup, which scales well across many services and APIs. They are ideal for short-lived access tokens, single sign-on, and passing verified claims between services. They are a poor fit when you need to revoke access instantly (a signed token stays valid until it expires, unless you add a denylist), or when the data really must stay confidential (use encryption for that). Used with a short expiry, a strong key, and proper verification, they are a clean, widely-supported way to carry trust across a system.
How a JWT is actually built
Understanding the construction makes everything else click. Building a token is three steps. First, the header JSON is serialised and Base64URL-encoded. Second, the payload JSON gets the same treatment. Third, those two encoded strings are joined with a dot, and that combined string is fed through the signing algorithm together with your key. The resulting signature is itself Base64URL-encoded and appended after another dot.
Written as a formula, an HS256 token is simply:
HMACSHA256( base64url(header) + "." + base64url(payload), secret )
Verification reverses it: the server takes the header and payload exactly as received, recomputes the signature with its own copy of the key, and compares. If even one character of the payload changed in transit, the recomputed signature will not match and the token is rejected. Notice what this implies — the server does not "decrypt" anything, and it does not need to store the token. It only needs the key, which is what makes JWTs stateless.
Note also why Base64URL rather than plain Base64: tokens travel in URLs, headers and cookies, so the standard + and / characters are swapped for - and _ and the trailing = padding is dropped. That is why a JWT can be pasted anywhere without escaping.
Access tokens and refresh tokens
In real systems JWTs rarely travel alone. The standard pattern uses two tokens with very different lifetimes. An access token is a short-lived JWT — typically 5 to 15 minutes — sent with every API request to prove who you are. A refresh token is long-lived, stored more carefully, and used only to obtain a new access token when the current one expires.
The reason for the split is the central weakness of JWTs: because a signed token is valid until it expires, you cannot easily revoke one. A stolen access token works until it dies of old age. Keeping that window tiny limits the damage, while the refresh token — which is typically tracked in a database and can be revoked — provides the control you gave up. This is the standard compromise: statelessness where it buys performance, statefulness where it buys safety.
| Access token | Refresh token | |
|---|---|---|
| Lifetime | Minutes | Days or weeks |
| Sent | With every request | Only to refresh |
| Revocable | Not really | Yes, server-side |
| Risk if stolen | Limited by expiry | High — treat carefully |
JWT vs session cookies
It is worth being clear-eyed about when a JWT is the right choice, because they are frequently used where a plain session would be simpler and safer. A traditional session stores state on the server and gives the client an opaque id in a cookie; the server looks up that id on every request. A JWT carries the state itself, signed, so no lookup is needed.
That difference produces a clean trade-off. Sessions are trivially revocable — delete the record and the user is logged out instantly — but require shared storage across servers. JWTs scale beautifully across many services and APIs without shared storage, but cannot be revoked before expiry without adding exactly the kind of central store they were meant to avoid. As a rule of thumb: for a single traditional web application, sessions are usually simpler and more secure. For APIs, microservices, mobile clients and single sign-on across systems, JWTs earn their place.
Where to store a token in the browser
This is one of the most consequential and most-argued decisions in front-end security, and the honest answer is that every option involves a trade-off. Storing a token in localStorage is convenient and survives page reloads, but it is readable by any JavaScript on the page — so a single cross-site scripting (XSS) flaw anywhere on your site, including in a third-party script, hands an attacker the token.
Storing it in an httpOnly cookie makes it invisible to JavaScript, which neutralises that XSS risk, but cookies are sent automatically with requests and therefore need CSRF protection (typically the SameSite attribute plus a token check). Keeping it only in memory is the most secure — nothing persists — but the user is logged out on every refresh unless you pair it with a refresh-token flow. Most modern guidance lands on: access token in memory, refresh token in an httpOnly, Secure, SameSite cookie. Whatever you choose, always send tokens over HTTPS only.
Debugging tokens in practice
Day to day, most JWT work is troubleshooting, and a decoder is the fastest way through it. When an API returns 401, the first question is whether the token is expired — decode it and read the exp claim as a real date rather than trusting the client clock. If it is still valid, check the iss and aud claims: a token issued for a different environment (staging versus production) or a different audience will be rejected by an otherwise correct server, and this is a classic cause of "it works on my machine."
If the claims all look right and it is still failing, the problem is usually the signature — a mismatched secret, or a service configured for a different algorithm. Paste the token and your secret into the verify box: a green result narrows the problem to server configuration, a red one points straight at your key. And if a user reports missing permissions, decode their actual token rather than reading your own code — the roles or scopes baked into it at issue time are the truth, and they are often stale because the token was minted before the permission change.
A JWT security checklist
- Pin the algorithm server-side. Never let the token's own
algheader decide how you verify it — that is the classic "alg confusion" attack. Configure your verifier to accept exactly one algorithm. - Always verify before reading. Decoding is not validation. Check the signature first, then the claims, then act.
- Validate
issandaud. A correctly signed token from a different issuer or intended for a different service should still be rejected. - Set short expiries. Minutes for access tokens; pair with refresh tokens for longer sessions.
- Use long, random secrets. An HS256 secret should be a high-entropy random string, not a memorable word — short secrets can be brute-forced offline from a single captured token.
- Rotate keys. Use the
kid(key id) header so you can roll a compromised key without invalidating everything at once.
Two of those deserve extra emphasis because they are the ones attackers actually exploit. Algorithm confusion works by taking a token signed with RS256, changing the header to HS256, and signing it with the server's public key — which a naive verifier will happily accept as a shared secret. Pinning the expected algorithm server-side closes this completely. And the "none" algorithm, permitted by the original specification for unsecured tokens, means a token with an empty signature can be forged by anyone; every serious library now rejects it by default, but any custom verification code must do the same explicitly.
The next time a JWT lands in a log or an error, do not squint at it — paste it into theJWT Decoderto read the claims, check the expiry, and verify the signature, all without the token ever leaving your browser.