>samit_hota
Back to research
ETHICAL HACKING

Fixing the Three Persistent JWT Flaws: From Key Confusion to Weak Secrets

Samit Hota·
#jwt#application-security#cryptography#python

Most developers treat JSON Web Tokens as a settled abstraction, assuming that invoking a standard parsing library is enough to guarantee token authenticity. In practice, application-level integration errors and permissive library defaults consistently turn stateless identity tokens into authentication bypasses. The issue is rarely the underlying cryptography; it is the decision to let an untrusted token header dictate how the server verifies signatures.

When a server relies on token headers to decide which algorithm or key type to use, it hands control of the validation logic to the caller. A secure implementation must enforce strict policy decisions independently of token claims.

The Unsigned Token Fallacy (alg: none)

RFC 7519 includes a provision for unsigned tokens where the alg header parameter is set to none. This feature was intended for environments where token integrity is established out-of-band, such as mutual TLS tunnels. However, early parser implementations automatically honored the alg header without requiring explicit developer configuration, allowing attackers to strip the signature entirely.

An unsigned token uses a header explicitly declaring the omitted algorithm:

{
  "alg": "none",
  "typ": "JWT"
}

When Base64URL-encoded alongside a modified payload, the resulting token string ends with a trailing period and no signature segment:

eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhZG1pbiIsImlhdCI6MTcwMDAwMDAwMH0.

Vulnerable logic checks whether a signature exists only if the algorithm calls for one. If the parser dynamically inspects alg: none, it skips signature verification entirely and accepts the forged payload claims. Modern libraries disable none by default, but custom wrappers or legacy configurations still expose this behavior when dynamic algorithm parsing is enabled.

Exploiting Public Keys via Algorithm Confusion

Algorithm confusion occurs when an application uses asymmetric signing (such as RS256) but accepts symmetric algorithms (such as HS256) using the server’s public key as the HMAC secret.

In an RS256 architecture:

  • The authentication server signs tokens using a private RSA key.
  • Resource servers verify tokens using the corresponding public RSA key.

Because the public key is intentionally exposed (often via a public JWKS endpoint), an attacker can copy the public key content, change the token header alg parameter from RS256 to HS256, and sign the forged token locally using the public key string as the HMAC secret.

If the backend verification call accepts HS256 and passes the public key object or string to the verification function, the parser executes an HMAC-SHA256 calculation:

$$\text{Signature} = \text{HMAC-SHA256}(\text{Header} + “.” + \text{Payload}, \text{PublicKeyBytes})$$

Because the backend passes the public key bytes into the HMAC engine, the computed signature matches the forged token’s signature, causing the server to accept an invalid signature as legitimate.

Offline Key Recovery on Symmetric Tokens

When applications rely on symmetric HMAC signing (HS256), token security depends entirely on the entropy of the shared secret. Because JWT signatures are entirely self-contained, an attacker who obtains a single valid token can execute offline brute-force or dictionary attacks without generating network traffic to the target server.

Using audit tools such as Hashcat (specifically using format mode 16500 for JWT HMAC-SHA256), candidate secrets are hashed against the token’s header and payload until the output matches the signature segment:

hashcat -m 16500 sample_token.txt wordlist.txt

If the secret is a short string, a common word, or a default configuration value (like secret or development), high-throughput offline cracking will recover the key in seconds. Once recovered, an attacker can mint valid arbitrary tokens with any chosen claims.

Building a Defensible Verification Routine

Preventing all three vulnerabilities requires enforcing strict rules before token verification takes place:

  1. Never parse algorithm choices dynamically from the token header.
  2. Maintain strict separation between symmetric key stores and asymmetric public keys.
  3. Enforce strong entropy requirements on symmetric secrets (minimum 256 bits).

Below is a complete Python implementation using PyJWT that explicitly mitigates alg:none, algorithm confusion, and weak secrets by enforcing explicit algorithm whitelisting and proper key type bindings.

import jwt
from cryptography.x509 import load_pem_x509_certificate
from typing import Dict, Any

# Load the public key explicitly as an RSA Public Key object
PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz6A...
-----END PUBLIC KEY-----"""

def verify_asymmetric_token(token_string: str) -> Dict[str, Any]:
    """
    Verifies an RS256 token securely.
    Rejects alg:none and HS256 confusion by explicitly locking the algorithm.
    """
    try:
        # Enforce exact algorithm expectation.
        # Passing algorithms=["RS256"] explicitly rejects 'none' and 'HS256'.
        payload = jwt.decode(
            token_string,
            PUBLIC_KEY_PEM,
            algorithms=["RS256"],
            options={
                "verify_signature": True,
                "verify_exp": True,
                "require": ["exp", "sub", "iss"]
            },
            issuer="https://auth.example.com"
        )
        return payload
    except jwt.InvalidAlgorithmError:
        raise ValueError("Rejected: Algorithm mismatch or forbidden algorithm detected.")
    except jwt.InvalidSignatureError:
        raise ValueError("Rejected: Invalid signature.")
    except jwt.PyJWTError as e:
        raise ValueError(f"Rejected: Token validation failed - {str(e)}")

def verify_symmetric_token(token_string: str, hmac_secret: bytes) -> Dict[str, Any]:
    """
    Verifies an HS256 token securely while enforcing key entropy constraints.
    """
    # Enforce minimum secret length (256 bits / 32 bytes)
    if len(hmac_secret) < 32:
        raise ValueError("Security Failure: HMAC secret must be at least 32 bytes.")

    try:
        payload = jwt.decode(
            token_string,
            hmac_secret,
            algorithms=["HS256"],
            options={
                "verify_signature": True,
                "verify_exp": True,
                "require": ["exp", "sub"]
            }
        )
        return payload
    except jwt.PyJWTError as e:
        raise ValueError(f"Rejected: Symmetric token validation failed - {str(e)}")

By decoupling verification parameters from token-supplied headers and explicitly whitelisting expected algorithms in code, the parser guarantees that signature validation behaves deterministically regardless of what an attacker submits.

Want a second set of eyes on your security posture?

Let's talk about where your real exposure is.

Book an advisory call