JWTs are simple enough that it's easy to wire up authentication in an afternoon — and just as easy to get a handful of details wrong in ways that don't show up until someone actually tries to exploit them. None of this is exotic; it's the same short list of mistakes showing up in real incident writeups again and again.
Never accept alg: none
The JWT spec allows an unsigned token with {"alg":"none"} in the header — intended for cases where the token is already inside a trusted, verified channel. Several real JWT libraries have shipped bugs where they honored whatever alg the token itself claimed, meaning an attacker could take any valid token, change alg to none, strip the signature, edit any claim they wanted, and have it accepted. The fix is simple but non-negotiable: your verifier should hard-reject alg: none, always. Our JWT Inspector flags this automatically as a critical finding if you want to check a token by hand.
The algorithm-confusion attack (RS256 → HS256)
This is the one JWT vulnerability worth understanding in detail, because it's subtle and has hit real production systems. Say your server issues tokens signed with RS256: a private key signs, and a public key verifies. Your public key is public by design — it might be published at a /.well-known/jwks.json endpoint or just embedded in a mobile app.
Now imagine your verification code does something like "read alg from the token header, then verify using that algorithm." An attacker crafts a token with the header changed to {"alg":"HS256"}, and signs it using HMAC with your RSA public key as the secret. If your server blindly trusts the header and runs HMAC verification using "the configured key" — which is that same public key, just now treated as an HMAC secret instead of an RSA public key — the forged signature checks out. The attacker just signed an arbitrary token as if they were your server, using nothing but a key you handed out publicly.
The fix: never let the token tell your verifier which algorithm to use. Your server should have exactly one expected algorithm (or a fixed allowlist) configured per key, decided by your own code — not read from attacker-controlled input. Every mature JWT library has a way to pin this; use it. This is also exactly why our own JWT Signature Verifier only uses the algorithm to pick which cryptographic primitive to run, never to decide trust — pinning the expected algorithm server-side is what actually closes this hole in your own code.
Keep tokens short-lived, and know they can't be revoked
Once a JWT is issued, it's valid until its exp passes — full stop. There's no built-in way to invalidate a single token early the way you can delete a server-side session row. That makes token lifetime a real security control, not just a convenience setting:
- Keep access tokens short-lived (minutes to a couple of hours), and use a separate, revocable refresh token to mint new ones.
- If you need real revocation before
exp— logging out a compromised session immediately, for example — you need a server-side blocklist or a reference/opaque token pattern instead. A JWT alone can't give you that. - Watch for tokens with no
expat all, or a suspiciously distant one — both are things our JWT Expiration Checker and JWT Inspector flag automatically.
Treat signing keys like the secrets they are
- HMAC secrets need real entropy. A short password or a string like
your-256-bit-secret(the jwt.io example — please don't ship it) is brute-forceable. Generate a long, random secret and store it the same way you'd store a database credential. - Private keys never leave your server. For RS256/ES256, only the public key should ever be distributed — to other services, to a JWKS endpoint, to a debugging tool. If something asks you to paste a private key just to check a token, that tool is designed wrong.
- Plan for rotation. Keys eventually need to change — after a suspected leak, or just on a schedule. The
kid(Key ID) header claim exists so a verifier can support multiple valid keys at once during a rotation window instead of invalidating every outstanding token the moment you rotate.
Claims are readable, not secret
Covered in more depth in JWT Claims Explained, but worth repeating here: a JWT's payload is Base64URL-encoded, not encrypted. Anyone holding the token can read every claim in it. Never put a password, API key, or anything else confidential into a claim — the signature protects against tampering, not disclosure.
Validate more than just the signature
A verified signature only proves a token wasn't tampered with and came from whoever holds the signing key — it says nothing about whether the token is expired, meant for your service, or otherwise still valid. On every request, a correct verifier checks:
- The signature, using an algorithm and key your server chose — see above.
exp(andnbf, if present) against the current time.audmatches your service, andissmatches the issuer you actually trust.
Skipping any of these turns "I checked the signature" into a false sense of security.
Where you store the token matters too
There's no universally "correct" answer here, only tradeoffs: localStorage is readable by any JavaScript running on the page, so it's directly exposed to XSS; an httpOnly cookie can't be read by JavaScript at all, closing that hole, but needs CSRF protection (SameSite, anti-CSRF tokens) since the browser will send it automatically. Whichever you choose, it should be a deliberate decision made with your specific threat model, not a default nobody revisited.
Try it yourself
Run a token through our JWT Inspector for an automated check of the issues above — expired tokens, missing exp, alg: none, and unusually long lifetimes are all flagged automatically. For RS256/ES256 signature verification specifically, use the JWT Signature Verifier. Everything runs entirely in your browser — nothing you paste is ever uploaded.