JSON Web Tokens (JWTs) are everywhere in modern authentication, and for good reason: they are compact, self-contained and easy to pass around between services. The catch is that a lot of their security depends on how you use them. If you rely on a well-maintained JWT library, the hardest cryptographic parts are handled for you, but a handful of practical decisions are still yours to get right.
This post is aimed at developers who use a JWT library rather than write one. We will walk through the practical steps that make the difference between a token you can trust and one an attacker can bend, starting with the single most important one.
This is the one to get right before anything else. A JWT carries the algorithm it was signed with in its header, in the alg attribute, and that header is controlled by whoever sends the token. If your code trusts the alg from the token to decide how to verify the signature, an attacker gets to pick the verification path. Two classic attacks come from exactly this:
none algorithm: the attacker sets alg to none and strips the signature. A library that honours it will treat an unsigned token as valid.alg to HS256 so the public key, which is not secret and can be trivially recovered for both RSA and ECDSA, is used as an HMAC key. They can then sign their own tokens.The fix is simple and it lives entirely on your side: tell the library which algorithm you expect instead of letting the token decide.
if jwt.verify(secret, token, 'HS256')
return true
else
return false
end
Most libraries let you pass an explicit algorithm (or an allow-list of algorithms) to verify(). Always do it. If your library verifies without an expected algorithm argument, treat that as a red flag and check its documentation carefully. We have hands-on challenges on exactly these attacks if you want to see them from the attacker's side: one on the none algorithm, and three on algorithm confusion: with the public key provided, recovering an RSA public key and recovering an ECDSA public key from signed tokens first.
For HMAC-signed tokens (HS256 and friends), the security of every token you issue rests on the strength of one secret. A short or low-entropy secret can be brute-forced offline: an attacker who captures a single valid token can try candidate secrets until the signature matches, then forge any token they like.
Use a high-entropy secret and enforce a minimum size in code so a weak value never sneaks into production:
if secret.size < 32
puts "Secret too small"
exit
end
Generate the secret from a cryptographically secure source, keep it out of your codebase, and load it from configuration or a secrets manager. For RSA or ECDSA, the equivalent is using properly generated keys and protecting the private key.
A token without an expiry is valid forever. If one leaks, through a log, a browser history, a proxy or a compromised device, it stays useful to an attacker indefinitely. Every token you sign should carry an expiration using the exp claim (and it is good practice to set iat, issued-at, as well).
Setting the claim is only half the job. Some libraries do not validate exp unless you ask them to, so confirm that expiry is actually enforced on verification, not just present in the payload. A token that says it expired yesterday should be rejected today.
Never sign a token without an expiry. Keep the lifetime as short as your workflow allows, and use refresh tokens if you need longer sessions.
You will need to change your signing secret at some point, whether on a schedule or after a suspected exposure. If verification only ever accepts the current secret, rotating it invalidates every live token at once and forces a synchronised change across every service. A small tweak avoids that: accept the current secret, and fall back to the previous one during a grace period.
if jwt.verify(secret, token)
return true
elsif jwt.verify(previous_secret, token)
return true
else
return false
end
This lets you roll a secret out gradually: new tokens are signed with the new secret, old tokens keep working until they expire, and you retire the previous secret once the grace period passes. Whether you use HMAC secrets or asymmetric keys, you can also use the kid (key id) header to select the right secret or key.
Signature verification failures are rarely accidental. A burst of them usually means someone is tampering with tokens: probing for the none algorithm, trying algorithm confusion, or trying to find a vulnerability in your token handling. Log these failures so you can spot an attack early rather than reading about it later.
if jwt.verify(secret, token)
return true
else
log.error("Invalid token: " + token)
return false
end
Feed those logs into your monitoring and alert on unusual spikes. It is one of the cheapest ways to turn a silent attack into an actionable signal.
Bugs and refactors happen. A change to your auth code, a swapped library or a misconfigured option can quietly turn off signature verification, and everything keeps working, which is exactly why nobody notices until it is exploited. Guard against it with a simple, automated canary.
On a schedule (daily is a good start), take a valid JWT, change the payload while keeping the original header and signature, and send it to your application. A correctly configured application must reject it. If it ever accepts a tampered token, your canary fails and you get paged. This one check catches a whole class of "verification silently broke" incidents.
iss and aud: if your tokens are meant for a specific issuer and audience, verify those claims so a token minted for one service cannot be replayed against another.None of these steps are complicated, and that is the point. Pinning the algorithm, using a strong secret, enforcing expiry, planning for rotation, logging failures and running a canary are foundational habits that together remove most of the ways JWT authentication goes wrong in practice. A good library does the cryptography; these are the decisions it leaves to you.
If you want to understand these issues by exploiting them, that is exactly what PentesterLab is built for. Our hands-on JWT and code review content walks through the attacks and the defences side by side, which is the fastest way to internalise why each of these tips matters.
Want to build these skills hands-on?
PentesterLab has 700+ real-world labs on web hacking, code review, and vulnerability analysis. Start with a free account.