Code Beautifier
Security & Auth

JWT security mistakes that show up in every pentest

The alg:none bypass, HS256/RS256 confusion, unverified signatures, secrets in payloads, and missing expiry checks. What actually goes wrong with JWTs and how to avoid it.

JWTs are easy to adopt and easy to get subtly wrong. The format hands you a token that looks opaque, is trivially readable, and produces working code long before it produces secure code. The result is a set of mistakes that recur across codebases with remarkable consistency.

1. Treating the payload as private

A JWT is not encrypted. The header and payload are Base64url-encoded, which is an encoding, not a cipher. Anyone holding the token can read every claim in it, with no key and no tooling beyond a decoder.

Paste any token into a JWT decoder and the entire payload is right there.

So nothing sensitive belongs in a JWT payload: no PII beyond what you need, no internal role descriptions you would not publish, and certainly no secrets or API keys. If the contents genuinely must be confidential, you want JWE (encrypted) rather than JWS (signed), which is a different specification and a different implementation path.

2. The alg: none bypass

The JWT spec includes an algorithm called none, intended for cases where integrity is guaranteed by another layer. It means "this token has no signature."

An attacker takes a valid token, changes the header to {"alg":"none"}, edits the payload to "role":"admin", drops the signature, and sends it. A library that honors the header's algorithm field accepts it.

The fix is to never let the token tell you how to verify it. Pin the expected algorithm server-side:

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

Most current libraries reject none by default. Older versions and hand-rolled verification frequently do not.

3. HS256 / RS256 algorithm confusion

This one is subtler and more interesting.

HS256 is symmetric: the same secret signs and verifies. RS256 is asymmetric: a private key signs, and the corresponding public key verifies.

If your server expects RS256 and an attacker submits a token with the header changed to HS256, a naive implementation calls verify(token, key) where key is your RSA public key. It now treats that public key as an HMAC secret. Since the public key is, by definition, public, the attacker can sign their own tokens with it and they verify correctly.

Same fix as above: pin the algorithm explicitly rather than trusting the header.

4. Decoding without verifying

Nearly every JWT library ships two functions that look interchangeable and are not:

jwt.decode(token);            // parses only, NO signature check
jwt.verify(token, secret);    // parses AND validates the signature

decode will happily parse a token with a garbage signature, an expired timestamp, or claims an attacker invented. It exists for cases where you already verified elsewhere, or want to inspect a token you do not trust.

Using decode on an auth path means you have no authentication at all, only a structured claim that the client asserted about itself. Grep your codebase for it.

5. Not checking exp, or checking it yourself

The exp claim only does something if something enforces it. Some libraries validate it during verify automatically; others require you to opt in. Confirm which yours does rather than assuming.

The related mistake is issuing tokens with no expiry at all, or with a year-long lifetime, because short expiry made testing annoying. A JWT cannot be revoked once issued, which is the fundamental tradeoff of the format: there is no server-side session to delete. A stolen token is valid until it expires. That is precisely why expiry needs to be short, with refresh tokens handling continuity.

While auditing expiry, also check nbf ("not before") and iat ("issued at") if you rely on them.

6. Ignoring aud and iss

If you run several services that share a signing key, a token minted for service A is cryptographically valid at service B. The signature check passes. Everything looks fine. A user with low-privilege access to one system now has a valid token for another.

The aud (audience) and iss (issuer) claims exist for this, and they only help if you verify them:

jwt.verify(token, secret, {
  algorithms: ["HS256"],
  audience: "api.example.com",
  issuer: "auth.example.com",
});

7. Weak HMAC secrets

An HS256 token's security reduces entirely to the strength of the shared secret. A short or guessable one (secret, changeme, the app's name) can be brute-forced offline from a single captured token, and there are tools built specifically to do it.

Use a long random secret, at least 256 bits for HS256, from a CSPRNG. Keep it out of the repo and out of client-side code.

A quick audit checklist

Run through these against your own implementation:

  • Algorithm pinned server-side, none rejected
  • verify on every auth path, decode nowhere near one
  • exp enforced, and short
  • aud and iss checked when multiple services share keys
  • Secret is long, random, and not in version control
  • No sensitive data in the payload
  • Tokens transmitted only over TLS, stored somewhere sensible

For claim-by-claim reference, the JWT claims cheat sheet covers what each registered claim is for. To inspect a token, the JWT decoder runs entirely in your browser, which matters when the token you are debugging is a real production credential.