1. Pin the algorithm
The single most exploited JWT mistake is letting the token choose how it is verified. A verifier that reads alg from the header and dispatches on it is open to two attacks.
The first is alg: none. Older libraries treated it as "no signature required" and accepted anything. The second is key confusion, where an attacker signs with HS256 using your RSA public key as the secret, and a verifier that uses one key variable for all algorithms accepts it.
The fix is the same for both. Tell the library which algorithm you expect, in code, and reject anything else.
// jsonwebtoken
jwt.verify(token, publicKey, { algorithms: ['RS256'] });# PyJWT
jwt.decode(token, public_key, algorithms=["RS256"])Every mainstream library takes an explicit list now. Use it, even when the library claims a safe default.
2. Validate every claim you rely on
A valid signature says the token is unmodified. It says nothing about whether the token is for you, from who you think, or still current. Those come from claims, and each one has to be checked.
exp: reject if in the past. Allow a small clock skew, thirty seconds or so, not minutes.nbf: reject if in the future.iss: must equal the issuer you configured. A token from a different tenant of the same identity provider will have a valid signature and the wrong issuer.aud: must contain your service's identifier. A token issued for a different API is not for you, even if it came from the same issuer.sub: must be present and in the format you expect before you use it as a user id.
Libraries validate exp by default and most will check iss and aud if you pass them. Pass them.
3. Keep access tokens short-lived
A JWT cannot be revoked. Whatever lifetime you set is how long a stolen token works. Five to fifteen minutes for an access token is the usual range. Pair it with a refresh token, stored more carefully, that the server can invalidate.
If your product needs instant revocation, keep a server-side denylist keyed by jti and check it on every request. At that point you have reintroduced a database lookup, which is worth knowing before choosing JWTs over plain sessions.
4. Treat the payload as public
The payload is base64url, not ciphertext. Anyone holding the token, including the browser it sits in, can read every claim. This site's decoder does it without a key.
Never put in a payload: passwords or hashes, API keys, personal data beyond what the receiver needs, internal ids that leak system structure, or anything a regulator would call sensitive. If a claim truly must be hidden, use JWE encryption or keep it server-side and reference it by id.
5. Store tokens where scripts cannot reach them
In a browser, localStorage and sessionStorage are readable by any script on the page. A cross-site scripting bug or a poisoned npm dependency can lift the token and use it from anywhere.
Prefer a cookie with HttpOnly, Secure, and SameSite=Lax or Strict. JavaScript cannot read it, it only travels over TLS, and the browser will not send it on cross-site requests. Cookies bring CSRF considerations, which SameSite handles for most cases and a CSRF token handles for the rest.
For native and mobile apps, use the platform keychain or keystore, not a plain file or shared preferences.
6. Use asymmetric keys once a second party verifies
HS256 means every verifier holds the signing secret and could forge tokens. As soon as a separate API, partner, or mobile app needs to verify, move to RS256 or ES256, publish the public key via JWKS, and keep the private key in one place. The HS256 vs RS256 article covers the details.
If you stay on HS256, the secret must be at least 32 random bytes from a real random source. Not a passphrase, not the app name, not secret.
7. Rotate keys with kid
Put a kid (key id) in every token header and publish keys through a JWKS endpoint. Verifiers look up the key by kid, so you can add a new key, start signing with it, and retire the old one once every token signed with it has expired. Without kid, rotation means a hard cutover and a flood of invalid-signature errors.
Never fetch keys from a URL supplied inside the token (jku, x5u). Configure the JWKS URL in the verifier and fetch it over TLS.
8. Send tokens only in headers
Put the token in Authorization: Bearer <token>, not in a query string. URLs end up in server logs, browser history, referrer headers, and analytics. A token in a URL is a token in a dozen places you do not control.
9. Reject what you do not understand
A token with typ set to something unexpected, an alg you did not list, a crit header naming an extension you do not implement, or five segments instead of three should be rejected, not partially processed. Fail closed.
10. Log verification failures, not tokens
Log that a token was rejected and why: expired, bad signature, wrong audience. Do not log the token itself. A log line with a bearer token in it is a credential in your log pipeline, your log vendor, and every backup of both.
Quick audit
Paste one of your production tokens into the decoder and ask:
- Is
algwhat my verifier pins, and does the verifier actually pin it? - Is
expminutes away, or days? - Do
issandaudname my issuer and my API, and does my code check them? - Is there anything in the payload I would not want on a billboard?
- Is there a
kid, and can I rotate the key behind it without downtime?
Five yeses and the token side of your system is in good shape. Each no is a concrete fix.