JWT Security Best Practices: Attacks, Defenses, and a 2026 Checklist
JSON Web Tokens run most modern authentication, yet the JWT security best practices that keep them safe are skipped far more often than they should be. JWT is the de facto credential format for OAuth 2.0, OpenID Connect, and service-to-service calls inside microservices. It is also the source of a steady stream of CVEs every year, and almost all of them trace back to the same avoidable mistakes: accepting unsigned tokens, trusting the algorithm an attacker chose, using a weak signing secret, or skipping claim validation.
A JWT is secure when four things hold at once. The signature is intact, the algorithm cannot be swapped by an attacker, the claims are actually checked, and the token is stored somewhere it cannot be trivially stolen. Break any one of those and you have an authentication bypass, not a hardened API. Below are the three attacks that matter most, then the defenses: choosing and pinning an algorithm, managing keys, validating claims, and storing tokens. The last section is a checklist you can paste into a review.
How a JWT Signature Actually Protects You (and What It Doesn’t)
Before any attack makes sense, you need one fact straight: a JWT is encoded, not encrypted. A signed token has three Base64URL segments joined by dots, in the form header.payload.signature. The header and payload are plain Base64URL of JSON. Anyone holding the token can read every claim in it. Paste any token into our JWT decoder and you will see the header and payload spelled out in readable JSON with no key required. The payload is public by design.
So where does the security come from? The signature, and only the signature. It is a cryptographic value computed over the header and payload using a secret (HMAC) or a private key (RSA, ECDSA). An attacker can read a token freely but cannot produce a different token that passes verification without the signing key. That single property is the entire trust model.
Two consequences follow. First, never put secrets in the payload (passwords, API keys, full PII), because it is readable to anyone who intercepts the token. Second, your entire security posture rests on one step: verifying the signature correctly. That is exactly the step attackers go after. If you want a deeper walkthrough of reading tokens segment by segment, see how to decode a JWT.
The 3 Critical JWT Attacks (and How to Stop Each)
Most JWT vulnerabilities are variations on one theme: the server trusts something the attacker controls. Here are the three that break authentication outright, with the mechanism behind each and the fix.
1. The alg:none Attack — Unsigned Token Bypass
The JWS spec includes an alg value of none, meaning “unsigned.” An alg:none token has an empty signature segment and still ends with a trailing dot, like header.payload.. The attack is simple: take a valid token, change the header’s alg to none, swap in whatever claims you want (say "role": "admin"), and drop the signature. Early JWT libraries accepted this by default, so the forged token sailed through verification. No key, no signing, full impersonation.
You can see what such a token looks like by loading the “alg:none” example in our JWT decoder, which surfaces an explicit red warning that the token is unsigned and must never be accepted for authentication. Reproducing one yourself takes a minute and makes the threat concrete.
The defense is an explicit algorithm allowlist on every verify call. Never let the library default decide what is acceptable, because older defaults were permissive and the cost of being explicit is one extra option.
// WRONG — the library may accept alg:none or any algorithm
jwt.verify(token, key);
// RIGHT — pin the exact algorithm you expect
jwt.verify(token, key, { algorithms: ['RS256'] });
none should never appear in that array. If your library cannot pin algorithms, replace it.
2. Algorithm Confusion — RS256 Downgraded to HS256
This is the most dangerous JWT vulnerability in practice, known since 2015 and still found in audits today. It exploits servers that decide how to verify based on the alg field in the header, the one part of the token an attacker can rewrite.
Here is the mechanism. Your server issues RS256 tokens: it signs with an RSA private key and verifies with the matching public key. That public key is, by definition, public; it may sit in your JWKS endpoint or your repo. The attacker takes it, changes the token header from RS256 to HS256, and signs a forged payload using HMAC-SHA256 with the public key string as the HMAC secret. Now the verification side: if your code reads alg from the header and picks HMAC accordingly, it computes HMAC-SHA256 over the token using that same public key as the secret. The signatures match. The forged token is accepted.
The root cause is two facts colliding: the verifier trusted the attacker-controlled alg header, and the RSA public key was available to the attacker to use as an HMAC key. Neither fact is a bug on its own. A public key is supposed to be public, and an alg header is supposed to describe the token. The vulnerability appears the moment your verification logic lets that header pick which key type and algorithm to use, because then a value the attacker writes drives the crypto path the server runs.
// WRONG — verification method follows the header's alg field
jwt.verify(token, publicKeyOrSecret);
// RIGHT — hard-code the expected algorithm; never let the header choose
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
Pin the asymmetric algorithm explicitly (RS256 or ES256 only), keep HMAC verification on a completely separate code path from RSA verification, and use a maintained library that distinguishes key types. Our JWT decoder flags any HS-family token with a public-key-confusion warning precisely because this attack is so common. When a token you expected to be asymmetric shows up as HS256, that warning is your signal.
3. Weak HMAC Secret — Brute-Force and Dictionary Attacks
When you do use HMAC (HS256/384/512), the entire security of the token rests on the entropy of one secret. If that secret is short, a dictionary word, or a value like secret or password123, an attacker who captures a single valid token can crack it offline. Tools like hashcat run through billions of candidates per second against the token’s signature. Once the secret falls, the attacker can mint any token they like: valid admin credentials forever.
What makes this attack quietly dangerous is that it happens entirely offline. The attacker does not hammer your login endpoint, so there is no rate limit to trip and nothing in your logs to notice. They capture one token, crack the secret on their own hardware, and only come back once they can sign tokens that pass every check you have. The fix is non-negotiable: use at least 32 random bytes (256 bits) from a cryptographically secure source, and store it in a secrets manager, never in code or a repo.
// WRONG — guessable, low entropy, crackable in seconds
const secret = "password123";
// RIGHT — 256 bits from a CSPRNG, then load from KMS at runtime
const secret = require('crypto').randomBytes(32).toString('base64');
Need a strong value quickly? Our random password generator produces high-entropy strings suitable for an HMAC key. Want to feel the difference in practice? Sign a test token with a strong secret in our JWT encoder, which runs entirely in the browser so the secret never leaves your machine. And when verification crosses a trust boundary, such as multiple services or third-party verifiers, drop HS256 entirely and switch to an asymmetric algorithm.
Choosing and Pinning the Right Algorithm
Algorithm choice is where the confusion attack is won or lost, so pick deliberately. The three you will actually use:
| Algorithm | Type | Signing / verifying key | When to use |
|---|---|---|---|
| HS256 | Symmetric (HMAC) | One shared secret | Single trust boundary, same party signs and verifies |
| RS256 | Asymmetric (RSA) | Private key signs / public key verifies | Cross-service, third-party verification, JWKS rotation |
| ES256 | Asymmetric (ECDSA) | Private key signs / public key verifies | Same as RS256, with smaller and faster keys; preferred for new systems |
The rule is short. If the same party signs and verifies inside one trust boundary, HS256 is fine and fast. If anyone other than the signer needs to verify, whether that is another service, a partner, or a public client, use an asymmetric algorithm and prefer ES256: its keys and signatures are far smaller than RSA at equivalent strength. You can sign sample HS256, RS256, and ES256 tokens side by side in the JWT encoder to compare their structure and signature length.
Whatever you choose, the defense that actually matters is the same: pin one explicit algorithm set on the verify call and never trust the alg field in the header. The allowlist is the foundation everything else rests on.
Key Management and Rotation
Algorithms are only as safe as the keys behind them, and key handling is where most guides go quiet. For HS256, the secret is at least 32 random bytes and lives in a secrets manager such as AWS Secrets Manager, HashiCorp Vault, or Azure Key Vault. For asymmetric algorithms, the private key belongs in an HSM or KMS and never touches application code; the public key is published, typically through a JWKS endpoint that verifiers fetch.
Rotation needs to be routine, not an emergency. Tag each key with a kid (key ID) in the JWT header so verifiers know which key signed a given token. Keep a small set of valid keys on the verifying side, the current key plus the recent previous one, so tokens signed just before a rotation still verify during their lifetime. That overlap is what keeps rotation from turning into an outage.
A short checklist for keys:
- Rotate signing keys at least every 90 days, and immediately on any suspicion of compromise.
- Publish public keys via JWKS; version them with
kid. - Keep private keys and HMAC secrets in a KMS or HSM, never in git, never in client code, never hard-coded.
- On a leak, rotate the key and revoke outstanding refresh tokens at once.
Claim Validation You Cannot Skip
Signature checking proves a token is authentic. It does not prove the token is for you, right now. That is the job of claim validation, and it is the cheapest defense you can add. Five claims need checking on every request:
exp(expiration): reject tokens whose expiry is in the past.nbf(not before): reject tokens used before their valid window opens.iat(issued at): optionally reject tokens that are implausibly old.iss(issuer): confirm the token came from the issuer you trust.aud(audience): confirm the token was minted for your service. A missingaudcheck is the most common silent hole, letting a token issued for one API be replayed against another.
Most libraries validate these for you when you pass the expected values:
jwt.verify(token, key, {
algorithms: ['ES256'],
issuer: 'https://auth.example.com',
audience: 'api.example.com',
clockTolerance: 5, // seconds, for distributed clock skew
});
Allow a small clock tolerance (five seconds is typical) so minor drift between servers does not reject otherwise-valid tokens. Resist the urge to make it larger; a generous tolerance widens the window in which an expired token still works, which is exactly what exp exists to close. To sanity-check the exp and iat values on a token by eye, drop it into the JWT decoder and convert the timestamps with our Unix timestamp converter.
Token Lifetime and Where to Store JWTs
Server-side checks are only half the story. Where the client keeps the token decides how easily it can be stolen, and storage is where XSS and session hijacking meet. The pattern that holds up: a short-lived access token (15 to 60 minutes) paired with a separate, longer-lived refresh token you can revoke.
The storage decision comes down to a single trade-off:
| Storage location | XSS exposure | CSRF risk | Recommendation |
|---|---|---|---|
| localStorage | High; any JavaScript on the page can read it | None | Avoid for session tokens |
| HttpOnly + Secure + SameSite=Strict cookie | Low; invisible to JavaScript | Needs CSRF protection | Recommended for sessions |
A token in localStorage is readable by any script that runs on the page, so a single XSS bug leaks the whole session, and the attacker can replay it from their own machine for as long as it lives. An HttpOnly cookie cannot be read by JavaScript at all, which shrinks the damage of XSS to what an attacker can do inside a live page. That is still bad, but it is not a stolen credential they can carry away. The cost of the cookie approach is that you now need CSRF protection, since cookies ride along automatically on every request; SameSite=Strict plus a CSRF token handles it. Keep the access token short so a leak has a small blast radius, and put the refresh token in an HttpOnly, Secure, SameSite cookie. On logout or suspected compromise, revoke the refresh token server-side and rotate the signing key. For the wider context around XSS, CSRF, and secure cookies, see our web security best practices guide.
JWT Security Checklist
Run through this before shipping any JWT-based auth:
- Verification pins an explicit algorithm allowlist and rejects
alg:none. - Asymmetric verification hard-codes the expected algorithm and never reads
algfrom the header (blocks confusion). - HS256 secrets are at least 32 random bytes, loaded from a KMS.
- Private keys live in an HSM/KMS; public keys are published via JWKS and versioned with
kid. - Signing keys rotate at least every 90 days.
- Every request validates
exp,nbf,iat,iss, andaud, with a clock tolerance of 5 seconds or less. - Access tokens last 15 to 60 minutes; refresh tokens live in an
HttpOnlycookie. - No secrets in the payload, since it is encoded, not encrypted.
FAQ
Is JWT secure by default?
No. JWT security depends on configuration. You must pin the algorithm, reject alg:none, use a high-entropy secret or key, and validate claims. Default or permissive library setups frequently allow authentication bypasses.
What is the most dangerous JWT vulnerability?
Algorithm confusion, where RS256 is downgraded to HS256 and the public key is used as an HMAC secret. It has been known since 2015 yet still appears in audits, because it exploits servers that pick the verification method from the header’s alg.
Should I use HS256 or RS256?
Use HS256 when the same party signs and verifies inside one trust boundary. Use RS256 or ES256 when another service or a third party must verify, or when you need JWKS rotation. For new systems, prefer ES256: smaller, faster keys at equal strength.
Where should I store a JWT?
Prefer an HttpOnly, Secure, SameSite cookie for session tokens, since JavaScript cannot read it and a single XSS bug cannot steal it. Avoid localStorage for session tokens, since any XSS leaks the entire session for replay.
How often should I rotate JWT signing keys?
Rotate at least every 90 days as routine, and immediately on any suspected compromise. Version keys with kid and keep both the active key and the recent previous one on the verifier so tokens signed just before rotation still validate.
Can a JWT be tampered with?
Not without the signing key. No attacker can forge a token that passes verification. But if your server accepts alg:none, is vulnerable to algorithm confusion, or uses a weak secret, the signature can be bypassed. Those are configuration failures, not flaws in JWT itself.
What claims must I validate?
Validate exp (expiration), nbf (not before), iat (issued at), iss (issuer), and aud (audience). A missing aud check is the most common silent vulnerability, letting a token meant for one service be replayed against another.
Conclusion
JWT security is not complicated, but every layer has to hold. The signature is your only guarantee, so verify it correctly. Pin one explicit algorithm and never trust the header’s alg. Use strong, rotated keys kept in a KMS. Validate exp, nbf, iat, iss, and aud on every request. Store tokens where XSS cannot reach them.
When you want to check a real token, paste it into our JWT decoder to inspect its algorithm and claims and catch alg:none or HS-confusion risks. Use the JWT encoder to experiment with signing entirely in your browser, where your keys never leave your device.