Root cause
Every JWT library ships two distinct functions: one that decodes a token (Base64URL decode + JSON parse), and one that verifies it (decode + signature check). The vulnerability appears when application code calls the decode-only function in a context that assumes the token has been validated. The signature is ignored entirely - the server processes whatever claims the token contains.
This sounds like an obvious mistake, but it recurs constantly because:
- Libraries make decode functions convenient and prominently documented for debugging
- Developers copy-paste example code that uses decode for simplicity
- Microservice architectures create trust boundaries where "the gateway already verified it"
- Testing always uses tokens created by the same server - so signature validity is always incidentally true
Vulnerable patterns by language
Node.js - jsonwebtoken
const jwt = require('jsonwebtoken');
// jwt.decode() returns the payload WITHOUT verifying the signature.
// It accepts any well-formed JWT including forged ones.
const decoded = jwt.decode(req.headers.authorization.split(' ')[1]);
const userId = decoded.sub; // attacker controls thisconst jwt = require('jsonwebtoken');
// jwt.verify() throws if signature is invalid or token is expired.
// Always pass an explicit algorithms array.
const decoded = jwt.verify(
req.headers.authorization.split(' ')[1],
process.env.JWT_SECRET,
{ algorithms: ['HS256'] }
);
const userId = decoded.sub; // cryptographically provenPython - PyJWT
import jwt
# options={"verify_signature": False} disables ALL verification.
# This pattern is documented as "for debugging" but appears in production.
payload = jwt.decode(
token,
options={"verify_signature": False}
)
# Also vulnerable: algorithms=[] (empty list tricks some versions)import jwt
payload = jwt.decode(
token,
secret_key,
algorithms=["HS256"], # non-empty allowlist
audience="api.example.com" # validate aud claim
)
# Raises jwt.InvalidSignatureError on tampered token
# Raises jwt.ExpiredSignatureError on expired tokenJava - jjwt
// Jwts.parserBuilder() without signedWith() → no signature check
Claims claims = Jwts.parserBuilder()
.build()
.parseClaimsJwt(token) // parseClaimsJwt, not parseClaimsJws
.getBody();
// Any unsigned or forged token passesClaims claims = Jwts.parserBuilder()
.setSigningKey(secretKey) // required
.requireAudience("api.example.com")
.build()
.parseClaimsJws(token) // parseClaimsJws (signed)
.getBody();The microservice trap
The most prevalent production variant is not a developer mistake on a single service, but an architectural assumption. A typical pattern:
Client → API Gateway (verifies JWT) → Auth headers stripped
↓
Internal Service A
Internal Service B ← also receives original JWT
Internal Service C and re-decodes it without verify
# The gateway verifies once. Internal services assume the gateway
# already validated and call jwt.decode() for convenience.
# An attacker with access to the internal network (or via SSRF)
# can call internal services directly with forged tokens.This pattern has been exploited in bug bounty programs targeting large SaaS platforms. The vulnerability surface is the internal services, not the public-facing gateway.
Detection techniques
Testing for this vulnerability requires crafting a token with a tampered payload and an invalid signature, then observing whether the server accepts it.
import base64, json
def b64url(data):
if isinstance(data, str):
data = data.encode()
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
# Take an existing valid token and change a claim
header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}))
payload = b64url(json.dumps({"sub": "admin", "role": "admin", "exp": 9999999999}))
fake_sig = b64url(b"invalidsignature")
forged = f"{header}.{payload}.{fake_sig}"
print(forged)
# Send to server. If 200 → unverified signature.
# If 401 → server validates signatures (good).- Most critical when
role,admin, orpermissionsclaims directly control authorization - escalation is trivial - Internal tooling and developer portals built quickly: signature verification is often skipped entirely in early iterations and never revisited
- Node.js with
jsonwebtoken:decode()andverify()both exist, and it is easy to call the wrong one - fuzz with a modified token and watch for a 200 - Microservices that trust tokens forwarded by an API gateway: the gateway verifies, the downstream service often does not
Mitigations
- Always call the verify function, never just decode - enforce this via code review or linting
- Pass an explicit
algorithmsallowlist - reject tokens with unexpected algorithm values - Validate
exp,aud, andissclaims after signature verification - Internal services must verify signatures independently - never trust "the gateway already checked it"
- Consider a shared middleware library that enforces correct verification, removing the choice from individual service developers