A JSON Web Token (JWT, defined in RFC 7519) is a compact string that carries a set of claims, such as who the user is, who issued the token and when it expires, along with a signature that lets the receiver detect tampering. JWTs are used as OAuth 2.0 access tokens, OpenID Connect ID tokens, and session tokens in countless APIs.
They are also easy to get wrong. Most JWT vulnerabilities come from a server that trusts a token it should have rejected, not from broken cryptography. This article walks through a real token byte by byte, then covers algorithm choice, the checks a server must perform, and the mistakes that keep showing up in security reviews.
Anatomy of a real token
Here is a token signed with HS256. It is one string; the line breaks after each dot are only for readability:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJ1c2VyXzg0MTIiLCJhdWQiOiJhcGkuZXhhbXBsZS5jb20iLCJpYXQiOjE3OTEzNzQ0MDAsImV4cCI6MTc5MTM3NTMwMCwic2NvcGUiOiJvcmRlcnM6cmVhZCJ9.
qIlhE7f5p5MGbPMhmkc0RsTjwv70zO-bLTVvkfGH2_o
The three dot-separated parts are the header, the payload and the signature. The first two are Base64URL-encoded JSON. Decoded, they read:
// header
{"alg":"HS256","typ":"JWT"}
// payload
{"iss":"https://auth.example.com","sub":"user_8412","aud":"api.example.com",
"iat":1791374400,"exp":1791375300,"scope":"orders:read"}
The header says how the token is signed. The payload holds the claims: this token was issued by
auth.example.com for user user_8412, is meant for
api.example.com, was issued at 12:00:00 UTC on October 7, 2026 and expires 15 minutes
later.
The signature is an HMAC-SHA256 over the first two parts, exactly as they appear in the token, joined with a dot. This Node.js code produces the token above byte for byte:
const crypto = require('crypto');
const b64url = (s) => Buffer.from(s).toString('base64url');
const header = { alg: 'HS256', typ: 'JWT' };
const payload = { iss: 'https://auth.example.com', sub: 'user_8412', aud: 'api.example.com',
iat: 1791374400, exp: 1791375300, scope: 'orders:read' };
const secret = 'replace-with-32+-random-bytes-from-a-CSPRNG';
const signingInput = b64url(JSON.stringify(header)) + '.' + b64url(JSON.stringify(payload));
const signature = crypto.createHmac('sha256', secret).update(signingInput).digest('base64url');
console.log(signingInput + '.' + signature);
Because the signature covers the encoded bytes, changing a single character of the header or payload,
for example "sub":"user_8413", invalidates the token. Re-encoding the same claims with
different key order or whitespace also produces a different signature, which is why verifiers check the
original string and never a re-serialized copy.
Base64URL, and why the payload is not secret
JWTs use the URL-safe Base64 alphabet from RFC 4648 section 5: - and _ replace
+ and /, and the trailing = padding is dropped. That keeps the
token safe to put in URLs, headers and cookies without further escaping.
Encoding is not encryption. Anyone who holds the token can read every claim:
$ node -e "console.log(Buffer.from(process.argv[1].split('.')[1], 'base64url').toString())" "$TOKEN"
{"iss":"https://auth.example.com","sub":"user_8412","aud":"api.example.com",...}
A signed JWT (a JWS, RFC 7515) guarantees integrity, not confidentiality. Encrypted JWTs (JWE, RFC 7516) exist but are much less common. Treat everything in a typical JWT as visible to the client, to browser extensions, and to every log the token passes through. For the encoding itself, see Base64 encoding explained.
Registered claims
RFC 7519 section 4.1 defines seven registered claim names. None are mandatory in the spec, but your verifier should require the ones it relies on.
| Claim | Meaning | What to check |
|---|---|---|
iss | Issuer | Exact match against the issuer you trust. |
sub | Subject (usually the user ID) | Unique within the issuer; don't assume it's an email. |
aud | Audience: string or array | Must contain your service's identifier. |
exp | Expiration time | Reject if now is after exp (plus small leeway). |
nbf | Not before | Reject if now is before nbf (minus leeway). |
iat | Issued at | Informational; useful for max-age policies. |
jti | Unique token ID | Used for replay detection and deny lists. |
Time claims are NumericDate values: seconds since the Unix epoch, not milliseconds. Setting
exp: Date.now() + 3600 in JavaScript creates a token that expires tens of thousands of
years from now. Use Math.floor(Date.now() / 1000) + 3600. You can check a timestamp
quickly with the Epoch Converter.
HS256 vs RS256 vs ES256
The alg header names a JWA algorithm from RFC 7518. Three
cover almost all real deployments:
| Algorithm | Keys | Signature length in the token | Use when |
|---|---|---|---|
| HS256 (HMAC + SHA-256) | One shared secret signs and verifies | 43 characters | The issuer and the verifier are the same service, or a small set you fully control. |
| RS256 (RSA PKCS#1 v1.5 + SHA-256) | Private key signs, public key verifies | 342 characters (2048-bit key) | Many independent services verify tokens from one issuer; maximum library compatibility. |
| ES256 (ECDSA P-256 + SHA-256) | Private key signs, public key verifies | 86 characters | Same as RS256, but with smaller keys and tokens. |
The important difference is who can create tokens. With HS256, every service that can verify a token
can also mint one, so a leak from your least-protected consumer compromises everything. With RS256 or
ES256, only the issuer holds the private key. Verifiers fetch public keys, usually from a JWKS endpoint,
and the kid header selects the right key during rotation. EdDSA (Ed25519, RFC 8037) is a good
modern choice where your libraries and identity provider support it.
What a server must verify
Verification is a sequence of checks, and skipping any one of them has caused a real vulnerability somewhere. For each request:
- Parse strictly. Exactly three parts, sensible total size, and valid Base64URL-encoded JSON.
- Pick the algorithm from your configuration, not from the token. Keep an allow-list
such as
['RS256']and reject anything else. - Select the key from a trusted source. Look up
kidin the JWKS of the issuer you configured. Never fetch keys from URLs in the token (jku,x5u) unless they match a strict allow-list. - Verify the signature with a constant-time comparison.
- Check
expandnbfwith a small leeway. - Check
issandaud. - Check the token type if one issuer creates several kinds of token, for example
typ: "at+jwt"for access tokens (RFC 9068), so an ID token can't be used as an access token. - Apply your own rules: scopes, roles, and revocation if you have it.
Here is what steps 2 to 6 look like written out by hand for HS256. It is for understanding only; use a maintained library in production:
const crypto = require('crypto');
function verifyHs256(token, secret, { iss, aud, leeway = 60, now = Math.floor(Date.now() / 1000) }) {
const parts = token.split('.');
if (parts.length !== 3) throw new Error('malformed token');
const [h, p, s] = parts;
const header = JSON.parse(Buffer.from(h, 'base64url'));
if (header.alg !== 'HS256') throw new Error(`unexpected alg ${header.alg}`);
const expected = crypto.createHmac('sha256', secret).update(`${h}.${p}`).digest();
const actual = Buffer.from(s, 'base64url');
if (actual.length !== expected.length || !crypto.timingSafeEqual(actual, expected)) {
throw new Error('bad signature');
}
const claims = JSON.parse(Buffer.from(p, 'base64url'));
if (typeof claims.exp !== 'number' || now > claims.exp + leeway) throw new Error('expired');
if (typeof claims.nbf === 'number' && now + leeway < claims.nbf) throw new Error('not yet valid');
if (claims.iss !== iss) throw new Error('wrong issuer');
const auds = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
if (!auds.includes(aud)) throw new Error('wrong audience');
return claims;
}
With the jose library for Node.js, the same policy is a few options. Every one of them
matters:
import { jwtVerify } from 'jose';
const key = new TextEncoder().encode(process.env.JWT_SECRET);
const { payload } = await jwtVerify(token, key, {
algorithms: ['HS256'], // allow-list, never taken from the token
issuer: 'https://auth.example.com',
audience: 'api.example.com',
clockTolerance: 60, // seconds of leeway for exp/nbf
});
Classic JWT vulnerabilities
The "none" algorithm
RFC 7519 defines unsecured JWTs with "alg": "none" and an empty signature. Early libraries
that took the algorithm from the header would accept such a token as valid, so an attacker could write
any claims they liked. Modern libraries reject none by default, but the defense is the
allow-list from step 2: if none is not on it, the token fails.
Algorithm confusion (RS256 to HS256)
Suppose a server verifies RS256 tokens and calls a generic verify(token, publicKey) that
follows the header's alg. An attacker takes the public key, which is public by design,
sets "alg": "HS256", and computes an HMAC using the PEM text of the public key as the secret.
The library then runs HMAC with that same "secret" and the signature matches. The fix is again to fix
the algorithm per key on the server, and to use libraries that refuse to use an RSA or EC key as an HMAC
secret. RFC 8725,
JWT Best Current Practices, covers this and the other attacks in this section.
Decoding instead of verifying
Many libraries have both a decode function, which only reads the payload, and a
verify function. Using decode in an authorization path means any forged
token works. Equally dangerous are verifiers that check the signature but not exp,
aud or iss. Without the audience check, a valid token issued for one service
can be replayed against another service that trusts the same identity provider.
Secrets in the payload
Because the payload is only encoded, never put passwords, API keys, personal data you wouldn't show the user, or internal infrastructure details in it. Keep claims to identifiers and permissions, and look up anything sensitive on the server.
Weak HMAC secrets
An HS256 token is everything an attacker needs to test secret guesses offline, at high speed, without
touching your server. Password-cracking tools support JWTs directly, so secrets like
secret, changeme or the company name fall in seconds. RFC 7518 requires a key
at least as long as the hash output, which means 256 bits for HS256. Generate it randomly, for example
with openssl rand -base64 32, and store it like any other credential.
Expiry, refresh tokens and revocation
A JWT is valid until it expires, wherever it ends up. The standard pattern is a short-lived access token (minutes, not days) paired with a longer-lived refresh token. The refresh token is usually opaque, stored server-side, and exchanged at the authorization server for new access tokens. Rotating refresh tokens on every use and treating reuse of an old one as theft is recommended by the OAuth 2.0 Security Best Current Practice (RFC 9700).
Revocation is the main trade-off of self-contained tokens. Options, from cheapest to most thorough:
- Short lifetimes: a revoked user loses access at the next refresh.
- A per-user "tokens issued before" timestamp: reject tokens whose
iatis older. One lookup, and it covers "log out everywhere". - A deny list of
jtivalues: entries only need to live until the token'sexp. - Token introspection (RFC 7662) on every request. This is effectively a session lookup, which gives up the statelessness that made JWTs attractive.
If you need instant revocation on every request anyway, a plain server-side session may be simpler than a JWT.
Where to store tokens in the browser
There is no option without trade-offs:
HttpOnly,Secure,SameSitecookie: JavaScript can't read the token, so a cross-site scripting (XSS) bug can't steal it, though it can still send requests while the page is open. Cookies are sent automatically, so you need CSRF defenses:SameSite=LaxorStrictplus anti-CSRF tokens for state-changing requests.localStorage: simple and survives reloads, but any script running on your origin, including a compromised third-party script, can read it and send it elsewhere.- In memory: lost on reload, but harder to exfiltrate. It's often paired with a
refresh token in an
HttpOnlycookie that is scoped to the refresh endpoint.
For browser apps, a backend-for-frontend that keeps tokens server-side and gives the browser only a session cookie avoids most of these questions. Whatever you choose, XSS is the threat that matters most, so a strict Content Security Policy does more for token safety than any storage choice.
Clock skew
The issuer and verifier compare timestamps from different machines. If the verifier's clock runs
ahead, a fresh token can look expired, and if it runs behind, nbf can reject a token
that was just issued. Run NTP everywhere and allow a small leeway: 30 to 60 seconds is typical
(clockTolerance in jose, leeway in PyJWT). A leeway of
several minutes quietly extends every token's lifetime, so keep it small. If tokens are rejected
intermittently right after login, compare the server clocks before you look at the code.