A JWT is not encrypted — it's signed
The single most common misunderstanding about JSON Web Tokens: people assume that because a token is a long, unreadable-looking string, its contents are hidden. They aren't. A JWT is three base64url-encoded segments joined by dots — header.payload.signature — and base64url is an encoding, not encryption. Anyone can decode the header and payload with nothing more than a text editor. What the signature actually guarantees is integrity, not secrecy: if the header or payload is changed after signing, the signature no longer matches, and a server checking it will reject the token.
The practical rule that follows: never put anything in a JWT payload you wouldn't be comfortable showing to the person holding the token. Passwords, secrets, and anything genuinely confidential don't belong in a JWT — only claims about identity and authorization.
Anatomy of a token
Decoding eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c (the canonical jwt.io example) gives three parts:
| Part | Decodes to | Purpose |
|---|---|---|
| Header | {"alg":"HS256","typ":"JWT"} | Which algorithm signed this token |
| Payload | {"sub":"1234567890","name":"John Doe","iat":1516239022} | The actual claims — who, and other data |
| Signature | raw bytes, base64url-encoded | Proof the header + payload weren't altered |
The signature is computed over the first two parts (HMAC(header + "." + payload, secret) for HMAC algorithms), so changing even one character of the header or payload produces a completely different signature — which is exactly how tampering gets caught.
The standard claims
The JWT spec (RFC 7519) reserves a handful of short claim names with defined meanings. None are required, but most real tokens use several of them:
| Claim | Name | Meaning |
|---|---|---|
iss | Issuer | Who created and signed the token |
sub | Subject | Who the token is about (usually a user ID) |
aud | Audience | Who the token is intended for |
exp | Expiration Time | After this, the token should be rejected |
nbf | Not Before | The token isn't valid until this time |
iat | Issued At | When the token was created |
jti | JWT ID | A unique identifier, useful for one-time-use tokens |
Everything else in the payload is a custom claim your application defines — roles, permissions, tenant IDs, and so on.
HS256 vs. RS256/ES256 — symmetric vs. asymmetric
The alg in the header determines how the signature is computed, and the choice has real architectural consequences:
- HS256/384/512 (HMAC) use a single shared secret for both signing and verifying. Simple, and fine when only one service issues and checks tokens — but every service that needs to verify a token also needs the secret, which means every one of those services can also forge tokens.
- RS256/ES256 (RSA / ECDSA) use a private key to sign and a matching public key to verify. The issuer keeps the private key secret; any number of other services can hold the public key and verify tokens without ever being able to create one. This is why RS256 and ES256 are the standard choice for anything with multiple services or third-party consumers verifying tokens — most identity providers (Auth0, Firebase, Google, Okta) sign with RS256 by default for exactly this reason.
Security mistakes that keep showing up
JWT-related vulnerabilities are consistently near the top of API security findings, and it's almost always one of a small set of mistakes:
- Not verifying the signature at all. Decoding a token (reading its claims) and verifying it (confirming it's authentic) are different operations. Code that decodes a token and trusts its claims without calling a verify function is trivially bypassable — anyone can craft a payload claiming to be an admin.
- The "none" algorithm attack. Some early JWT libraries honored an
alg: "none"header, meaning no signature check was performed at all. An attacker could strip the signature, set the algorithm to "none," and the token would be accepted as-is. Modern libraries reject this by default, but only if you don't explicitly allow it — always whitelist the exact algorithms your server accepts rather than trusting whatever the token's header claims. - Algorithm confusion. A more subtle variant: if a server is configured to accept both RS256 and HS256, an attacker can take a known RS256 public key, use it as the HMAC secret, and sign a forged token with HS256 — which the server then verifies successfully using that same public key as the "shared secret." Pin your server to expect one specific algorithm.
- Not checking expiration server-side. A valid signature only proves a token wasn't tampered with — it says nothing about whether it should still be honored.
expis a self-reported claim; the server must actively check it against the current time on every request.
Where should you store a JWT client-side?
This is genuinely debated, but the consensus among security engineers has settled on a hybrid: keep the short-lived access token in memory (a JS variable, not localStorage or sessionStorage), and put a longer-lived refresh token in an HttpOnly, Secure, SameSite=Strict cookie. The reasoning:
- localStorage/sessionStorage are readable by any JavaScript running on the page — including malicious script injected via an XSS vulnerability anywhere on the site. If an attacker can run JS, they can read the token directly.
- HttpOnly cookies can't be read by JavaScript at all, which closes off the XSS-theft path — but cookies are automatically attached to every request to that domain, which opens a different door: CSRF. That's why
SameSite=Strict(or at minimumLax) matters alongside it. - Keeping the access token in memory (lost on page refresh, re-fetched using the refresh token) limits how long a stolen token would even be valid if an attacker did manage to exfiltrate it mid-session.
JWT vs. session tokens
A traditional session token is a random ID that means nothing on its own — the server looks it up in a database on every request to find out who it belongs to and whether it's still valid. A JWT is self-contained: the claims travel with the token, so a server can verify it without a database lookup at all, which is what makes JWTs attractive for distributed systems and microservices.
The tradeoff: a session token can be revoked instantly by deleting the row in the database. A JWT can't be un-issued — once signed, it's valid until it expires, no matter what happens on the server after that. This is why most real systems use short-lived JWTs (minutes, not days) paired with a longer-lived, database-backed refresh token that can be revoked — combining the JWT's performance benefit for routine requests with the ability to actually cut off access when needed.