JWT Security: Common Vulnerabilities and How to Avoid Them

A JSON Web Token is a signed, self-contained credential made of three base64url segments (header.payload.signature) that a server can verify without a database lookup. Its security depends entirely on that signature: skip verification, guess the secret, or trust the wrong algorithm, and the token stops proving anything.

JWTs are popular because they're stateless: sign a user ID and some claims, and any service holding the secret or public key can verify the token later without a session store. That convenience is also the source of most JWT bugs. A stateless token has no built-in way to check "has this been revoked" or "was this issued for me," and library defaults have historically been loose about which algorithm they'll accept. These are well-documented vulnerability classes, called out in the JWT specification's own security considerations (RFC 8725) and tracked for years across pentest reports and library CVEs.

What is the alg:none attack? #

The JWT header includes an alg field naming the signing algorithm, and early JWT libraries read that field and used whatever it said, including none. A token with {"alg":"none"} and an empty signature would pass verification, since the library skipped the check the header told it to skip. An attacker who can see a valid token edits the payload, sets alg to none, drops the signature, and the server accepts a token it never validated.

Most current libraries reject none by default, but the pattern behind this bug (letting the token's own header decide how it gets verified) still shows up in other forms.

What is algorithm confusion? #

RS256 uses a private key to sign and a public key to verify, so the public key is meant to be exposed. Algorithm confusion happens when a server expects RS256 but a naive verify call still trusts the alg field in the header. An attacker fetches the public key (often published at a .well-known JWKS endpoint), crafts a token with alg: HS256, and signs it using the public key as an HMAC secret. If the verify function follows the header, it hashes the public key against the token exactly as predicted, and a forged token comes back valid.

The root problem in both cases is the same: the header is attacker-controlled input, and letting it dictate verification behavior hands the attacker the keys to the check.

js.js

// Vulnerable: trusts whatever algorithm the token claims
const payload = jwt.verify(token, publicKey);
// jsonwebtoken infers the algorithm from the header, so an
// HS256 token signed with the public key passes.

// Fixed: pin the expected algorithm explicitly
const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
});
// Any token whose header says HS256, none, or anything
// other than RS256 is rejected before signature checking.

Every JWT library exposes an algorithms option to restrict what's accepted. Set it on every verification call; never derive the expected algorithm from the token itself.

Weak or guessable HS256 secrets #

HS256 uses one shared secret for both signing and verification. If that secret is short, a dictionary word, or copied from a tutorial and never rotated, an attacker with one valid token can brute-force it offline: try candidates, recompute the HMAC, compare against the signature. Once recovered, they can forge any token for any user.

The fix is a long, random secret (32+ bytes) stored in a secrets manager, not a config file in the repository. Better still, use RS256 for anything beyond a single trusted service, since the signing key never has to leave the issuing server.

Tokens that never expire #

A JWT with no exp claim, or one set to some far-future date "so users don't have to log in again," turns a single leaked token into a permanent credential.

Set short expiries, minutes to a couple of hours for access tokens, paired with a separate refresh token that has its own longer, still bounded, expiry, stored and transmitted more carefully (HttpOnly cookie, rotated on use). The access token stays cheap to reissue, and cheap to lose if it leaks.

Sensitive data in the payload #

The payload is base64url-encoded, not encrypted. A browser extension, a proxy log, or a curious user pasting the token into jwt.io can decode it and read every claim. Storing a password, a full credit card number, or other PII directly in a JWT payload exposes that data to everyone the token passes through.

Keep the payload to non-sensitive identifiers: a user ID, a role, an expiry, an issuer. If a claim needs to stay confidential from the token holder, look it up server-side instead of embedding it, or use JWE (encrypted JWTs) for the rare case an opaque encrypted claim is genuinely required.

How do you handle JWT revocation? #

JWTs are validated by signature and expiry alone, so a stolen but still-valid token keeps working until it naturally expires. There's no built-in way to say "this one token is dead now," because that requires a lookup, and a lookup defeats the point of a stateless credential.

Two approaches close the gap. Keep access token lifetimes short so a leaked token has a small window of usefulness, and rely on the refresh flow for renewal. Second, maintain a denylist (or an allowlist of active session IDs) in a fast store like Redis, checked on each request for the tokens that matter most: admin sessions, password-reset tokens. That reintroduces a bit of state, but only for the highest-value tokens.

Missing issuer and audience validation #

A JWT can carry iss (issuer) and aud (audience) claims stating who issued it and who it's meant for. If a service checks the signature and expiry but never validates iss or aud, a token issued for one API can be replayed against a different API that trusts the same signing key. This is common where a shared auth provider serves multiple internal services: a token minted for a low-privilege reporting service ends up accepted by billing, because nothing checked it was meant for billing.

Validate both claims on every verification call, against the exact expected values for that service, not a wildcard.

The call we'd make #

Pin the algorithm explicitly on every verify call, favor RS256 with keys in a secrets manager over HS256 with a shared string, keep access tokens short and paired with refresh tokens, keep the payload free of anything sensitive, and check iss/aud alongside the signature. None of these fixes are exotic, and together they cover the vulnerability classes that keep showing up in JWT security reviews. See application security best practices and secure authentication and session best practices for the wider picture.