Most JWT vulnerabilities aren't broken cryptography. HS256 and RS256 are fine. Where things go wrong is the code that's supposed to check the token: it decodes it but never verifies it, lets the token pick its own algorithm, or checks the signature and then ignores who the token was issued to.
This is a fix guide for developers. We'll go through the verification mistakes that keep showing up, how to search your own code for them, how to configure PyJWT, jsonwebtoken and jose so the library enforces the rules for you, and a small test suite that proves your verifier rejects what it should. That last part matters most. "We use a JWT library" doesn't tell you your tokens are safe. A failing forged token in CI does.
Only run these tests against code you own or are authorized to test.
What "verifying a JWT" actually means
A signed JWT is three base64url parts: header.payload.signature. The header names the algorithm (alg), the payload carries claims (iss, sub, aud, exp…), and the signature covers the first two parts.
Verifying it properly means checking all of these:
- The signature is valid, with a key you chose, for an algorithm you chose.
- The algorithm is one you expect. RFC 8725 (the JWT Best Current Practices, February 2020) says libraries must let the caller specify the allowed algorithms and must not use any others (§3.1).
-
The token hasn't expired (
exp) and is already valid (nbf). RFC 7519 allows a small leeway for clock skew, "usually no more than a few minutes" (§4.1.4). -
It was issued by who you think (
iss) for you (aud). If an issuer serves more than one relying party, RFC 8725 §3.9 says the token must carryaudand the recipient must reject it if the audience is missing or isn't theirs.
If you skip any one of these, you have a bug.
Five ways verification fails
1. Decoding instead of verifying. Node's jsonwebtoken has both jwt.verify() and jwt.decode(). Its README is blunt that decode() doesn't check the signature and shouldn't be used on untrusted input. PortSwigger's JWT material describes exactly this: an app passes tokens only to decode(), so any payload is accepted. The Python version of this bug is options={"verify_signature": False}, which the PyJWT docs call "generally ill-advised".
2. Letting the token choose the algorithm. If your code reads alg from the header and verifies with whatever it says, an attacker controls the check. RFC 8725 §2.1 names the two classic results:
-
alg: none, an unsigned token with an empty signature. RFC 7518 §3.6 says implementations must not accept unsecured JWSs by default. PortSwigger adds that blocklists fornoneare often plain string checks, so a value likenOnEcan slip through. -
Algorithm confusion. A service that signs with RS256 receives a token with
alg: HS256that was HMAC-signed with the service's public key. If the verifier uses whatever algorithm the header names with the key it always uses, the forged token checks out. RFC 8725 §3.1's answer: each key is used with exactly one algorithm, and that's checked every time.
3. A weak HMAC secret. For HS256, RFC 7518 §3.2 requires a key of at least 256 bits, and RFC 8725 §3.5 says human-memorizable passwords must not be used directly as an HMAC key. A key like secret123 can be guessed offline from a single captured token. That's why PortSwigger has a lab just for it.
4. Valid signature, wrong token. The signature checks out, but nobody checks aud, iss or exp. So a token from your staging issuer works in production, an ID token minted for another app works against your API, or a token from last month still works. RFC 8725 §3.10 puts it simply: don't trust received claims.
5. Trusting header parameters that pick the key. jwk, jku and kid tell the verifier which key to use. If the app accepts an embedded key, fetches a key from any URL, or puts kid into a file path or SQL query, the attacker chooses the key. PortSwigger's prevention advice is to strictly allowlist jku hosts and make sure kid can't be used for path traversal or SQL injection.
How to find these in your own code
Start with a search. It won't prove anything, but it shows you where to look:
grep -rnE 'jwt\.decode\(|verify_signature|get_unverified_header|ignoreExpiration' \
--include='*.py' --include='*.js' --include='*.ts' src/
Read each hit with the library in mind. In Node jsonwebtoken, jwt.decode() is the call that doesn't verify, so any hit on a request path is a lead. In PyJWT, jwt.decode() is the verifying call, so the question is what you pass to it: a hard-coded algorithms list, or something read from get_unverified_header()? Any verify_signature: False? An audience?
Then check three things in your config:
- Where does the allowed algorithm list come from? It should be a constant next to the key, never read from the token.
- What is the HMAC secret? If it's short, typed by a person, or in the repo, rotate it.
- Which claims are required? If
expis optional in practice, so is expiry.
The fix: let the library enforce the rules
You don't need to write any crypto yourself. You need to pass the right options.
Python (PyJWT):
import jwt # PyJWT
ISSUER = "https://auth.example.com"
AUDIENCE = "api.example.com"
def verify_token(token: str, key: bytes) -> dict:
return jwt.decode(
token,
key,
algorithms=["HS256"], # pinned next to the key, never read from the token
audience=AUDIENCE,
issuer=ISSUER,
leeway=30, # seconds of clock skew
options={"require": ["exp", "iat", "iss", "aud", "sub"]},
)
The PyJWT docs specifically warn against computing algorithms from the token's own alg: hard-code it, or configure it in the same place as the key. options={"require": [...]} makes missing claims an error (MissingRequiredClaimError) instead of a silent pass.
Node (jsonwebtoken):
const jwt = require('jsonwebtoken');
function verifyToken(token, secret) {
return jwt.verify(token, secret, {
algorithms: ['HS256'],
audience: 'api.example.com',
issuer: 'https://auth.example.com',
clockTolerance: 30,
});
}
If you leave out algorithms, the README says the default is based on the key type (for a secret, the HS256/384/512 family). Pass it explicitly anyway, so the allowed list is written down in your code. The README calls expiration "optional", so don't count on verify() to require an exp claim. Make sure your issuer always sets one.
Node (jose):
import { jwtVerify } from 'jose';
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
export async function verifyToken(token) {
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'],
issuer: 'https://auth.example.com',
audience: 'api.example.com',
clockTolerance: 30,
requiredClaims: ['exp', 'sub'],
});
return payload;
}
jose documents that unsecured alg: "none" tokens are never accepted, that issuer and audience require the claim to be present, and that requiredClaims adds more required claims.
Two things that apply whatever the library:
-
Generate the HMAC secret, don't type it.
python -c "import secrets; print(secrets.token_hex(32))"gives 32 random bytes (256 bits). Keep it in your secret store, not in the repo. -
For RS256/ES256, pin the asymmetric algorithm (
["RS256"]) and load keys from your own configured JWKS. Don't follow ajkufrom the token, and don't trust an embeddedjwk.
Prove the fix holds
This is the step most teams skip. A configuration that looks right is a claim. A forged token your verifier rejects is evidence. Detection isn't proof, and neither is a code review.
The suite below builds test tokens with only the standard library, so it works against whatever verifier you use. It includes one positive control: a correctly signed, current token that must be accepted. Without it, a verifier that rejects everything (say, because the key isn't loaded) would pass every negative test and prove nothing.
# jwt_test_tokens.py
import base64, hashlib, hmac, json, time
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def make_token(header: dict, claims: dict, key: bytes | None) -> str:
"""Build a compact JWS for tests. key=None gives an unsigned token."""
head = b64url(json.dumps(header, separators=(",", ":")).encode())
body = b64url(json.dumps(claims, separators=(",", ":")).encode())
signing_input = f"{head}.{body}"
if key is None:
return signing_input + "." # empty signature, trailing dot
sig = hmac.new(key, signing_input.encode(), hashlib.sha256).digest()
return f"{signing_input}.{b64url(sig)}"
def good_claims(**overrides) -> dict:
now = int(time.time())
claims = {
"iss": "https://auth.example.com",
"aud": "api.example.com",
"sub": "user-1001",
"iat": now,
"exp": now + 300,
}
claims.update(overrides)
return claims
# test_jwt_verify.py
import secrets, time
import jwt
import pytest
from jwt_test_tokens import make_token, good_claims
from myapp.auth import verify_token # your verifier from above
KEY = secrets.token_bytes(32) # stand-in test key, 256 bits
HS256 = {"alg": "HS256", "typ": "JWT"}
def test_control_good_token_is_accepted():
claims = verify_token(make_token(HS256, good_claims(), KEY), KEY)
assert claims["sub"] == "user-1001"
@pytest.mark.parametrize("token", [
make_token({"alg": "none", "typ": "JWT"}, good_claims(), None),
make_token({"alg": "nOnE", "typ": "JWT"}, good_claims(), None),
make_token(HS256, good_claims(), secrets.token_bytes(32)), # wrong key
make_token(HS256, good_claims(exp=int(time.time()) - 600), KEY), # expired
make_token(HS256, good_claims(aud="admin.example.com"), KEY), # wrong audience
make_token(HS256, good_claims(iss="https://evil.example.net"), KEY), # wrong issuer
make_token(HS256, {k: v for k, v in good_claims().items() if k != "exp"}, KEY), # no exp
], ids=["alg-none", "alg-mixed-case-none", "wrong-key", "expired",
"wrong-aud", "wrong-iss", "missing-exp"])
def test_bad_tokens_are_rejected(token):
with pytest.raises(jwt.InvalidTokenError):
verify_token(token, KEY)
jwt.InvalidTokenError is PyJWT's base class for decode failures. If your verifier wraps errors in your own exception type, catch that instead. For a Node service, use the same tokens: generate them with this helper as fixtures, or port the helper to your test runner. They're plain strings.
Check that the suite can fail. Before you trust a green run, break the verifier on purpose: make it decode without checking the signature. Run against a stand-in verifier broken that way, the control still passes and all seven negative cases fail with "did not raise". That's what you want to see. A suite that stays green when verification is off isn't testing anything.
If your service uses RS256, add one more case: a token with alg: HS256, HMAC-signed with your own public key PEM as the secret. With algorithms=["RS256"] pinned, it should be rejected. This is the algorithm confusion case from earlier, run against your own service.
Try it yourself
Only test what you own or are authorized to test. These targets are built for practice:
-
PortSwigger Web Security Academy, JWT labs. Free, and they map directly onto the five failures above:
-
JWT authentication bypass via unverified signature (Apprentice): the
decode()-only bug. -
JWT authentication bypass via flawed signature verification (Apprentice):
alg: none. - JWT authentication bypass via weak signing key (Practitioner).
- JWT authentication bypass via kid header path traversal (Practitioner).
- JWT authentication bypass via algorithm confusion (Expert).
-
JWT authentication bypass via unverified signature (Apprentice): the
Burp's JWT Editor extension, which PortSwigger's material uses, makes editing and re-signing tokens much easier.
- OWASP crAPI. Its challenge list includes "Find a way to forge valid JWT Tokens" (Challenge 15). It runs locally with Docker Compose; follow the QuickStart in the repo README.
- Your own service, locally. Point the test suite above at your verifier, then try to make it go green with verification switched off. If you can't, the suite works.
Wrap-up
JWT libraries are rarely the problem. The problem is how they're called. Pin the algorithm next to the key, require the claims you depend on, check iss and aud, and use a generated secret. Then prove it: a positive control that must pass, forged and stale tokens that must fail, and one deliberate break to show the suite can tell the difference. That gives you evidence that verification works, not just a config that looks right.
Further reading
- RFC 8725: JSON Web Token Best Current Practices (§2.1, §3.1, §3.5, §3.9, §3.10)
- RFC 7519: JSON Web Token (claims: §4.1, validation: §7.2)
-
RFC 7518: JSON Web Algorithms (HMAC key size §3.2,
none§3.6) - PortSwigger: JWT attacks and Algorithm confusion attacks
- PyJWT usage and API reference
- node-jsonwebtoken README
- jose: jwtVerify and JWTVerifyOptions
- OWASP crAPI challenges
I'm Krishna, a pentester and the founder of Xalgorix, an open-source AI pentester that only reports vulnerabilities it has actually exploited.
🔐 If this was useful, follow me for more hands-on web & API security write-ups.
🔗 Connect with me on LinkedIn: https://www.linkedin.com/in/xalgord/

Top comments (0)