10 Things Every Developer Gets Wrong About JWTs

JWTs (JSON Web Tokens) are everywhere. Auth systems, API gateways, microservices handshakes — they've become the default glue for stateless authentication. And yet, for something so ubiquitous, they're wildly misunderstood. I've reviewed production codebases from startups and scale-ups alike, and the same mistakes show up again and again. Here's a countdown of the ten most common JWT misconceptions — some are subtle gotchas, others are outright security disasters waiting to happen.

10. "The payload is encrypted, so my data is safe"

Let's start here because it trips up almost every developer new to JWTs. A standard JWT is signed, not encrypted. The payload is just Base64url-encoded JSON — anyone with the token can decode it in two seconds using jwt.io or a single line of JavaScript:

atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))

If you're storing a user's email, plan tier, or anything remotely sensitive in the payload, that information is readable by anyone who intercepts or possesses the token. Use JWE (JSON Web Encryption) if you need confidentiality, or better yet, keep sensitive data server-side and only put identifiers in the token.

9. Trusting the Decoded Payload Before Verifying the Signature

This one is dangerous. Some developers decode the JWT first — read the user_id or role — and then verify the signature as an afterthought, or skip it entirely in certain code paths. The signature is the only thing that makes the payload trustworthy. Without verifying it against your secret or public key, the token is just a bag of JSON that anyone could have fabricated.

Always verify first. Every single time. Libraries like jsonwebtoken in Node.js or PyJWT in Python do both in one call — use those, don't hand-roll the flow.

8. Using the alg: none Attack Vector

This one is legendary in security circles. The JWT spec originally allowed an algorithm value of "none", meaning "no signature required." Some early libraries would happily accept a JWT with alg: none and an empty signature — effectively letting an attacker forge any token they wanted.

Modern libraries have patched this, but the real lesson is: never let the client dictate which algorithm to use. On your server, hardcode the expected algorithm:

jwt.verify(token, secret, { algorithms: ['HS256'] })

If the token arrives with a different algorithm, reject it. Period.

7. Storing JWTs in localStorage

The convenient choice. The wrong choice. localStorage is accessible by any JavaScript running on your page — including injected scripts from XSS attacks. A single successful XSS payload can drain every user's token from storage and ship it off to an attacker's server before you've even noticed something's wrong.

HttpOnly cookies aren't a perfect solution either, but they're significantly better — XSS can't read them via JavaScript. Pair that with SameSite=Strict and Secure flags, and you've meaningfully reduced your attack surface. The "localStorage vs cookie" debate has a pretty clear winner from a security standpoint.

6. Setting Expiry Times That Are Way Too Long

I've seen production systems with JWTs that expire in 30 days. One year. Never. The thinking is "we don't want to log users out," which is valid UX thinking but terrible security thinking. A stolen long-lived token is valid for its entire lifespan with no way to invalidate it server-side (that's the whole point of stateless tokens).

Best practice: keep access tokens short-lived (15 minutes to 1 hour) and use refresh tokens to silently obtain new ones. Refresh tokens can be stored more securely, rotated on each use, and revoked in a database if needed. It's more moving parts, but it closes a huge risk window.

5. No Token Revocation Strategy

JWTs are stateless by design — the server doesn't keep a record of issued tokens. That's the feature. But it also means that if a user logs out, changes their password, or gets compromised, their existing token is still perfectly valid until it expires.

The correct approach depends on your risk tolerance. Options include: maintaining a server-side blocklist of revoked JTIs (JWT IDs), using short expiry windows so the blast radius is limited, or switching to opaque tokens for sessions where revocation matters. There's no free lunch — statelessness and instant revocation are fundamentally at odds, so you pick your tradeoff consciously.

4. Confusing HS256 and RS256 — and Using the Wrong One

HS256 uses a single shared secret for both signing and verification. RS256 uses a private key to sign and a public key to verify. The choice matters enormously depending on your architecture.

In a microservices setup, if every service needs to verify tokens but only one service should issue them, RS256 is the right call. You distribute the public key widely, keep the private key locked down to the auth service. With HS256, every service that needs to verify tokens must also have the secret — which means any one of them becoming a breach point compromises your entire token system.

Use HS256 for simple monoliths. Use RS256 (or ES256) when multiple services verify tokens independently.

3. Not Validating the aud and iss Claims

Your library verified the signature. Great. But did it check who issued the token and who it was intended for? The iss (issuer) and aud (audience) claims exist precisely to prevent token confusion attacks — where a valid token issued for one service is replayed against a different service.

Imagine you have two APIs: api.yourapp.com and admin.yourapp.com. If both verify tokens but neither checks aud, a token issued for the regular API could be used to hit admin endpoints. Always validate all relevant claims, not just the signature and expiry.

2. Rolling Your Own JWT Implementation

Cryptographic primitives are not a DIY project. Writing your own JWT parser from scratch — even just the verification logic — is asking for subtle bugs that open security holes. Timing attacks on string comparisons, incorrect Base64url decoding, missing claim validation, off-by-one errors in expiry checks — the list of ways to get it wrong is long and non-obvious.

There are battle-tested, audited libraries for every major language. Use them. If you feel the urge to implement your own because the library "does too much," that's actually a sign the library is doing the right amount. Resist the urge.

1. Treating the Decoded Payload as a Source of Truth for Authorization

And here's the big one — the mistake I see most often in authorization logic. The token is verified, the payload is decoded, and then the code does something like this:

const { role } = jwt.verify(token, secret);
if (role === 'admin') {
  // grant access
}

The problem isn't the code itself — it's the assumption baked into it. That role was set at login time. What if the user's role changed since then? What if their account was downgraded, suspended, or deleted? The token doesn't know. It still says admin. You just granted access to someone who shouldn't have it.

For low-stakes data, token-based roles are fine. But for anything sensitive — admin actions, billing changes, destructive operations — do a database lookup to confirm the user's current state. Yes, it adds latency. Yes, it somewhat undermines the stateless advantage. But authorization correctness is worth it. Consider caching that lookup with a short TTL if performance is a concern.


The Common Thread

Looking at this list, a pattern emerges: most JWT mistakes come from treating the token as more magical than it is. It's not a secure vault — it's a signed note. It's not a live database record — it's a snapshot in time. It's not inherently safe to store anywhere — it's a credential that needs protection.

Once you internalize that mental model, the right decisions become more intuitive. Verify before you trust. Protect it like a password. Keep it short-lived. And never, ever assume the signature check is all that stands between you and a breach — there's always more to validate.

JWTs are a genuinely useful tool. They just demand respect for what they actually are, not what we wish they were.