I once found a critical vulnerability in a production API because the developer had copy-pasted JWT validation code from a blog post that skipped signature verification entirely. The verify parameter was set to false "for local testing" and somehow made it through code review, past staging, and into production. For eighteen months, anyone could forge a token claiming to be admin, sign it with any key—or no key at all—and the API would happily accept it. JWTs are deceptively simple: three base64 blobs separated by dots. But that simplicity hides real complexity, and when you get it wrong, you don't get subtle bugs. You get total authentication bypass.
I've broken JWT implementations in red team exercises, fixed them in incident response, and reviewed enough to spot the same mistakes repeating. Here's what actually matters when you're handling these tokens in production.
1. Signature Verification Is Not Optional, and Algorithm Matters
The most common JWT vulnerability isn't some exotic crypto attack — it's simply not verifying the signature. Or worse, verifying it with the wrong algorithm. JWT headers specify the algorithm used to sign: HS256 for HMAC with SHA-256, RS256 for RSA with SHA-256, ES256 for ECDSA. The server should know which algorithms it accepts and reject everything else. But some libraries let you specify alg: none in the header, and naive implementations accept it. I've seen this in the wild: a token with alg: none, empty signature, and the payload says role: admin. The server parses it, sees no signature needed, and trusts the claims. This is a known attack. It has a CVE. It still happens.
Algorithm confusion is the other big one. If a server supports both HS256 and RS256, an attacker might take the public key (which is public by design) and use it as an HMAC secret. The server verifies the HMAC signature with what it thinks is a shared secret, but it's actually the RSA public key. If the library doesn't distinguish key types, the signature validates, and the attacker controls the token. I prevent this by explicitly whitelisting allowed algorithms in my JWT library configuration and using separate key stores for symmetric and asymmetric operations. Never let the token dictate how you verify it.
2. Secrets Need Real Protection
For HMAC-signed JWTs, the security of the entire system rests on one secret. If I leak that secret, I can forge any token, for any user, with any claims. I see teams store JWT secrets in environment variables, config files, or — worst case — hardcoded in source. The right approach is a dedicated secrets manager: HashiCorp Vault, AWS Secrets Manager, Azure Key Vault. Rotation should be automated and frequent. I also use key IDs in JWT headers so I can rotate without breaking in-flight tokens. When a new key is introduced, tokens start using it immediately, but old tokens with the previous key ID still validate during a grace period. This sounds obvious until you realize most teams have never rotated their JWT secret and have no process for doing so.
For asymmetric signing (RSA, ECDSA), private keys need even stricter handling. They should never leave the signing service. I use hardware security modules or cloud KMS for key operations, with no direct key export possible. The public key, used for verification, can be distributed via a JWKS endpoint that clients poll and cache. This separation is the whole point of asymmetric crypto — don't undermine it by letting private keys wander.
3. Where You Store Tokens Is a Security Decision
Browser-based applications face a genuine dilemma: where do you keep the JWT? localStorage is convenient and persistent across tabs, but any XSS vulnerability gives the attacker immediate access to all stored tokens. Cookies with HttpOnly, Secure, and SameSite=Strict are harder to steal via JavaScript, but they're vulnerable to CSRF if not properly protected, and they don't work cleanly for SPAs calling cross-origin APIs.
I prefer a hybrid approach for SPAs: keep tokens server-side in a session store, issue the browser a session cookie with strict attributes, and use a Backend-for-Frontend pattern for API calls. For mobile apps, the Keychain (iOS) and Keystore (Android) provide hardware-backed storage that resists extraction. For native desktop apps, it's harder — I've seen teams use OS credential stores or encrypted local databases with device-bound keys. The rule is simple: assume the device is compromised and minimize what the attacker gets. A stolen refresh token is bad. A stolen refresh token with six months of validity is catastrophic.
4. Claims Are Assertions, Not Facts
The payload of a JWT contains claims: sub, iss, aud, exp, iat, custom roles and permissions. These are assertions made by the issuer. But they're not automatically true just because the signature validates. I see APIs that check signature and expiration but skip audience validation. A token issued for app-a should not be accepted by app-b, even if the signature is perfect. Same for issuer—if you're expecting tokens from your corporate IDP, don't accept one from a random OAuth provider. I validate every standard claim and define custom validation rules for application-specific claims. A role: admin claim in a token issued to a regular user is a sign of either token tampering or broken issuance logic. Either way, reject it.
5. Expiration and Revocation Are Hard Problems
JWTs are stateless by design, which means the server doesn't need to query a database to validate them. That's the performance win. It's also the revocation nightmare. If a user logs out, or an account is compromised, or permissions change, the token is still valid until it expires. Short expiration helps — 15 minutes for access tokens is my default — but you need a revocation strategy for the exceptions.
I implement token blacklists for critical events: account compromise, admin role revocation, user termination. The blacklist is checked before signature validation for flagged tokens. For refresh tokens, I use rotation: every refresh yields a new refresh token, and the old one is invalidated. This contains the window of abuse if a refresh token is stolen. It's not perfect — there's always a race condition — but it beats long-lived, non-rotating credentials.
The Takeaway
JWT security isn't about choosing the right library, though that helps. It's about understanding that a JWT is a signed claim, not a magic trust token. Verify signatures with explicit, whitelisted algorithms. Protect signing keys like the credentials they are. Store tokens where attackers can't easily extract them. Validate every claim, not just the signature. Plan for revocation because expiration alone isn't enough. The breaches I've seen involving JWTs weren't sophisticated crypto attacks. They were basic failures: no verification, alg: none, secrets in repos, tokens that never died. Get the fundamentals right, and JWTs are a solid, scalable authentication mechanism. Get them wrong, and you've built a forgeable master key into your architecture. Choose carefully.