Goal:

  • πŸ”‘ Fetch the server's public key from /jwks.json β€” import the JWK into JWT Editor Keys as a New RSA Key
  • πŸ“‹ Right-click the RSA key β†’ "Copy Public Key as PEM" β€” Base64-encode it in Burp's Decoder tab
  • πŸ” Create a New Symmetric Key β€” replace the k value with the Base64-encoded PEM β€” save it
  • ✏️ In Burp Repeater, change the JWT header's alg from RS256 to HS256 β€” change the payload's sub to "administrator"
  • ✍️ Click Sign β€” select your symmetric key β€” check "Don't modify header" β€” the token is now signed with the server's own public key as the HMAC secret
  • πŸ—ΊοΈ Send GET /admin β€” the server verifies with its public key as the HMAC secret β€” it matches β€” admin panel loads
  • πŸ—‘οΈ Delete the user carlos β†’ lab solved!

🧠 Concept Recap

JWT algorithm confusion (also called key confusion) exploits a server that uses RSA (RS256) to sign tokens but fails to enforce the algorithm server-side. Because the alg header is client-controlled, an attacker can switch it from RS256 to HS256. If the server then uses its RSA public key as the HMAC secret for verification, and the attacker already knows that public key (exposed at /jwks.json), the attacker can sign any token with that same public key and the server will accept it. The public key β€” normally safe to share β€” becomes the secret that forges unlimited valid tokens.

πŸ“Š Normal RS256 Flow vs Algorithm Confusion Attack

Feature              Normal RS256               Algorithm Confusion Attack
──────────────────────────────────────────────────────────────────────────────
alg value            RS256                      HS256 (changed by attacker)

Server signs with    RSA private key            β€” (attacker forges
                     (secret)                   the token)

Attacker signs       ❌ Can't β€”                 βœ… Public key as
with                 no private key             HMAC secret

Server verifies      RSA public key             Public key reused as
with                                            HMAC secret

Result               βœ… Secure                  ❌ Attacker-forged
                                                token accepted
Why the public key exposure makes this catastrophic:

Normal RS256:
  Sign:   private_key (secret - only the server has it)
  Verify: public_key  (safe to publish - anyone can verify)
  β†’ Attacker knows public key but can't sign β†’ secure

After alg confusion to HS256:
  Sign:   public_key  (attacker already has this!)
  Verify: public_key  (server uses same key for HMAC)
  β†’ Attacker can sign anything with the public key β†’ fully forged tokens βœ…

The full attack flow:

Log in as wiener β†’ capture JWT in Burp Repeater
         ↓
GET /jwks.json β†’ copy the JWK object from the keys array
         ↓
JWT Editor Keys β†’ New RSA Key β†’ paste JWK β†’ save
Right-click RSA key β†’ "Copy Public Key as PEM"
Burp Decoder β†’ paste PEM β†’ encode as Base64 β†’ copy result
         ↓
JWT Editor Keys β†’ New Symmetric Key β†’ Generate
Replace k value with Base64 PEM β†’ save
         ↓
In Repeater β†’ JSON Web Token tab:
  Header:  { "alg": "HS256", ... }    ← changed from RS256
  Payload: { "sub": "administrator", ... }
         ↓
Sign β†’ select symmetric key β†’ "Don't modify header" β†’ OK
  β†’ Token signed with HMAC-SHA256 using server's public key as secret
         ↓
Send GET /admin
  β†’ Server reads alg: HS256 β†’ uses public key as HMAC secret to verify
  β†’ Our signature matches β†’ token accepted βœ…
  β†’ sub: "administrator" β†’ admin access granted βœ…
         ↓
Delete carlos β†’ lab solved βœ…

πŸ› οΈ Step-by-Step Attack

πŸ”§ Step 1 β€” Install JWT Editor and Log In

  1. 🌐 Click "Access the lab"
  2. πŸ”Œ Ensure Burp Suite is running and proxying traffic
  3. βš™οΈ In Burp β†’ Extender β†’ BApp Store β†’ search JWT Editor β†’ click Install
  4. πŸ–±οΈ On the lab site, click "My account" β†’ log in with wiener / peter
  5. πŸ–±οΈ In Burp β†’ Proxy β†’ HTTP history β†’ find the post-login GET /my-account
  6. πŸ–±οΈ Right-click β†’ "Send to Repeater"

πŸ”§ Step 2 β€” Confirm You're Blocked from /admin

  1. πŸ–±οΈ In Repeater, change the path from /my-account to /admin:
GET /admin HTTP/1.1
Cookie: session=eyJhbGci...  (wiener's original token)

2. πŸ–±οΈ Click "Send"

Expected result:
  β†’ "Admin interface only accessible if logged in as the administrator"
  β†’ Confirms admin access is gated on the sub claim
  β†’ The JWT header shows alg: RS256 β€” the server is using asymmetric signing

πŸ”§ Step 3 β€” Obtain the Server's Public Key

  1. 🌐 In the browser, navigate to:
https://YOUR-LAB-ID.web-security-academy.net/jwks.json

πŸ”Ž Common locations

/.well-known/jwks.json  
/jwks.json  
/.well-known/openid-configuration

2. πŸ–±οΈ The server responds with a JWK Set:

{
  "keys": [
    {
      "kty": "RSA",
      "e": "AQAB",
      "use": "sig",
      "kid": "da5f15e1-402b-4132-810e-791058eed829",
      "alg": "RS256",
      "n": "rSKFd-fIzml9WYdlGZtLYX32IuQDXFRJekhycLKueKR1..."
    }
  ]
}

3. πŸ“‹ Copy the JWK object β€” the { ... } inside the keys array β€” not the outer array itself

Key observation:
  β†’ The server exposes its RSA public key openly at /jwks.json
  β†’ This is normal for RS256 β€” public keys are safe to publish
  β†’ But if the server accepts HS256, this public key becomes the HMAC secret
  β†’ The attacker now has everything needed to forge tokens

πŸ”§ Step 4 β€” Import the JWK as an RSA Key in JWT Editor

  1. πŸ–±οΈ In Burp β†’ click the JWT Editor Keys tab in the main tab bar
  2. πŸ–±οΈ Click "New RSA Key"
  3. πŸ–±οΈ In the dialog, ensure the JWK option is selected
  4. πŸ“‹ Paste the JWK object you copied from /jwks.json:
{
  "kty": "RSA",
  "e": "AQAB",
  "use": "sig",
  "kid": "da5f15e1-402b-4132-810e-791058eed829",
  "alg": "RS256",
  "n": "rSKFd-fIzml9WYdl..."
}

5. πŸ–±οΈ Click "OK" to save

πŸ”§ Step 5 β€” Copy the Public Key as PEM

  1. πŸ–±οΈ In the JWT Editor Keys tab, find the RSA key you just saved
  2. πŸ–±οΈ Right-click it β†’ select "Copy Public Key as PEM"
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArSKFd+fIzml9WYdlGZtL
YX32IuQDXFRJekhycLKueKR1wQ+35E7P6h+3v/zV/d2K5PN2BIWmkQackscLtvR9
...
-----END PUBLIC KEY-----

3. πŸ“‹ The PEM is now in your clipboard

The server stores its public key in X.509 PEM format.
To use it as an HMAC secret, we need it Base64-encoded.
Next step: encode it using Burp's Decoder.

πŸ”§ Step 6 β€” Base64-Encode the PEM in Burp Decoder

  1. πŸ–±οΈ In Burp β†’ click the Decoder tab
  2. πŸ“‹ Paste the PEM (including the -----BEGIN PUBLIC KEY----- and -----END PUBLIC KEY----- lines)
  3. πŸ–±οΈ Click "Encode as…" β†’ select "Base64"
  4. πŸ“‹ Copy the resulting Base64 string
Important:
  β†’ Include the full PEM including the header/footer lines when encoding
  β†’ The Base64 output is a single string with no line breaks
  β†’ This string will become the k value in the symmetric key β€” next step

πŸ”§ Step 7 β€” Create a Symmetric Key Using the Base64 PEM

  1. πŸ–±οΈ In Burp β†’ JWT Editor Keys tab β†’ click "New Symmetric Key"
  2. πŸ–±οΈ Click "Generate" β€” a placeholder JWK is created:
{
  "kty": "oct",
  "kid": "some-generated-id",
  "k": "random-base64-string"
}

3. ✏️ Replace the k value with the Base64-encoded PEM you copied from Decoder:

{
  "kty": "oct",
  "kid": "some-generated-id",
  "k": "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0K..."
}

4. πŸ–±οΈ Click "OK" to save

What we've done:
  β†’ Created an HMAC symmetric key whose secret IS the server's RSA public key
  β†’ When we sign a JWT with this key, we're signing it with HMAC-SHA256
    using the server's public key as the secret
  β†’ The server will verify using its public key in HMAC mode β†’ same result β†’ passes

πŸ”§ Step 8 β€” Modify the JWT Header (alg)

  1. πŸ–±οΈ Back in Repeater (on the GET /admin request) β†’ click the JSON Web Token tab
  2. πŸ–±οΈ In the Header section, change alg from "RS256" to "HS256":
{
  "kid": "da5f15e1-402b-4132-810e-791058eed829",
  "alg": "HS256"
}

3. πŸ–±οΈ Click "Apply changes"

What this does:
  β†’ Tells the server to verify this token using HMAC-SHA256 instead of RSA
  β†’ A correctly hardened server would reject any alg that isn't RS256
  β†’ This server reads alg from the client-supplied token β†’ vulnerability

πŸ”§ Step 9 β€” Modify the JWT Payload (sub)

  1. πŸ–±οΈ In the Payload section, change sub from "wiener" to "administrator":
{
  "iss": "portswigger",
  "sub": "administrator",
  "exp": 1648037164
}

2. πŸ–±οΈ Click "Apply changes"

πŸ”§ Step 10 β€” Sign the Token with the Symmetric Key

  1. πŸ–±οΈ At the bottom of the JSON Web Token tab, click "Sign"
  2. πŸ–±οΈ Select the symmetric key you created (the one with the Base64 PEM as k)
  3. β˜‘οΈ Ensure "Don't modify header" is checked
Why "Don't modify header" must be checked:
  β†’ The Sign function may regenerate the header (including alg) by default
  β†’ Without this option: alg gets reset back to RS256 (matching the key type)
  β†’ With this option: the header stays exactly as you edited it β€” alg: HS256 preserved

4. πŸ–±οΈ Click "OK"

Result:
  β†’ The JWT is signed with HMAC-SHA256 using the server's public key as the secret
  β†’ Header preserved: alg: HS256, kid: (original kid value)
  β†’ Payload: sub: "administrator"
  β†’ When the server reads this token:
      β†’ reads alg: HS256 β†’ switches to HMAC verification
      β†’ uses its own public key as the HMAC secret
      β†’ computes HMAC(header+payload, public_key) β†’ matches our signature βœ…

πŸ”§ Step 11 β€” Access the Admin Panel

  1. πŸ–±οΈ Confirm the request looks like this:
GET /admin HTTP/1.1
Host: YOUR-LAB-ID.web-security-academy.net
Cookie: session=MODIFIED-HEADER.MODIFIED-PAYLOAD.NEW-SIGNATURE

2. πŸ–±οΈ Click "Send"

Expected result:
  β†’ 200 OK β€” admin panel HTML loads βœ…
  β†’ Server verified HMAC with its own public key β†’ passed
  β†’ Server read sub: "administrator" β†’ admin access granted

πŸ”§ Step 12 β€” Delete carlos

  1. πŸ–±οΈ In the admin panel response, find the delete URL:
/admin/delete?username=carlos

2. πŸ–±οΈ In Repeater, update the path:

GET /admin/delete?username=carlos HTTP/1.1
Cookie: session=MODIFIED-HEADER.MODIFIED-PAYLOAD.NEW-SIGNATURE

3. πŸ–±οΈ Ensure the JWT still has alg: HS256, sub: administrator, and the public-key-signed signature

4. πŸ–±οΈ Click "Send"

Result:
  β†’ carlos is deleted βœ…
  β†’ Lab solved! πŸŽ‰

πŸŽ‰ Lab Solved!

βœ… Fetched server's public key from /jwks.json
βœ… Imported JWK as RSA key β†’ copied as PEM β†’ Base64-encoded in Decoder
βœ… Created symmetric key with Base64 PEM as k value
βœ… Changed JWT alg β†’ HS256, sub β†’ "administrator"
βœ… Signed with symmetric key (preserving header)
βœ… Server verified with public key in HMAC mode β†’ passed
βœ… Admin panel loaded β†’ carlos deleted
βœ… Lab complete!

πŸ”— Complete Attack Chain

1.  Log in as wiener β†’ GET /my-account β†’ Send to Repeater
2.  GET /admin with original token β†’ blocked (sub: wiener, alg: RS256)
3.  Browser: GET /jwks.json β†’ copy JWK object from keys array
4.  JWT Editor Keys β†’ New RSA Key β†’ paste JWK β†’ OK
5.  Right-click RSA key β†’ "Copy Public Key as PEM"
6.  Burp Decoder β†’ paste PEM β†’ Encode as Base64 β†’ copy result
7.  JWT Editor Keys β†’ New Symmetric Key β†’ Generate β†’ replace k with Base64 PEM β†’ OK
8.  Repeater β†’ JSON Web Token tab β†’ Header: alg β†’ "HS256"
9.  Payload: sub β†’ "administrator"
10. Sign β†’ select symmetric key β†’ "Don't modify header" checked β†’ OK
11. GET /admin β†’ admin panel loads
12. GET /admin/delete?username=carlos β†’ lab solved

No private key needed. No brute-force. The server's own public key β€” freely published β€” becomes the secret that forges any token.

🧠 Deep Understanding

Why the attack works β€” the server uses public key for HMAC

Correct RS256 verification:
  verify(token, public_key, algorithm="RS256")
  β†’ Uses RSA signature verification
  β†’ Only the private key can produce a valid RSA signature
  β†’ Attacker knows public key but can't sign β†’ secure

Vulnerable HS256 path (this lab):
  alg = token.header['alg']          // reads "HS256" from attacker's token
  verify(token, public_key, alg)     // uses public key as HMAC secret!
  β†’ Attacker knows public key
  β†’ Attacker signs token with HMAC-SHA256(public_key)
  β†’ Server recomputes HMAC-SHA256(public_key) β†’ same result β†’ accepted βœ…

The fatal assumption:
  β†’ The developer assumed alg would always be RS256
  β†’ They forgot that the client controls the alg field
  β†’ Their HMAC branch uses the RSA public key (same object) as the secret
  β†’ Public key is not secret β†’ attacker has it β†’ game over

Why /jwks.json is normally safe β€” but not in this case

Publishing public keys at /jwks.json is standard and correct for RS256:
  β†’ Public keys are meant to be public β€” anyone can verify RS256 tokens
  β†’ There is no secret in the public key by design

The problem is NOT the public key being published.
The problem is the server accepting HS256 and using the public key as the HMAC secret.
Fix the algorithm confusion β†’ /jwks.json becomes harmless again.

The three-step key transformation

Step 1 β€” JWK (JSON Web Key format, from /jwks.json):
  { "kty": "RSA", "e": "AQAB", "n": "rSKFd..." }
  Used by: RSA libraries, JWT verification code

Step 2 - PEM (Privacy Enhanced Mail format, X.509):
  -----BEGIN PUBLIC KEY-----
  MIIBIjANBgkqhkiG9w0...
  -----END PUBLIC KEY-----
  Used by: OpenSSL, most crypto libraries, file storage

Step 3 - Base64-encoded PEM (as k value in symmetric JWK):
  { "kty": "oct", "k": "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0K..." }
  Used by: JWT Editor's HMAC signing - treats the PEM bytes as the HMAC secret

Why this chain works:
  β†’ The server stores its public key as X.509 PEM (hint confirms this)
  β†’ When it verifies HS256 tokens, it reads that PEM file and uses its bytes as the key
  β†’ We reproduce those exact bytes in our symmetric key β†’ HMAC matches

πŸ› Troubleshooting

Problem: /admin returns 401 after signing
β†’ Most likely cause: "Don't modify header" was not checked
  β†’ Without it, signing resets alg back to RS256 β€” check the header in the JWT tab after signing
β†’ Verify the header still shows alg: HS256 before sending

Problem: JWT Editor tab shows alg reverted to RS256 after signing
β†’ The Sign dialog overwrote the header - go back and reapply alg: HS256 in the header section
β†’ Then sign again with "Don't modify header" checked this time

Problem: Decoder Base64 output has line breaks or spaces
β†’ Ensure you use "Encode as Base64" - not "URL-encode" or other options
β†’ Remove any whitespace from the Base64 string before pasting as k value
β†’ The k value must be a single continuous Base64 string

Problem: New RSA Key dialog - JWK not accepted
β†’ Ensure you copied only the { ... } object from inside the keys array
β†’ Do not include the outer array brackets [ ] or "keys": wrapper
β†’ The pasted content should start with { "kty": "RSA", ...

Problem: Delete request returns 401 after /admin loaded
β†’ The modified JWT must be used in the delete request too
β†’ Check the Cookie header - Burp may have reverted to the original session cookie
β†’ Paste the forged JWT manually into the Cookie header before sending

Problem: Lab already solved / carlos not in user list
β†’ Reset the lab from the lab page to restore original state

πŸ’¬ In One Line

πŸ”“ The server exposed its RSA public key at /jwks.json and accepted HS256 tokens β€” so switching alg from RS256 to HS256, signing the token with that public key as the HMAC secret, and setting sub to administrator produced a token the server verified with its own public key and trusted completely. That's JWT Authentication Bypass via Algorithm Confusion β€” the public key that was safe to publish became the secret that forged unlimited admin tokens.

πŸ”’ How to Fix It

# Priority 1 β€” Hardcode the expected algorithm server-side β€” never read it from the token
# The server decides the algorithm; the client has no say

# ❌ BAD - algorithm taken from the JWT header:
header = jwt.get_unverified_header(token)
algorithm = header['alg']                    # attacker controls this
payload = jwt.decode(token, public_key, algorithms=[algorithm])
# βœ… GOOD - algorithm fixed in server config:
EXPECTED_ALGORITHM = 'RS256'
payload = jwt.decode(token, public_key, algorithms=[EXPECTED_ALGORITHM])
# If token has alg: HS256 β†’ InvalidAlgorithmError raised automatically
# Priority 2 β€” Use separate key objects for asymmetric and symmetric algorithms
# Never allow the same key material to be used for both

# ❌ BAD - same variable used regardless of algorithm:
def verify(token):
    alg = jwt.get_unverified_header(token)['alg']
    return jwt.decode(token, rsa_public_key, algorithms=[alg])
    # If alg=HS256 β†’ rsa_public_key bytes used as HMAC secret β†’ confused!
# βœ… GOOD - keys and algorithms are bound together:
RSA_PUBLIC_KEY = load_rsa_public_key('/etc/keys/public.pem')
ALLOWED = ['RS256']
def verify(token):
    return jwt.decode(token, RSA_PUBLIC_KEY, algorithms=ALLOWED)
    # HS256 is not in ALLOWED β†’ rejected regardless of what the header says
# Priority 3 β€” Explicitly reject symmetric algorithms when using RSA infrastructure

def verify_token(token):
    header = jwt.get_unverified_header(token)
    symmetric_algorithms = {'HS256', 'HS384', 'HS512'}
    if header.get('alg') in symmetric_algorithms:
        raise ValueError("Symmetric algorithms are not accepted on this endpoint")
    return jwt.decode(token, RSA_PUBLIC_KEY, algorithms=['RS256'])
# Priority 4 β€” Use a modern JWT library that separates key types strictly

import jwt  # PyJWT >= 2.0 enforces this by default
# Passing an RSA key with algorithms=['HS256'] raises InvalidKeyError
# Passing alg: HS256 in a token verified against RSA key β†’ rejected
payload = jwt.decode(
    token,
    RSA_PUBLIC_KEY,
    algorithms=['RS256'],          # whitelist - HS256 always rejected
    options={'verify_exp': True}
)

πŸ‘ If this helped you β€” clap it up (you can clap up to 50 times!)

πŸ”” Follow for more writeups β€” dropping soon

πŸ”— Share with your pentest team

πŸ’¬ Drop a comment