VNX-JWT-001 – JWT Signature Verification Disabled

Overview

A token is decoded and trusted without its signature being verified: PyJWT/python-jose options={"verify_signature": False} or verify=False, jsonwebtoken jwt.decode() used on a request token in a file that never calls jwt.verify(), jwt-simple decode(token, key, true), golang-jwt ParseUnverified() in a file that never calls Parse/ParseWithClaims, jjwt Jwts.parser().setSigningKey(k).parse(t) (which accepts an unsigned token), Auth0 JWT.decode() with no JWT.require() verifier, ruby-jwt JWT.decode(token, key, false), and .NET RequireSignedTokens = false or a SignatureValidator that returns a new token unchecked. Accepting the none algorithm is reported separately by VNX-JWT-004, and unsigned-token APIs (jjwt unsecured()/parseClaimsJwt, Nimbus PlainJWT, jose UnsecuredJWT) by VNX-SEC-015, which leaves the shapes listed here to this rule. This completely disables cryptographic authentication of tokens, allowing an attacker to forge arbitrary tokens with any payload — including elevated privilege claims — and have them accepted as valid without any key material.

Severity: Critical | CWE: CWE-347 – Improper Verification of Cryptographic Signature | CAPEC: CAPEC-196 – Session Credential Falsification through Forging

OWASP ASVS v4: 3.5.3 – Stateless session tokens must use digital signatures and other countermeasures to protect against null cipher and key substitution attacks.

Why This Matters

JWT authentication relies entirely on signature verification. The signature is a cryptographic proof that the token was issued by a party holding the signing key and that its contents have not been altered. Disabling verification means any JSON payload — base64url-encoded and passed as a token — will be accepted regardless of its source or content.

The alg:none Attack

The none algorithm attack is one of the oldest and best-documented JWT vulnerabilities, originally disclosed in 2015 and covered by CVE-2015-9235 and multiple related CVEs. The steps require no cryptographic knowledge:

  1. Obtain any valid JWT (including one issued to your own low-privilege account).
  2. Base64url-decode the header (no key required — this is public data).
  3. Change "alg" from "HS256" (or any signing algorithm) to "none".
  4. Modify the payload to claim any identity, role, or permission you want.
  5. Re-encode header and payload, append an empty signature section (a trailing dot), and submit.

A vulnerable library that includes "none" in its accepted algorithms list will skip signature validation entirely and accept the forged token as authentic.

Algorithm Confusion Attack

A more sophisticated attack targets services that use asymmetric algorithms (RS256, ES256). If the verify call accepts both RS256 and HS256:

  1. Obtain the server’s RSA public key — often exposed at a /.well-known/jwks.json endpoint.
  2. Forge a token signed with HS256 using the public key bytes as the HMAC secret.
  3. Submit the token with "alg": "HS256" in the header.

A vulnerable library that calls a single algorithm-agnostic verify() without checking the expected algorithm will use the public key as the HMAC secret, confirm the HMAC signature, and accept the forged token. The defence is always specifying the exact expected algorithm server-side and never trusting the alg field from the token header.

Library Defaults

Library"none" accepted by default?
PyJWT (Python)No. Since v2.0, algorithms is a required parameter. "none" must be explicitly listed to be permitted.
jsonwebtoken (Node.js)v8.x and earlier: yes — omitting the algorithms option could result in "none" being accepted. v9.0.0+: no — "none" was removed from default behaviour. Upgrade immediately if on v8.
jose (Node.js)No. Algorithm is always required at verification time; "none" is never implicitly accepted.
java-jwt (Java/Auth0)No. Algorithm is a mandatory, typed constructor parameter. There is no Algorithm.NONE constant; you must explicitly construct a signing algorithm.
golang-jwt (Go)No. alg=none is rejected unless the caller explicitly passes jwt.UnsafeAllowNoneSignatureType as the key — a deliberately conspicuous opt-in.

What Gets Flagged

# FLAGGED: verify_signature=False bypasses all signature checks (PyJWT)
payload = jwt.decode(token, options={"verify_signature": False})

# FLAGGED: verify=False in older PyJWT API
payload = jwt.decode(token, secret, verify=False)

# FLAGGED: bare decode imported from PyJWT
return decode(token, options={"verify_signature": False, "verify_exp": False})
// FLAGGED: decode() never checks the signature, and this file never calls jwt.verify()
const token = req.headers.authorization.split(' ')[1];
const user = jwt.decode(token);

// FLAGGED: jwt-simple with noVerify = true
return jwt.decode(raw, process.env.SESSION_SECRET, true);
// FLAGGED: jjwt parse() accepts an unsigned token even with a signing key set
Jwt jwt = Jwts.parser().setSigningKey(key).parse(token);
// FLAGGED: claims read without verification, and nothing in the file verifies
_, _, err := jwt.NewParser().ParseUnverified(raw, claims)
// FLAGGED
RequireSignedTokens = false,
SignatureValidator = (token, parameters) => new JwtSecurityToken(token),

Not flagged: options={"verify_exp": False} (the signature is still checked), decoding the header to pick a key and then calling jwt.verify(), a browser-side jwt.decode() of a token read from storage, jjwt parseClaimsJws() / parseSignedClaims() and the 0.12 verifyWith() builder.

Remediation

Python (PyJWT)

Always specify a concrete algorithm list that excludes "none". For symmetric secrets:

import jwt
import os

SECRET_KEY = os.environ["JWT_SECRET"]

# SAFE: explicit algorithm whitelist — signature is always verified
payload = jwt.decode(
    token,
    SECRET_KEY,
    algorithms=["HS256"],
)

For asymmetric keys (RS256 or ES256), pass the PEM public key and specify the exact algorithm:

import jwt

with open("public.pem") as f:
    PUBLIC_KEY = f.read()

# SAFE: RS256 only — algorithm confusion attack is not possible
payload = jwt.decode(
    token,
    PUBLIC_KEY,
    algorithms=["RS256"],  # never mix asymmetric and symmetric algorithms here
)

Node.js (jsonwebtoken v9+)

const jwt = require("jsonwebtoken");

// SAFE: explicit algorithm list, 'none' is absent
const payload = jwt.verify(token, process.env.JWT_SECRET, {
  algorithms: ["HS256"],
});

Java (Auth0 java-jwt)

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;

// Algorithm.HMAC256() enforces HMAC-SHA256 — 'none' cannot be constructed
Algorithm algorithm = Algorithm.HMAC256(System.getenv("JWT_SECRET"));
DecodedJWT verified = JWT.require(algorithm)
    .build()
    .verify(token);

Go (golang-jwt/jwt v5)

import (
    "fmt"
    "os"
    "github.com/golang-jwt/jwt/v5"
)

secretKey := []byte(os.Getenv("JWT_SECRET"))

// WithValidMethods rejects any algorithm not in the explicit list
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
    if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
        return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
    }
    return secretKey, nil
}, jwt.WithValidMethods([]string{"HS256"}))
if err != nil {
    // token is invalid or signature failed
}

Key rules

  1. Never include "none" in any algorithms list — in encode or decode calls.
  2. Always specify the exact expected algorithm server-side — never read alg from the token header to decide which algorithm to use for verification.
  3. Do not mix asymmetric (RS256, ES256) and symmetric (HS256) algorithms in the same verify call — this enables algorithm confusion attacks.
  4. If you are running jsonwebtoken v8 or earlier, upgrade to v9+ immediately.

References