A JSON Web Token is a small JSON document with a signature attached. The signature proves who issued it and that nobody has changed it. It does not hide anything: the contents are Base64URL text that anyone holding the token can read. If you remember one thing, make it this: a standard JWT is signed, not encrypted, so never put anything in it that the holder shouldn’t see.
The three parts
A JWT is three Base64URL strings joined by dots: header, payload, signature. We generated this one with HS256 in Node.js:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiJ1c2VyXzQyIiwicm9sZSI6ImVkaXRvciIsImlhdCI6MTc5Mzc1MDQwMCwiZXhwIjoxNzkzNzUxMzAwfQ
.XfkR64Mz0QsL9uiSXih_rRirvXoOUjH9WEOYQcejHbk
(Line breaks added for reading. A real token is one line.)
Decode the first two parts and you get plain JSON. No key is needed:
{"alg":"HS256","typ":"JWT"}
{"sub":"user_42","role":"editor","iat":1793750400,"exp":1793751300}
The header names the signing algorithm. The payload holds claims. RFC 7519 registers a few short names:
| Claim | Meaning |
|---|---|
iss | Issuer: who created the token |
sub | Subject: who it is about |
aud | Audience: who it is meant for |
exp | Expiry time, in seconds since the Unix epoch |
nbf | Not valid before this time |
iat | Issued at |
jti | Unique ID for this token |
Our example’s iat is 2026-11-04 00:00:00 UTC and its exp is 15 minutes later.
How the signature works
For HS256, the signature is an HMAC-SHA256 of the literal text header.payload, using a secret key, then Base64URL encoded. To verify, the server recomputes that HMAC and compares. Change one character of the payload and the values no longer match.
Here is a minimal verifier. We ran it against the token above, a copy with role changed to admin, and a forged token with alg set to none. The first passed, the second threw bad signature, and the third threw unexpected alg:
import crypto from "node:crypto";
function verify(token, secret) {
const [h, p, s] = token.split(".");
const header = JSON.parse(Buffer.from(h, "base64url"));
if (header.alg !== "HS256") throw new Error("unexpected alg");
const expected = crypto.createHmac("sha256", secret)
.update(h + "." + p).digest();
const given = Buffer.from(s, "base64url");
if (given.length !== expected.length ||
!crypto.timingSafeEqual(given, expected)) {
throw new Error("bad signature");
}
const claims = JSON.parse(Buffer.from(p, "base64url"));
if (typeof claims.exp !== "number" || Date.now() / 1000 >= claims.exp) {
throw new Error("expired");
}
return claims;
}
That is for understanding. In production use a maintained library such as jose for JavaScript or PyJWT for Python, and pass it an explicit list of allowed algorithms.
HS256 versus RS256 or ES256. HS256 uses one shared secret, so anyone able to verify a token can also forge one. That is fine when a single service both issues and checks tokens. RS256 and ES256 use a private key to sign and a public key to verify, and that’s the right choice when many services need to check tokens but only one should issue them.
What “not encrypted” means in practice
Any token in a browser, a log file, a proxy or a support ticket can be read by anyone who sees it. Paste one into our JWT Decoder and the claims appear with no key at all. So keep these out of the payload: passwords, API keys, personal data you wouldn’t print on a postcard, and internal details an attacker could use.
Encrypted tokens do exist. JWE (RFC 7516) has five parts rather than three. Most systems never need it, because a server-side lookup of an opaque ID solves the same problem with less machinery.
The mistakes that lead to real attacks
RFC 8725, the JWT best practices document, catalogues these.
Trusting the alg header. The token says which algorithm to verify it with, and the token is attacker-controlled. Two classic attacks follow. Changing it to none asks a naive library to skip verification. Changing RS256 to HS256 tricks some libraries into using the RSA public key, which is public, as the HMAC secret, so the attacker can sign anything. The fix is to fix the algorithm on the server, as the code above does. Never read it from the token and act on it.
Decoding instead of verifying. Many libraries have both decode and verify. Using decode for an access decision means you trust whatever the client sent. It’s a common bug, and it looks fine in normal testing.
Weak HMAC secrets. An attacker with one token can guess secrets offline, as fast as their hardware allows, with no rate limit and no log entry. RFC 8725 says human-memorable passwords must not be used directly as HMAC keys. RFC 7518 requires a key at least as long as the hash output, so at least 256 bits for HS256. Generate it randomly:
openssl rand -base64 32
Not checking aud and iss. If one identity provider issues tokens for several apps, a token meant for app A may verify fine at app B. Check the audience and issuer on every request.
Confusing one token type for another. An ID token accepted as an access token, or a password reset token accepted for login. RFC 8725 recommends explicit typing and separate validation rules for each kind.
Expiry and revocation
A JWT is valid until exp, wherever it goes. That is the design: the server doesn’t need to look anything up. It’s also the main cost, since there is no way to cancel a stateless token. If one leaks, or a user logs out or gets fired, it keeps working until it expires.
Here is the common pattern, and our recommendation:
- Short-lived access tokens, in the range of 5 to 15 minutes. A leaked token has limited use.
- A longer-lived refresh token, stored server-side so it can be revoked, and rotated each time it’s used. Reuse of an old refresh token is a theft signal.
- A deny list of
jtivalues for emergencies, if you need instant revocation. Recognize that this adds a lookup on every request, which removes much of the stateless benefit.
If you need instant logout across the board and every request hits your database anyway, consider ordinary server-side sessions. A random session ID in a cookie is simpler and can be revoked by deleting one row. JWTs earn their complexity where several services must verify identity without calling a central store.
Allow for a little clock skew when you check exp, a minute or so at most. Also remember that exp is in seconds. Passing Date.now() from JavaScript, which is in milliseconds, sets an expiry tens of thousands of years away, and the bug is invisible until someone audits it.
Inspecting tokens with our decoder
The JWT Decoder runs in your browser and doesn’t send the token anywhere. It shows the header, the payload and the raw signature, converts time claims such as exp to readable dates, and tells you whether exp has passed by your device’s clock. It also warns about alg: none, and about time claims that look like milliseconds, the bug described above.
Be clear about its main limit. It does not verify the signature, as that takes a key it doesn’t have. A token that decodes cleanly and shows “not expired” may still be forged, so never treat it as proof of anything. It also can’t read encrypted five-part tokens, because those need a key too.
Be careful with production tokens wherever you paste them, including here on a shared machine. A valid token is a credential until it expires. If you want to know what the encoding itself is doing, read Base64 explained, and for how HMAC differs from a plain hash, see hashing versus encryption.
Where to store a token in a browser
Each option trades one risk for another:
| Storage | XSS can steal it? | CSRF risk? |
|---|---|---|
localStorage | Yes, any script on the page can read it | No, you attach it by hand |
Cookie with HttpOnly, Secure, SameSite | No, scripts can’t read it | Reduced by SameSite, may still need a CSRF token |
| Memory only | Harder, lost on reload | No |
For browser apps, our position is an HttpOnly, Secure, SameSite=Lax or Strict cookie. A single XSS bug in any third party script can read localStorage from every user, and you have far less control over that than over CSRF, which has well-understood defenses. Note that XSS is still serious with cookies, since malicious script can make requests on the user’s behalf. It just can’t take the token away for later use.