Unverified Signature

The server decodes the token but never calls the cryptographic verification function - any forged payload is accepted.

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

Vulnerable
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 this
Secure
const 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 proven

Python - PyJWT

Vulnerable
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)
Secure
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 token

Java - jjwt

Vulnerable (Java)
// Jwts.parserBuilder() without signedWith() → no signature check
Claims claims = Jwts.parserBuilder()
    .build()
    .parseClaimsJwt(token)   // parseClaimsJwt, not parseClaimsJws
    .getBody();
// Any unsigned or forged token passes
Secure (Java)
Claims 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:

Vulnerable microservice architecture
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.

Zero-trust internal traffic
Internal services should verify JWT signatures even when behind an API gateway. A compromised upstream service, an SSRF vulnerability, or a misconfigured network route can all deliver attacker-controlled tokens to a service that skips verification.

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.

Crafting a test token with a broken signature
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).
Real-world impact
  • Reported on HackerOne with payouts ranging $500-$25,000 depending on impact scope
  • Most critical when role, admin, or permissions claims control authorization decisions
  • Common in internal tooling built quickly without security review, then exposed via developer portals
  • Node.js jsonwebtoken: easy to call decode() vs verify() - accounts for the majority of bug reports in this category

Mitigations

  • Always call the verify function, never just decode - enforce this via code review or linting
  • Pass an explicit algorithms allowlist - reject tokens with unexpected algorithm values
  • Validate exp, aud, and iss claims 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
JWT Structure & InternalsAlgorithm None
GitHub
JWT Arsenal_
Loading cryptographic engineOK
Importing exploit modulesOK
Verifying secure contextOK
All systems operational
100% CLIENT-SIDE · NO DATA LEAVES YOUR BROWSER