Issue JWTs Attackers Cannot Forge or Replay
Lock the algorithm to HS256, verify issuer and audience, and cap expiry so a leaked token dies before it does damage.
A JSON Web Token is a bearer credential, which means whoever holds it is treated as the user it names. That makes the way you verify one the most security-critical few lines in your codebase. Get them right and a stolen token is useless after it expires. Get them wrong and an attacker forges an admin token in their browser console without ever touching your server.
The unsettling part is how subtle the wrong way looks. The token verifies. The tests pass. The app works. And yet a known class of attacks lets an attacker mint tokens you will happily accept, because the default behavior of many JWT libraries trusts data the attacker controls. The fixes are small and specific. Here are the ones that actually matter.
Never let the token decide its own algorithm
This is the vulnerability that has burned more applications than any other, and it comes in two flavors that share one root cause: trusting the alg field in the token header, which the attacker can edit freely.
The none algorithm. Every JWT carries a header declaring how it was signed. If a library honors "alg": "none", an attacker changes the header to say none, deletes the signature entirely, and rewrites the payload to make themselves an administrator. The server, told there is no signature to check, accepts the token. The entire security model collapses because the token was allowed to declare that it needs no proof.
Algorithm confusion. The subtler version. Suppose your system uses RS256, an asymmetric scheme: you sign with a private key and verify with a public key that is, by design, public. An attacker takes your public key, changes the token header from RS256 to HS256, and signs a forged token using your public key as the HMAC secret. If your verification code reads the algorithm from the token header, it sees HS256, grabs your public key, and verifies the attacker's forgery as valid. They turned your public key into a signing key.
Both attacks die the moment you stop reading the algorithm from the token. Pin it on the verification side and pass it explicitly. With the jose library:
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'],
});
That algorithms: ['HS256'] is not optional decoration. It is an allowlist that tells the verifier to reject any token whose header claims a different algorithm, including none. The token no longer gets a vote on how it is checked.
Verify the claims, not just the signature
A valid signature proves the token was issued by something holding your secret. It says nothing about whether the token is for this application, for this purpose, or still in date. Those are separate checks, and skipping them is how a legitimately signed token gets used where it was never meant to, the same class of oversight as the missing reconnaissance defenses in preventing user enumeration in your login and reset flows.
Three claims you verify on every request, no exceptions:
iss(issuer). Who minted this token. If you run several services, a token issued for one should not be accepted by another. Verifyissmatches the expected issuer and a token from a sibling system cannot wander in.aud(audience). Who this token is for. Verifying audience stops a token issued for your public API from being replayed against your admin API. Same signing secret, different intended audience, and theaudcheck is what keeps them apart. The same least-privilege instinct drives scoping API tokens so a leak cannot touch everything.exp(expiry). When the token dies. A token without a verified expiry is a permanent credential, so a single leak compromises the account forever. With a short expiry verified on every request, a leaked token is worthless within minutes.
Most libraries verify exp automatically once you pass the expected iss and aud, but you have to supply them:
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'],
issuer: 'your-app',
audience: 'your-app-api',
});
Now a token is accepted only if it is correctly signed, was issued by you, was meant for this audience, and has not expired. Drop any one of those and you have opened a door.
Keep the lifetime short
Expiry is your blast-radius control. A JWT cannot be revoked once issued in the same way a session in a database can; the whole appeal of a stateless token is that you do not check a server-side store on every request. The trade-off is that a leaked token stays valid until it expires, and there is no instant kill switch.
So the expiry is the kill switch, and it should be measured in minutes for access tokens, not days. An 8-hour ceiling is a reasonable outer bound for a token tied to a working session; shorter is better where you can afford it. If you need long-lived sessions, do it with a separate refresh token that you can revoke server-side, and keep the access token short. A leaked access token then dies on its own before it can do much, and the refresh token, which you can invalidate centrally, is the thing you actually control. The cookie that carries either one needs the right SameSite setting, or a perfectly valid token silently fails to ride along on an OAuth callback, which is the whole subject of why your OAuth login breaks with SameSite Strict.
This pattern, short-lived stateless access tokens backed by a revocable refresh token, is the spine of how we structure authentication in the web applications we build, because it gives you the performance of stateless verification without giving up the ability to cut off a compromised session.
The secret is the whole game for HS256
If you use HS256, the security of every token reduces to the secrecy and strength of one shared key. Two failures are common and both are fatal.
Weak secrets. HS256 is HMAC, and a short or guessable secret can be brute-forced offline against a single captured token. Once the attacker recovers the secret, they sign whatever they want. Generate the secret from a cryptographically secure random source, at least 256 bits, and treat it like the master password it effectively is. A secret you can remember is a secret an attacker can crack.
Hardcoded or shared secrets. Load the secret from the environment, never from source, and never with a fallback default value that ships if the env var is missing. A JWT_SECRET || 'dev-secret' line is an open invitation, because the default leaks the instant your code does, and a key that escapes into a client build is worse still, which is why stopping API keys from shipping in your frontend bundle is its own discipline. Throw on a missing secret rather than limp along with a known one. And use a dedicated secret for signing tokens, separate from the secrets that protect CSRF, cron endpoints, and other concerns, so a leak of one does not compromise the rest, the principle behind why one leaked secret should never compromise the rest. When the time comes to change that key, rotating production secrets without taking the app down is how you do it without invalidating every live session at once.
What good looks like
A correctly verified JWT is a small, boring thing, and that is the point. The algorithm is pinned on your side, so the token cannot talk you into using none or confusing your public key for a signing key. The issuer and audience are checked, so a token cannot stray from the service and the audience it was minted for. The expiry is short and verified, so a leaked token is a problem for minutes rather than forever. And for HS256, the secret is long, random, loaded from the environment, and used for nothing else.
None of these are advanced techniques. They are the difference between a verification routine that holds and one that an attacker forges around in an afternoon. The reason they get skipped is that the insecure version looks identical to the secure one right up until someone exploits it, which is precisely the kind of gap a security audit is built to surface before an attacker does. Pin the algorithm, verify the claims, cap the lifetime, and protect the secret, and your tokens become exactly what they were supposed to be: proof you can trust, that expires before it can hurt you.






