How JWT Libraries Block Algorithm Confusion: Key Lessons for Code Review

Published: 20 Nov 2024

When I wrote the first lab on algorithm confusion, I remember spending a bit of time trying to find a vulnerable library. As an attacker, you need a few things to go wrong to be able to exploit algorithm confusion attacks. In this blog post, we will cover why JWT libraries are not usually vulnerable to algorithm confusion.

A big part of learning security code review is to learn how developers commonly block attacks. The more knowledgeable you are about common ways to block attacks, the faster you are at reviewing code and the better you are at detecting anomalies that may be worth investigating.

What is algorithm confusion?

With JWT, the attacker can pick the algorithm used to verify how a token is signed as the algorithm is based on the alg attribute of the header.

Algorithm confusion attacks happen when an application uses asymmetric signature (RSA or ECDSA). When developers verify the signature they write code that looks something like this:


jwt.verify(public_key)

They are using the public key public_key to verify the signature. Under the hood, if for example the application uses ECDSA, the token will have a header indicating that ECDSA should be used and the following will happen:

ecdsa.verify(public_key)

However, as discussed earlier, the algorithm used is usually based on the header alg and is user-controlled. Therefore, if nothing prevents an attacker from picking another algorithm, the attacker can decide to change alg from ECDSA to HMAC and the code may end up executing:

hmac.verify(public_key)

This allows attackers to sign the token with the public key. Attackers can potentially discover the public key (since it's public, there is no reason to hide it) or they can recover it from one (for ECDSA) or multiple (for RSA) signatures.

PentesterLab offers a few challenges on exploiting this:

Now that we have a bit of background on algorithm confusion, let’s look at how JWT libraries usually prevent them!

How Are Algorithm Confusion Attacks Usually Prevented?

There are multiple ways to prevent algorithm confusion attacks, below are the most common ones I found during my code reviews.

Only supporting one family of algorithms

Sometimes KISS, Keep It Simple Stupid, works a charm for security, you eliminate the risk of algorithm confusion vulnerability if you only support one algorithm, for example, brianvoe/sjwt (Go) and Corviz/jwt (PHP) only support HMAC-signed tokens. brianvoe/sjwt goes even further; it only supports HMAC with SHA256. It doesn’t support ECDSA or RSA, so there’s no risk of algorithm confusion here! A smaller or simpler library can be the answer to security issues relying on complexity.

Matching the alg header to an algorithm set by the developer

Another way to prevent the attack is to validate the algorithm in the header and make sure it matches the one used to call verify(). We can find an example of this in garyf/json_web_token (Ruby):

    def verify(jws, algorithm, key = nil)
      validate_alg_match(jws, algorithm)
      […]

    def validate_alg_match(jws, algorithm)
      header = decoded_header_json_to_hash(jws)
      unless alg_parameter(header) == algorithm
        fail("Algorithm not matching 'alg' header parameter")
      end

As part of the call to verify(), the library requires developers using it to specify the algorithm to be supported. Since algorithm confusion attacks rely on the application blindly trusting the alg header, this mitigates the attack.

This also kills the None algorithm attack since the algorithm is enforced. One stone, two birds.

Not relying on the alg header

A similar approach can be found in nowakowskir/php-jwt (PHP). The library also forces the developers to explicitly indicate which algorithm should be supported.

class JWT       
{               
            
    /**         
     * List of available algorithm keys.
     */ 
    const ALGORITHM_HS256 = 'HS256';
    const ALGORITHM_HS384 = 'HS384';
    const ALGORITHM_HS512 = 'HS512';
    const ALGORITHM_RS256 = 'RS256';
    const ALGORITHM_RS384 = 'RS384';
    const ALGORITHM_RS512 = 'RS512';
            
    /** 
     * Mapping of available algorithm keys with their types and target algorithms.
     */ 
    const ALGORITHMS = [
        self::ALGORITHM_HS256 => ['hash_hmac', 'SHA256'],
        self::ALGORITHM_HS384 => ['hash_hmac', 'SHA384'],
        self::ALGORITHM_HS512 => ['hash_hmac', 'SHA512'],
        self::ALGORITHM_RS256 => ['openssl', 'SHA256'],
        self::ALGORITHM_RS384 => ['openssl', 'SHA384'],
        self::ALGORITHM_RS512 => ['openssl', 'SHA512'],
    ];

[...] 

    public static function validate(TokenEncoded $tokenEncoded, 

                             string $key, string $algorithm, ?int $leeway = null, 
                             ?array $claimsExclusions = null): bool
    {
        $tokenDecoded = self::decode($tokenEncoded);

        $signature = Base64Url::decode($tokenEncoded->getSignature());
        $payload = $tokenDecoded->getPayload();
        
        list($function, $type) = self::getAlgorithmData($algorithm);

        switch ($function) {
            case 'hash_hmac':
                if (hash_equals(hash_hmac($type, $tokenEncoded->getMessage(), $key, true), $signature) !== true) {
                    throw new IntegrityViolationException('Invalid signature');
                }
                break;
            case 'openssl':
                if (openssl_verify($tokenEncoded->getMessage(), $signature, $key, $type) !== 1) {
                    throw new IntegrityViolationException('Invalid signature');
                }
                break;
            default:
                throw new UnsupportedAlgorithmException('Unsupported algorithm type');
                break;

[...]


    public static function getAlgorithmData(string $algorithm): array
    {
        Validation::checkAlgorithmSupported($algorithm);

        return self::ALGORITHMS[$algorithm];
    }

The validate() method requires developers to provide an algorithm $algorithm, and use it to pick the function used for the verification. For example, if developers use HS256, the library is going to use ['hash_hmac', 'SHA256']. The library completely ignores the header alg provided in the JWT.

As a side note, it is worth looking at checkAlgorithmSupported() to see how they handle the None algorithm:

    public static function checkAlgorithmSupported(string $algorithm)
    {
        if (strtolower($algorithm) === 'none') {
            throw new InsecureTokenException('Unsecure token are not supported: none algorithm provided');
        }
       
        if (! array_key_exists($algorithm, JWT::ALGORITHMS)) {
            throw new UnsupportedAlgorithmException('Invalid algorithm');
        }
    }

The main difference between this approach and the previous one is that this library completely ignores the alg header and uses only the algorithm provided by developers leveraging the library .

Doing both!

The library auth0/java-jwt (Java) uses both of the previous methods.

You can verify a token using the code:

    Algorithm algorithm = Algorithm.RSA256(rsaPublicKey, rsaPrivateKey);
    JWTVerifier verifier = JWT.require(algorithm).build();
        
    decodedJWT = verifier.verify(token);

Just by looking at this code, it's already pretty obvious that this is unlikely to be vulnerable to algorithm confusion since everything seems to rely on the algorithm (Algorithm.RSA256(rsaPublicKey, rsaPrivateKey)) set by the developers: JWTVerifier verifier = JWT.require(algorithm).build();.

This code will end up calling the following:

    public DecodedJWT verify(DecodedJWT jwt) throws JWTVerificationException {
        verifyAlgorithm(jwt, algorithm);
        algorithm.verify(jwt);
        verifyClaims(jwt, expectedChecks);
        return jwt;
    }

    private void verifyAlgorithm(DecodedJWT jwt, Algorithm expectedAlgorithm) throws AlgorithmMismatchException {
        if (!expectedAlgorithm.getName().equals(jwt.getAlgorithm())) {
            throw new AlgorithmMismatchException(
                    "The provided Algorithm doesn't match the one defined in the JWT's Header.");
        }
    }

We can see that when verifying a token, the library first calls verifyAlgorithm(...) that checks that the algorithm in the header matches the expectedAlgorithm.getName(). After doing that, the code does not even use the algorithm from the header and relies on the algorithm defined by the developer when calling algorithm.verify(jwt);. Defence in depth!

Preventing the use of public keys

Another mechanism is to try to detect the usage of public keys when HMAC based algorithms are used. jpadilla/pyjwt (Python) is a great example of this technique:

class HMACAlgorithm(Algorithm):
    """
    Performs signing and verification operations using HMAC
    and the specified hash function.
    """
    
    SHA256 = hashlib.sha256
    SHA384 = hashlib.sha384
    SHA512 = hashlib.sha512

    def __init__(self, hash_alg):
        self.hash_alg = hash_alg 
    
    def prepare_key(self, key):
        key = force_bytes(key)
        
        if is_pem_format(key) or is_ssh_key(key):
            raise InvalidKeyError(
                "The specified key is an asymmetric key or x509 certificate and"
                " should not be used as an HMAC secret."
            )
    
        return key

As part of the prepare_key() call, used before verifying a signature, the code throws an exception if the secret or key is_pem_format() or is_ssh_key().

And we can see the code of the function is_pem_format() below:

_PEM_RE = re.compile(
    b"----[- ]BEGIN (" 
    + b"|".join(_PEMS)
    + b""")[- ]----\r?
.+?\r?
----[- ]END \\1[- ]----\r?\n?""",
    re.DOTALL,
)       

def is_pem_format(key: bytes) -> bool: 
    return bool(_PEM_RE.search(key))

This method of blocking the attack is obviously not ideal as it relies on having the perfect list of all potential formats for the key. It also prevents people from having some keywords in their secret when they are legitimately signing with HMAC. What if a legitimate HMAC secret contains "BEGIN RSA PUBLIC KEY"?


We can see in the git history of pyjwt that the list was initially incomplete and also that the project had difficulties keeping the supported algorithms and the blocklist in sync with CVE-2022-29217 being an example of a bypass.


Probably not the approach I would recommend if used on its own. But it may be part of a defence in depth strategy.

Validating the type of the secret or key provided by developers

Another way to prevent algorithm confusion attacks is to rely on the type of the variable used to validate the signature. For example, WebdevCave/jwt-php (PHP) validates that the Secret used to validate the token is an instance of a valid type of secret using the method validateSecret():

abstract class Signer
    public function setSecret(Secret $secret): void
    {
        if (!$this->validateSecret($secret)) {
            throw new InvalidArgumentException('Invalid secret provided');
        }

        $this->secret = $secret;
    }

And we can see that the HsSigner used to verify tokens signed with HMAC ensure that the secret is an instance of HsSecret:

abstract class HsSigner extends Signer
[...]

    protected function validateSecret(Secret $secret): bool
    {
        return $secret instanceof HsSecret;
    }

Since developers using RSA will have to use RsSecret when verifying an RSA signature, this prevents algorithm confusion attacks. You just cannot verify a signature using HMAC and RsSecret since RsSecret is not an instance of HsSecret.

The same behaviour can be observed in jose4j (Java):

public class HmacUsingShaAlgorithm extends AlgorithmInfo implements JsonWebSignatureAlgorithm
{
[...]
    public boolean verifySignature(byte[] signatureBytes, Key key, byte[] securedInputBytes, ProviderContext providerContext) throws JoseException
    {
        if (!(key instanceof SecretKey))
        {
            throw new InvalidKeyException(key.getClass() + " cannot be used for HMAC verification.");
        }

        Mac mac = getMacInstance(key, providerContext);
        byte[] calculatedSigature = mac.doFinal(securedInputBytes);

        return ByteUtil.secureEquals(signatureBytes, calculatedSigature);
    }

Autodetection of the algorithm based on the key's type

Another way that could potentially be used is to detect the algorithm based on the type of the key, nov/json-jwt (Ruby) uses something similar when a developer signs (not verifies) a token.

    def autodetected_algorithm_from(private_key_or_secret)
      private_key_or_secret = with_jwk_support private_key_or_secret
      case private_key_or_secret
      when String 
        :HS256
      when OpenSSL::PKey::RSA
        :RS256
      when OpenSSL::PKey::EC
        case private_key_or_secret.group.curve_name
        when 'prime256v1'
          :ES256
        when 'secp384r1'
          :ES384
        when 'secp521r1'
          :ES512
        when 'secp256k1'
          :ES256K
        else
          raise UnknownAlgorithm.new('Unknown EC Curve')
        end
      else
        raise UnexpectedAlgorithm.new('Signature algorithm auto-detection failed')
      end
    end


Based on the type of the value private_key_or_secret, the library knows what algorithm it should use. This code is not used as part of the verification, it is used when a token gets signed using sign!().

This library is not vulnerable to algorithm confusion attacks for another reason... The function valid? calls sign() when HMAC-based algorithms are used as you can see in the code below:

    def sign(signature_base_string, private_key_or_secret)
      private_key_or_secret = with_jwk_support private_key_or_secret
      case
      when hmac?
        secret = private_key_or_secret
        OpenSSL::HMAC.digest digest, secret, signature_base_string

And the call to OpenSSL::HMAC.digest will fail with a TypeError since there is no implicit conversion of OpenSSL::PKey::RSA into String. OpenSSL doesn't know how to convert a public key to a String and throws an exception. Preventing the exploitation.

Before we finish!

When writing this article, I wanted to find a non-Java (because "Java is Lava") examples of blocking via Type and decided to review a few scalas implementations. 
As part of this review, I found a (unmaintained but still listed on jwt.io) library that was vulnerable to Algorithm Confusion Attacks. This is a great example of why reading secure code allows you to find bugs. By reading a few libraries I knew that JWT libraries with types like Java usually force the developers to pick the algorithm and also use public key java.security.PublicKey to enforce the type of the key with RSA.

This is when I came across janjaali/spray-jwt (Scala).

You can find a snippet from the documentation on how to verify a token for one of these libraries below:



import org.janjaali.sprayjwt.Jwt
[...]
val token = "..."
val jsValueOpt = Jwt.decode(token, "super_fancy_secret")


If you have been paying attention to the rest of this article, you immediately know that this doesn't sound great. Developers leveraging the library don't need to specify an algorithm. First red flag (🚩)!

The code used to pick the algorithm can be find below:

  def decodeAsString(token: String, secret: String): Try[String] = {
    val splitToken = token.split("\\.")
    if (splitToken.length != 3) {
      throw new InvalidJwtException("JWT must have form header.payload.signature")
    }

    val header = splitToken(0)
    val payload = splitToken(1)
    val data = s"$header.$payload"

    val signature = splitToken(2)

    val algorithm = getAlgorithmFromHeader(header)

  private def getAlgorithmFromHeader(header: String): HashingAlgorithm = {
    val headerDecoded = Base64Decoder.decodeAsString(header)
    val jwtHeader = headerDecoded.parseJson.convertTo[JwtHeader]
    jwtHeader.algorithm
  }

We can see that the algorithm seems to be based on the header alg. Second red flag (🚩🚩)!

Time to keep digging! We can then see that even the underlying code used to verify a token using RSA relies on a string. Huge third red flag (🚩🚩🚩)!

  override def validate(data: String, signature: String, secret: String): Boolean = {
    val key = getPublicKey(secret)

    val dataByteArray = ByteEncoder.getBytes(data)

    val rsaSignature = Signature.getInstance(cryptoAlgName, provider)
    rsaSignature.initVerify(key)
    rsaSignature.update(dataByteArray)
    rsaSignature.verify(Base64Decoder.decode(signature))
  }

  private def getPublicKey(str: String): PublicKey = {
    val pemParser = new PEMParser(new StringReader(str))
    val keyPair = pemParser.readObject()

    Option(keyPair) match {
      case Some(publicKeyInfo: SubjectPublicKeyInfo) =>
        val converter = new JcaPEMKeyConverter
        converter.getPublicKey(publicKeyInfo)
      case _ => throw new IOException(s"Invalid key for $cryptoAlgName")
    }
  }

We see that the RSA "validation" works with a public key as a String (that is even named "secret" to make things worst).

If developers leverage this library for RSA validation, they are likely to write something like: 


// Load and Base64 encode the RSA public key
val publicKeyStr = loadPublicKeyAsString(publicKeyPath) 
// Use RS256 with Base64 encoded public key
val decodedTry: Try[JsValue] = Jwt.decode(token, publicKeyStr) 

Since no algorithm is enforced, the library is a classic example of an algorithm confusion vulnerability. After getting a simple snippet of code leveraging this library, I was able to confirm that the library was vulnerable to algorithm confusion:

  // Code to generate a JWT token using RS256
  def generateToken(): String = {
    val payload = """{"username":"test"}""" // Hardcoded payload

    val privateKeyStr = """-----BEGIN RSA PRIVATE KEY-----
[...]
-----END RSA PRIVATE KEY-----"""
    val publicKeyStr = """-----BEGIN PUBLIC KEY-----
[...]
-----END PUBLIC KEY-----"""


    // Use RS256 with private key
    val tokenTry: Try[String] = Jwt.encode(payload, privateKeyStr, RS256)

    // Use HS256 with public key
    //val tokenTry: Try[String] = Jwt.encode(payload, publicKeyStr, HS256)

    tokenTry match {
      case Success(token) =>
        println(s"Generated JWT token: $token")
        token
      case Failure(exception) =>
        println(s"Failed to generate JWT token: ${exception.getMessage}")
        ""
    }

  }
  // Code to verify a JWT token using RS256
  def verifyToken(token: String): Unit = {
     val publicKeyStr = """-----BEGIN PUBLIC KEY-----
[...]
-----END PUBLIC KEY-----"""

    val decodedTry: Try[JsValue] = Jwt.decode(token, publicKeyStr) 

    decodedTry match {
      case Success(jsValue: JsValue) =>
        println(s"Decoded JWT token: ${jsValue.prettyPrint}")
      case Failure(exception) =>
        println(s"Failed to verify/ decode the JWT token: ${exception.getMessage}")
    }
  }

  def main(args: Array[String]): Unit = {
    // Generate a token using RSA private key
    val token = generateToken()
    println(token)

    // Verify the generated token
    verifyToken(token)

    //HS256, our token signed with the public RSA key as a String 
    val token2 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3QifQ.RmOq9Y9kdLk/GrjATbWDySo7LlwfIam5weJzeZ5CYGM"

    println("TOKEN with HS256")
    verifyToken(token2 )
  }

I reached out to the project a month ago but received no response. The library appears to be end-of-life, and it’s unlikely that many are still using it, especially with RSA. However, it serves as an excellent example of how understanding defensive practices in code reviews can help highlight vulnerable code.

Conclusion

Hopefully, this blog post gave you valuable insights into common techniques for preventing algorithm confusion attacks. As you’ve seen, understanding how developers block attacks not only helps you spot potential vulnerabilities faster but also sharpens your ability to detect subtle anomalies in code that might otherwise go unnoticed.

If you found this post helpful and want to further develop your skills, consider diving deeper into security code review training. Our training equips you with the knowledge and hands-on experience to identify complex vulnerabilities in real-world codebases. Whether you're a developer, security engineer, or pentester, mastering code review can significantly enhance your ability to secure applications and find issues that automated tools often miss.

Thanks for reading, and happy reviewing!

Photo of Louis Nyffenegger
Written by Louis Nyffenegger
Founder and CEO @PentesterLab