Skip to main content
the auth layer

Algorithm Confusion and the Attacks That Work on Real JWT Implementations

7 min read Chapter 11 of 45

Algorithm Confusion and the Attacks That Work

The Assumption

JWT libraries handle signature verification. The assumption: if you pass a token to a JWT library with your public key, the library validates correctly.

The attack relies on a design flaw in the JWT specification itself. The token header declares which algorithm was used to sign it. If the verifier trusts this header without restriction, an attacker can choose the algorithm.

This is not a theoretical vulnerability. CVE-2015-9235 affected multiple JWT libraries across languages. The attack is trivial to execute once you understand the mechanism, and the fix is a single configuration line in Spring Security.

The Attack

The “none” Algorithm Attack

The JWT spec allows "alg": "none" for unsigned tokens. An attacker modifies a valid token: changes the alg header to "none", modifies the payload claims (elevating privileges, changing tenant_id), and removes the signature segment (leaving it empty or a single dot).

If the verification library reads the alg header and sees "none", it skips signature verification entirely. The modified claims are accepted as valid.

# Original token (signed with RS256)
eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyLTEyMyIsInJvbGUiOiJ1c2VyIn0.signature

# Attacker's forged token (alg: none)
eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyLTEyMyIsInJvbGUiOiJhZG1pbiJ9.
                                                          ^^^^^ changed to admin

The HS256-with-Public-Key Attack

More dangerous because it works against libraries that correctly reject "none". The attack exploits the difference between asymmetric and symmetric algorithms.

Setup: The authorization server signs with RS256 (private key signs, public key verifies). The public key is available at the JWKS endpoint. The resource server has the public key for verification.

Attack:

  1. Attacker obtains the RSA public key (publicly available via JWKS).
  2. Attacker forges a token with modified claims and sets the header to "alg": "HS256".
  3. Attacker computes an HMAC-SHA256 signature over the forged header and payload, using the RSA public key bytes as the HMAC secret.
  4. Attacker sends the forged token to the resource server.

Verification at the resource server (vulnerable implementation):

  1. Library reads the header: "alg": "HS256".
  2. Library selects HMAC verification mode.
  3. Library needs a secret for HMAC. The configured “key” is the RSA public key.
  4. Library verifies HMAC using the public key bytes as the secret.
  5. The signature matches because the attacker used the same public key bytes to sign.
  6. Token validates. Forged claims are accepted.

The root cause: the library uses the algorithm from the token header to select the verification method, and the “key” parameter is ambiguous (it could be an RSA public key for asymmetric verification or a symmetric secret for HMAC verification). When the attacker switches the algorithm to HS256, the library reinterprets the RSA public key as an HMAC secret.

CVE-2015-9235

This CVE covers the algorithm confusion vulnerability across JWT implementations. Affected libraries included jwt-go, node-jsonwebtoken, pyjwt, and others. The fix in all cases: the verifier must specify which algorithm to accept, not read it from the token header.

The Spec or Mechanism

RFC 7515 Section 5.2 (Message Signature or MAC Validation) states:

The algorithm used to verify the JWS MUST be determined by the recipient. The algorithm in the header is advisory.

Many libraries implemented this incorrectly by trusting the header’s algorithm declaration rather than using a recipient-determined algorithm. The spec says the recipient decides. Correct implementations allow the recipient to specify the expected algorithm and reject tokens using any other algorithm.

Spring Security’s NimbusJwtDecoder uses the Nimbus JOSE+JWT library. When configured with a specific algorithm, it rejects tokens whose header declares a different algorithm:

NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(rsaPublicKey)
    .signatureAlgorithm(SignatureAlgorithm.RS256)
    .build();

This decoder:

  • Always uses RS256 for verification, regardless of what the token header says.
  • Rejects tokens with "alg": "none".
  • Rejects tokens with "alg": "HS256".
  • Only accepts tokens with "alg": "RS256" and a valid RSA signature.

When configured with a JWKS URI, the algorithm restriction is set at the JWSKeySelector level:

NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwksUri)
    .jwsAlgorithm(SignatureAlgorithm.RS256)
    .build();

The Implementation

// VULNERABLE: No algorithm restriction, trusts token header
@Bean
public JwtDecoder jwtDecoder() {
    // This configuration does not restrict algorithms.
    // A token with "alg": "none" or "alg": "HS256" may be accepted
    // depending on the underlying library version.
    return NimbusJwtDecoder.withPublicKey(rsaPublicKey).build();
}
// HARDENED: Explicit algorithm restriction
@Bean
public JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.withPublicKey(rsaPublicKey)
        .signatureAlgorithm(SignatureAlgorithm.RS256) // Only RS256 accepted
        .build();
}
// HARDENED: JWKS URI with algorithm restriction and additional claim validation
@Bean
public JwtDecoder jwtDecoder() {
    NimbusJwtDecoder decoder = NimbusJwtDecoder
        .withJwkSetUri("https://auth.saas.example/oauth2/jwks")
        .jwsAlgorithm(SignatureAlgorithm.RS256)
        .build();

    // Add custom validators beyond signature verification
    OAuth2TokenValidator<Jwt> defaultValidators =
        JwtValidators.createDefaultWithIssuer("https://auth.saas.example");

    OAuth2TokenValidator<Jwt> audienceValidator = token -> {
        if (token.getAudience().contains("core-api")) {
            return OAuth2TokenValidatorResult.success();
        }
        return OAuth2TokenValidatorResult.failure(
            new OAuth2Error("invalid_audience",
                "Token audience does not include core-api", null));
    };

    OAuth2TokenValidator<Jwt> tenantValidator = token -> {
        if (token.getClaim("tenant_id") != null) {
            return OAuth2TokenValidatorResult.success();
        }
        return OAuth2TokenValidatorResult.failure(
            new OAuth2Error("missing_tenant",
                "Token does not contain tenant_id claim", null));
    };

    decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
        defaultValidators, audienceValidator, tenantValidator));

    return decoder;
}

The hardened configuration adds three layers of defense:

  1. Algorithm restriction: only RS256 accepted, preventing algorithm confusion.
  2. Issuer validation: tokens from unexpected issuers are rejected.
  3. Custom claim validation: tokens without required claims (audience, tenant_id) are rejected.

Preventing JKU/X5U Key Injection

A related attack uses the jku (JWK Set URL) or x5u (X.509 URL) header parameters to point the verifier to an attacker-controlled key server. The attacker sets "jku": "https://attacker.example/keys" in the token header. A vulnerable verifier fetches keys from that URL and validates the token against the attacker’s key.

Spring Security’s NimbusJwtDecoder configured with withJwkSetUri() uses a fixed JWKS URI and does not honor jku or x5u headers in the token. This is secure by default. The vulnerability exists in custom implementations that dynamically resolve key sources from token headers.

// VULNERABLE: Custom decoder that resolves JWK URI from token header
// DO NOT IMPLEMENT THIS PATTERN
public Jwt decode(String token) {
    JWSHeader header = JWSHeader.parse(Base64URL.from(token.split("\\.")[0]));
    URI jku = header.getJWKURL(); // Attacker-controlled!
    JWKSet keys = JWKSet.load(jku.toURL()); // Fetches attacker's keys
    // Validates against attacker's key → token appears valid
}
// HARDENED: Fixed JWKS URI, token header ignored for key resolution
@Bean
public JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder
        .withJwkSetUri("https://auth.saas.example/oauth2/jwks") // Fixed URI
        .jwsAlgorithm(SignatureAlgorithm.RS256)
        .build();
    // NimbusJwtDecoder ignores jku/x5u in token headers.
    // Key resolution always uses the configured JWKS URI.
}

The Verification

@SpringBootTest
class AlgorithmConfusionTest {

    @Autowired
    private JwtDecoder jwtDecoder;

    @Test
    void rejectsTokenWithNoneAlgorithm() {
        // Forge a token with "alg": "none"
        String header = Base64.getUrlEncoder().withoutPadding()
            .encodeToString("{\"alg\":\"none\",\"typ\":\"JWT\"}".getBytes());
        String payload = Base64.getUrlEncoder().withoutPadding()
            .encodeToString("{\"sub\":\"attacker\",\"role\":\"admin\"}".getBytes());
        String forgedToken = header + "." + payload + ".";

        assertThatThrownBy(() -> jwtDecoder.decode(forgedToken))
            .isInstanceOf(JwtException.class);
    }

    @Test
    void rejectsTokenWithHS256Algorithm() throws Exception {
        // Forge a token signed with HMAC using the public key
        RSAPublicKey publicKey = loadPublicKey();
        byte[] keyBytes = publicKey.getEncoded();

        String header = Base64.getUrlEncoder().withoutPadding()
            .encodeToString("{\"alg\":\"HS256\",\"typ\":\"JWT\"}".getBytes());
        String payload = Base64.getUrlEncoder().withoutPadding()
            .encodeToString("{\"sub\":\"attacker\",\"tenant_id\":\"stolen\"}".getBytes());

        Mac hmac = Mac.getInstance("HmacSHA256");
        hmac.init(new SecretKeySpec(keyBytes, "HmacSHA256"));
        String signature = Base64.getUrlEncoder().withoutPadding()
            .encodeToString(hmac.doFinal((header + "." + payload).getBytes()));

        String forgedToken = header + "." + payload + "." + signature;

        assertThatThrownBy(() -> jwtDecoder.decode(forgedToken))
            .isInstanceOf(JwtException.class);
    }

    @Test
    void acceptsValidRS256Token() {
        // Create a legitimately signed token
        String validToken = createSignedToken("user-123", "acme-corp", "core-api");

        Jwt decoded = jwtDecoder.decode(validToken);

        assertThat(decoded.getSubject()).isEqualTo("user-123");
        assertThat(decoded.getClaim("tenant_id")).isEqualTo("acme-corp");
    }
}

The first two tests prove that the hardened configuration rejects algorithm confusion attacks. The third test confirms that legitimate tokens still validate. This is a regression test: if someone removes the .signatureAlgorithm(SignatureAlgorithm.RS256) line, the first two tests may start passing (accepting forged tokens), catching the vulnerability before deployment.