What a JWT Actually Is
A JSON Web Token is a string that carries a small JSON document plus a cryptographic signature proving who produced it. It is defined in RFC 7519, which builds on JSON Web Signature (RFC 7515) for signing and JSON Web Algorithms (RFC 7518) for the algorithm names. In a web application, "JWT" almost always means a signed JWS in compact serialization — three Base64url segments joined by dots.
The important word is *signed*, not *encrypted*. A standard JWT is readable by anyone who has it; the signature only proves the contents have not been altered since the issuer created them. There is an encrypted variant, JWE (RFC 7516), but it is rare in browser auth and uses five segments instead of three.
That distinction explains most JWT mistakes. A user's email, internal ID, or role in a token is fine. A password reset secret, an API key, or a national ID number is not — you are publishing it to anyone who can read a request header.
Anatomy of a Token
This token is signed with HMAC-SHA-256 and the secret your-256-bit-secret:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Split on the dots for three parts:
The header describes how the token was signed:
{ "alg": "HS256", "typ": "JWT" }
The payload holds the claims:
{ "sub": "1234567890", "name": "John Doe", "iat": 1516239022 }
The signature is raw bytes, Base64url-encoded. For HS256 it is computed as:
HMAC-SHA-256(base64url(header) + "." + base64url(payload), secret)
Note the encoding is Base64url (RFC 4648 section 5), not ordinary Base64: + becomes -, / becomes _, and the trailing = padding is stripped. This is why pasting a JWT segment into a generic decoder sometimes fails on padding. A Base64 Encoder/Decoder that handles the URL-safe alphabet decodes header and payload correctly; a strict one may not.
Decoding One By Hand
You can decode a token in a terminal with no libraries. In Node:
const token = process.argv[2];
const [h, p] = token.split(".");
const decode = (s) => JSON.parse(Buffer.from(s, "base64url").toString());
console.log("header ", decode(h));
console.log("payload", decode(p));
The base64url Buffer encoding needs Node 16 or newer; on older runtimes, translate - to + and _ to / and decode as base64. Notice that the snippet never uses a secret — decoding requires nothing. A client can read every claim in its own token, and so can an attacker who steals it. To skip the shell, paste the token into the JWT Decoder — it splits, decodes and pretty-prints the claims in the browser, without sending the token anywhere.
Registered Claims and What to Validate
RFC 7519 registers seven claim names. Everything else is yours to invent.
| Claim | Name | What it means | Validate it? |
iss | Issuer | Who minted the token | Yes, against an allowlist |
sub | Subject | Who the token is about | Yes, as your user ID |
aud | Audience | Which service it is for | Yes — rejects cross-service reuse |
exp | Expiration | Reject at or after this time | Yes, always |
nbf | Not Before | Reject before this time | Yes, if present |
iat | Issued At | When it was created | Useful for max-age policies |
jti | JWT ID | Unique token identifier | Yes, if you maintain a denylist |
exp, nbf, and iat are NumericDate values: seconds since the Unix epoch in UTC, not milliseconds. The iat above, 1516239022, is 2018-01-18T01:30:22Z. Passing it straight to JavaScript's new Date() gives a date in 1970, because that constructor expects milliseconds — a bug that ships often. Drop a suspicious timestamp into the Timestamp Converter before theorising about clock skew.
Validation is the step libraries make easy to skip: decode and verify sit next to each other, and only one checks the signature.
const jwt = require("jsonwebtoken");// WRONG: returns claims from any string, signed or not
const unverified = jwt.decode(token);
// RIGHT: throws on bad signature, expiry, issuer or audience
const claims = jwt.verify(token, publicKey, {
algorithms: ["RS256"], // never omit this
issuer: "https://auth.example.com",
audience: "https://api.example.com",
clockTolerance: 5, // seconds, for modest clock skew
});
Choosing a Signing Algorithm
| Alg | Construction | Key model | Signature size | Use when |
HS256 | HMAC with SHA-256 | Symmetric, one shared secret | 32 bytes | One service signs and verifies |
RS256 | RSASSA-PKCS1-v1_5 + SHA-256 | Asymmetric RSA | 256 bytes at 2048-bit | Many verifiers, one issuer |
PS256 | RSASSA-PSS + SHA-256 | Asymmetric RSA | 256 bytes at 2048-bit | Same as RS256, better padding |
ES256 | ECDSA on P-256 + SHA-256 | Asymmetric EC | 64 bytes | Asymmetric with small tokens |
EdDSA | Ed25519 | Asymmetric, Edwards curve | 64 bytes | Modern default where supported |
none | Nothing | — | 0 | Never in production |
All of these except EdDSA are defined in RFC 7518; EdDSA was added to JOSE later by RFC 8037, which is why older libraries reject it.
Two practical rules. First, HS256 requires a key of at least 256 bits — RFC 7518 says so explicitly for HMAC-SHA-256 — so secret123 is not a key but a dictionary word an offline cracker resolves in seconds. Generate one with a Password Generator at 32+ random characters, or take 32 bytes from a CSPRNG. Second, prefer asymmetric algorithms as soon as more than one service verifies tokens: with HS256 every verifier also holds the power to mint tokens.
On the hashing underneath: SHA-256 is sound. MD5 and SHA-1 are broken for collision resistance — practical MD5 collisions have existed since 2004, and SHA-1 fell to the SHAttered collision in 2017 and to chosen-prefix collisions in 2020. Neither appears in the JWA registry, for good reason. (HMAC-SHA-1 is not broken the same way, since HMAC does not rely on collision resistance, but there is no reason to pick it.) To compare digests while debugging, the Hash Generator covers SHA-256 and SHA-512 — 256- and 512-bit outputs.
The Attacks That Actually Work
The none algorithm. An attacker rewrites the header to {"alg":"none"}, edits the payload, and drops the signature. A naive verifier that reads alg from the token and dispatches on it accepts the result. This is why algorithms: ["RS256"] above is not optional — the server, not the token, must decide which algorithm is acceptable.
Algorithm confusion. Your service signs with RS256 and publishes an RSA public key. An attacker re-signs a forged token with HS256, using that public key text as the HMAC secret. A verifier that accepts both families and picks its key by algorithm will HMAC-verify against the public key and pass. Pinning the algorithm list kills this too.
Weak secret cracking. HS256 tokens can be attacked offline: an attacker who holds one valid token can test candidate secrets at high speed with no requests to your server. Nothing about JWT protects a short secret.
Stolen token replay. A JWT is a bearer token: whoever holds it is the user, and signature validation does not care where the request came from.
RFC 8725 (BCP 225) collects these into a Best Current Practices document.
A Debugging Checklist for Broken Auth
Work in this order; most failures resolve in two steps.
Authorization header. A missing Bearer prefix, or a doubled one, is a common cause of "invalid token" on a valid token.%0A means something URL-encoded it in transit.alg against what your verifier expects, and kid against the keys your JWKS endpoint currently serves. Rotated-out kid values produce signature failures that look like tampering.exp against server time, not your laptop's. Compare in UTC seconds. Expiry is the single most common cause, and a drifted container clock mints tokens already expired or not yet valid.aud and iss. A token minted for the web app and presented to the internal API fails here, correctly.header.payload and compare. If it matches but verification still fails, the problem is a claim, not the crypto.Storage, Expiry and Revocation
The hardest property of JWTs is that verification is offline: a signed token stays valid until it expires, no matter what happens to the account. Logging out, banning a user or revoking a permission changes nothing the verifier can see.
The standard answer is short-lived access tokens plus a long-lived refresh token. Give the access token 5 to 15 minutes, narrowing the window a stolen one is useful in, and keep the refresh token in server-side state you can delete. Rotate refresh tokens on use: issue a new one each time, and treat reuse of an old one as theft, revoking the whole family.
For storage, prefer an HttpOnly, Secure, SameSite=Lax (or Strict) cookie over localStorage. A token in localStorage is readable by any script on the page, so one XSS hole exfiltrates every session. Cookies bring CSRF into scope, which SameSite plus a per-request token handles — a solved problem, unlike XSS exfiltration.
If you need immediate revocation, keep a denylist of jti values until their exp passes. One cache lookup per request buys back the one thing stateless tokens otherwise lack.