JWT Security Implementation Bugs: What Penetration Testers Find and How Developers Can Fix Them Before the Assessment

Retool's new app builder is where AI-generated code ships safely
Building apps with AI is easy. Getting them to production safely is another story.
JWT vulnerabilities rarely come from using the wrong library. They come from using the right library incorrectly. The most severe bugs found in penetration tests are implementation mistakes that happen when developers do not understand what they are delegating to the JWT library and what they still need to validate themselves. This guide covers each common flaw class with the attack payload, why it works, and the exact code change that fixes it.
The Algorithm Confusion Attack: RS256 to HS256 Downgrade
This is the most sophisticated JWT attack and the one with the highest impact. It exploits the trust relationship between the JWT header's alg field and how the server selects the verification algorithm.
When an application uses RS256 (asymmetric signing), it signs tokens with a private RSA key and verifies them with the corresponding public key. The public key is often published (JWKS endpoint, documentation, or embedded in the application). An attacker who observes the public key can:
- Change the JWT header's
algfield fromRS256toHS256 - Re-sign the token using HMAC-SHA256 with the RSA public key as the HMAC secret
- Submit the modified token
If the server selects the verification algorithm based on what the JWT header claims (rather than what the server expects), it will verify the token's HMAC signature using the RSA public key as the HMAC secret. The signature is valid because the attacker signed it with the same key the server is now using for verification. Authentication is bypassed.
The fix is simple but must be applied at every verification site:
# WRONG - trusts the alg from the token header
decoded = jwt.decode(token, public_key, algorithms=jwt.get_unverified_header(token)['alg'])
# CORRECT - specifies the expected algorithm explicitly
decoded = jwt.decode(token, public_key, algorithms=['RS256'])
Never pass the algorithm from the token header to the decode/verify function. Always hardcode the algorithm your application expects.
The None Algorithm Bypass
The JWT specification includes none as a valid alg value, indicating an unsigned token. Early JWT library implementations accepted none algorithm tokens as valid if the application did not explicitly disable unsigned tokens. The vulnerability:
- Decode an existing valid JWT (base64 decode without verification)
- Modify the payload (e.g., change
subto another user's ID orroletoadmin) - Change the header
algtonone(orNone,NONE,nOnEfor case-insensitive matching) - Re-encode without a signature (trailing dot only:
header.payload.) - Submit the token
Most modern libraries have patched this by default, but legacy code or custom JWT parsers may still be vulnerable.
The fix:
# PyJWT - reject none algorithm explicitly
decoded = jwt.decode(token, secret_key, algorithms=['HS256']) # only HS256 accepted
# Node.js jsonwebtoken - same pattern
jwt.verify(token, secret, { algorithms: ['HS256'] });
# Java (jjwt) - specify allowed algorithms
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token); # parseClaimsJws rejects unsigned tokens; parseClaims would accept them
The distinction in Java's JJWT library between parseClaimsJws() (requires signature) and parseClaimsJwt() (accepts unsigned) is a common source of vulnerabilities when developers pick the wrong method.
Briefings like this, every morning before 9am.
Threat intel, active CVEs, and campaign alerts, distilled for practitioners. 50,000+ subscribers. No noise.
Weak HMAC Secrets: Offline Cracking with Hashcat
HS256 JWTs are only as secure as the secret used to sign them. If the application uses a short, guessable, or default secret, an attacker who captures a valid JWT can crack the secret offline and forge tokens for any identity.
The attack:
# Extract the JWT and attempt to crack it with a wordlist
hashcat -a 0 -m 16500 "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.signature" /usr/share/wordlists/rockyou.txt
# Or try a brute-force of short secrets (6 chars, letters+digits)
hashcat -a 3 -m 16500 "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.signature" ?a?a?a?a?a?a
Common weak secrets found in penetration tests:
secret(the default in dozens of JWT tutorials)password,1234,changeme- Application name, company name, or environment name
- A short UUID or MD5 hash of a predictable value
For HS256, use a cryptographically random secret of at least 256 bits (32 bytes). Never use a secret derived from application configuration without appropriate entropy.
Generating a secure secret:
import secrets
secret = secrets.token_hex(32) # 256-bit hex string, 64 characters
print(secret) # Store this in a secrets manager, never in source code
For production systems, prefer RS256 over HS256. RS256 has no equivalent weak-key attack because the verification key (public key) cannot be used to forge tokens. The private key never leaves the signing service.
Missing Claims Validation: Expired Tokens and Audience Confusion
JWT libraries validate the signature by default, but claim validation is often optional or must be configured explicitly. Penetration testers regularly find applications that accept expired tokens, tokens issued for different audiences, or tokens with future nbf (not before) claims.
Claims that must always be validated:
exp (expiration time): If the application does not check exp, an attacker who steals a JWT can use it indefinitely. Most libraries validate exp by default but have options to disable it for testing that are sometimes left in production code.
aud (audience): Identifies the intended recipient. If your API issues tokens with aud: api.service.com but also accepts tokens issued for aud: internal.service.com, an attacker can use a token from a lower-privilege service to access your API.
iss (issuer): Identifies the token issuer. If not validated, an attacker can create tokens signed with a different key from a different identity provider.
Explicit claim validation in Python (PyJWT):
decoded = jwt.decode(
token,
public_key,
algorithms=['RS256'],
options={
'require': ['exp', 'iss', 'aud', 'sub'], # require these claims to be present
'verify_exp': True,
'verify_iss': True,
'verify_aud': True,
},
audience='api.yourdomain.com',
issuer='https://auth.yourdomain.com'
)
In Node.js (jsonwebtoken):
jwt.verify(token, publicKey, {
algorithms: ['RS256'],
audience: 'api.yourdomain.com',
issuer: 'https://auth.yourdomain.com',
// exp is checked by default unless clockTolerance is set too high
});
Do not set clockTolerance above 60 seconds. A tolerance of 300+ seconds effectively extends token expiry by 5 minutes and is exploitable if tokens are stolen.
JWK Header Injection and SSRF via kid Parameter
Two less common but high-impact JWT attack vectors that pen testers find in more sophisticated implementations:
JWK Header Injection (CVE class): The OIDC spec allows JWTs to embed the verification key directly in the token header via the jwk parameter. An attacker generates a key pair, embeds the public key in the token header, signs the token with the corresponding private key, and if the application trusts the embedded key, verifies successfully. Fix: Never trust keys embedded in the token header. Always fetch verification keys from a trusted JWKS endpoint (configured at startup) or use a hardcoded key material.
Kid (Key ID) SSRF and SQL Injection: Some applications use the kid header parameter to look up the correct signing key in a database or from a file path. If the kid value is used in a SQL query or filesystem path without sanitization:
- SQL injection:
kid = "' UNION SELECT 'attacker-controlled-secret' --" - Path traversal:
kid = "../../dev/null"(empty file used as secret for unsigned verification)
Fix: Validate the kid value against a whitelist of known key IDs before using it to look up a key. Never construct a database query or file path directly from the JWT header value.
# WRONG - kid value used directly
with open(f"/keys/{header['kid']}.pem") as f:
key = f.read()
# CORRECT - whitelist check before key lookup
ALLOWED_KEY_IDS = {'2026-01', '2026-06', 'legacy-2025-07'}
if header.get('kid') not in ALLOWED_KEY_IDS:
raise ValueError("Unknown key ID")
key = KEY_STORE[header['kid']]
The bottom line
JWT implementation bugs are self-inflicted. The library is usually correct; the application code is not. Fix algorithm confusion by hardcoding the expected algorithm at every verification site. Fix weak secrets by using 256-bit random keys or switching to RS256. Fix claim validation gaps by explicitly requiring exp, iss, aud, and sub. Fix kid and jwk injection by never trusting token-header-supplied key material. Run jwt_tool or python-jwt against your own application before the pen test does.
Frequently asked questions
Should I use HS256 or RS256 for API authentication?
Use RS256 for any API where the token is verified by a service that did not issue it. RS256 (asymmetric) allows the verification service to check signatures using only the public key, without ever having access to the private signing key. This eliminates the shared-secret risk of HS256. HS256 is acceptable when a single service both issues and verifies its own tokens and the secret is stored securely. Use RS256 for multi-service architectures, external API consumers, and any scenario where token verification is distributed.
How do I test whether my application is vulnerable to JWT algorithm confusion?
Use jwt_tool (ticarpi/jwt_tool on GitHub): `python3 jwt_tool.py [your_jwt] -X a` runs the algorithm confusion attack automatically if it can find or infer the public key. PortSwigger's Burp Suite JWT Editor extension also automates algorithm confusion and none algorithm attacks. For a quick manual test: decode the JWT, change the header alg to 'none', remove the signature (keep the trailing dot), and submit. If the application returns a successful response, it is vulnerable.
What is the right JWT expiry duration for different token types?
Access tokens: 15-60 minutes. These should be short-lived because they are presented with every API request and exposure risk is high. Refresh tokens: 7-30 days with rotation on each use (rotating refresh token pattern). ID tokens: same as access token duration (they are for client-side identity display, not API authorization). Session cookies backed by JWT: align with your organization's session timeout policy. Never issue access tokens with no expiry. Never set clockTolerance above 60 seconds in production.
Do JWTs need to be encrypted (JWE) or is signing (JWS) sufficient?
For most API authentication use cases, signing (JWS/JWT) is sufficient. JWTs are base64-encoded, not encrypted, so anyone who captures the token can read the payload claims. If the JWT payload contains sensitive information (PII, internal role mappings, price data, sensitive entitlements), use JWE to encrypt the payload. If the payload contains only non-sensitive identifiers (sub, iss, aud, exp), signing without encryption is appropriate. Never put secrets, credentials, or sensitive personal data in a signed-only JWT payload.
What is token binding and does it prevent JWT theft?
Token binding (RFC 8471) was a proposed mechanism to cryptographically bind a token to the TLS channel it was issued over, so a stolen token would be useless on a different TLS connection. Browser and server support for token binding was limited and the standard was deprecated. The practical alternative for preventing JWT theft today is: use short expiry with refresh rotation, transmit JWTs only over HTTPS, store JWTs in memory rather than localStorage (prevents XSS exfiltration), and consider DPoP (Demonstrating Proof-of-Possession, RFC 9449) for sender-constrained access tokens in modern OAuth flows. DPoP binds the token to the client's private key, providing theft resistance without requiring TLS-layer token binding.
What is the 'algorithm confusion' JWT attack and how do I prevent it?
Algorithm confusion (also called algorithm substitution or alg:none attacks) occurs when a JWT library accepts a token signed with a different algorithm than the application expects. The classic variant: an attacker changes the alg header from RS256 (asymmetric, uses RSA private key to sign) to HS256 (symmetric, uses a shared secret) and re-signs the token using the RS256 public key as the HS256 secret. If the library is not configured to enforce a specific algorithm, it may accept the forged token. Prevention: always specify the expected algorithm explicitly in your JWT validation code rather than reading it from the token header (e.g., jwt.verify(token, key, { algorithms: ['RS256'] }) in Node.js). Never pass an algorithm list that includes both asymmetric and symmetric algorithms, and never accept alg:none.
Sources & references
Free resources
Critical CVE Reference Card 2025–2026
25 actively exploited vulnerabilities with CVSS scores, exploit status, and patch availability. Print it, pin it, share it with your SOC team.
Ransomware Incident Response Playbook
Step-by-step 24-hour IR checklist covering detection, containment, eradication, and recovery. Built for SOC teams, IR leads, and CISOs.
Get threat intel before your inbox does.
50,000+ security professionals read Decryption Digest for early warnings on zero-days, ransomware, and nation-state campaigns. Free, daily, no spam.
Unsubscribe anytime. We never sell your data.

Founder & Cybersecurity Evangelist, Decryption Digest
Cybersecurity professional with expertise in threat intelligence, vulnerability research, and enterprise security. Covers zero-days, ransomware, and nation-state operations for 50,000+ security professionals every morning.
Win a $2,495 Black Hat pass.
Full-access to Black Hat USA 2026 in Las Vegas. Subscribe free to enter.
