JWT security: every vulnerability, and how to test for it
JSON Web Tokens run the authentication layer of most modern APIs and single-page apps. A server issues one, the client sends it back on every request, and the server trusts whatever claims are inside as long as the signature checks out. No session table, no server-side lookup, just a token and a cryptographic promise.
That promise is exactly what makes JWT bugs so valuable to find. When the token is the proof of identity, a flaw anywhere in how it’s created, signed, transmitted, or verified can mean a complete authentication bypass. Change one field, become the admin. This guide walks through every attack class that shows up against real JWT implementations, in the order you should actually test them, with the commands to run each one.
It pairs with the broader API security testing guide, which covers JWTs as one part of a wider authentication testing phase.
Practise legally. Every test below assumes a token from a system you own or are explicitly authorised to test. PortSwigger’s Web Security Academy has a full set of JWT labs built for exactly this. On a real engagement, stay strictly inside your authorised scope. Forging a token against a login you have no permission for is unauthorised access.
How a JWT works
The format is standardised in RFC 7519. A JWT is three Base64URL-encoded parts joined by dots:
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoxMDQyLCJyb2xlIjoidXNlciJ9.KmA3...signature
header payload signature
- Header: JSON describing the token, most importantly the signing algorithm (
alg), for example{"alg":"HS256"}. It can also carrykid,jku,x5u,jwk, andcrit, all of which matter below. - Payload: the claims, who the user is, their role, when the token expires:
{"user":1042,"role":"user","exp":1720000000}. - Signature: a cryptographic signature over the header and payload, using either a shared secret (HMAC, e.g. HS256) or a private key (asymmetric, e.g. RS256 or ES256).
The part most beginners miss: the header and payload are only encoded, not encrypted. Anyone with the token can decode and read them. Paste one into jwt.io and every claim is immediately legible. The signature doesn’t hide the contents; it proves they haven’t changed. The server trusts a JWT because it can recompute and verify that signature. Break that verification step and the entire trust model collapses.
The two root causes behind every JWT bug
Nearly every flaw in this guide reduces to one of two failures: the signature isn’t actually checked the way the server assumes, or the claims inside a validly signed token aren’t checked at all. Once you see a specific bug as a variation on one of those two failures, the list below stops feeling random.
What follows groups the attacks by where in the token they live: the header, the signature itself, the claims, the surrounding implementation, and finally how the token is stored and transmitted.
Header attacks: making the server pick the wrong key
The header’s job is to tell the verifier how to check the signature. Every attack in this section works by feeding the verifier a header that points it at a key the attacker controls, or at no key at all.
alg:none
The JWT spec defines a none algorithm, meaning the token carries no signature. If a server accepts {"alg":"none"} and skips verification entirely, an attacker rewrites the payload (role from user to admin), drops the signature, and sends it. A vulnerable server trusts it outright. Case variants (None, NONE, nOnE) have bypassed string-matching checks that only tested for the lowercase literal, so test those too.
Algorithm confusion (RS256 to HS256)
In RS256, tokens are signed with a private key and verified with a public key, and that public key isn’t secret by design; it’s often published at a /.well-known/jwks.json endpoint. If a verification function is written to just “verify with the key” without pinning which algorithm is allowed, an attacker switches the header’s alg to HS256 and signs the token using that public key as the HMAC secret. The server loads the same public key bytes it already trusts, runs HMAC verification instead of RSA verification, and the forged signature matches. This is exactly the bug that hit early versions of Node’s jsonwebtoken library (CVE-2015-9235), and it’s still common in hand-rolled verification code.
kid header injection
The optional kid (key ID) header tells the server which key to load for this token. If the server takes that attacker-controlled string and drops it unsanitised into a file path or a database query, you can redirect verification toward something you control. A path-traversal kid like ../../../../dev/null can point at a predictable, empty file, meaning HMAC verification runs against an empty key you can trivially sign with yourself. A SQL-injectable kid can be coerced into returning a key value of your choosing from the database.
jku header injection
jku (JWKS URL) tells the server where to fetch the public key set used to verify the token. If the server fetches whatever URL the token names, you host your own JWKS at a URL you control, point the token at it, and sign with the matching private key you generated. The server dutifully fetches your key set and validates your own forged signature against your own key. This also opens an SSRF angle if the fetch isn’t restricted to an allowlist of known hosts.
jwk header injection
jwk goes one step further than jku: instead of pointing at a URL, it embeds the public key directly inside the token’s header. A server that trusts an inline jwk without checking it against a known, pinned key will happily verify a token signed with a brand-new key pair the attacker just generated, because the token is telling the server exactly which key to use, and the server obeys.
x5u and x5c header injection
x5u and x5c are the certificate-chain equivalents of jku and jwk. x5u points to a URL hosting an X.509 certificate chain; x5c embeds the chain inline. Both suffer the identical flaw: if the server trusts a certificate chain the token itself supplies instead of validating it against a pinned CA or a known certificate, an attacker generates their own self-signed certificate, references or embeds it, and signs the token with the matching key.
crit header abuse
crit lists header parameters the verifier must understand and enforce, or reject the token. Implementations that don’t actually implement that enforcement will silently ignore an unsupported crit entry, which can be combined with the attacks above: naming a critical parameter the library doesn’t recognise, in the hope that parsing fails open rather than closed.
Signature attacks: breaking verification itself
Weak or guessable HMAC secret
Tokens signed with HS256 depend entirely on a shared secret. If that secret is a dictionary word, a short default, or a value copied from a tutorial, anyone who captures one valid token can brute-force the secret offline and then sign any token they want, with any claims they want.
Signature stripping / not checked at all
Sometimes the bug isn’t clever, it’s just missing. A developer decodes the payload to read claims and never calls the library’s verify function at all. The token becomes attacker-editable JSON with no cryptographic protection whatsoever, and nothing short of actually sending a tampered claim will reveal it.
Null or empty signature acceptance
Related to alg:none but distinct: some implementations will accept a token whose signature segment is simply empty, or a signature of all zero bytes, even when the header still names a real algorithm. Worth testing as its own case, separately from the none algorithm bypass.
Psychic signatures (malformed ECDSA)
ES256 and other ECDSA-based algorithms rely on a signature verification routine checking two values, r and s, correctly. A well-known class of bug, most famously the 2022 “Psychic Signatures” flaw in some Java cryptographic providers, let a verifier accept a signature where both r and s were set to zero as valid for any message. If a JWT library’s ECDSA implementation has this bug, you can forge a token for any payload without ever needing the private key. jwt_tool includes a check for exactly this.
Claim attacks: the signature is fine, the logic isn’t
A perfectly signed, perfectly legitimate token can still be abused if the server doesn’t validate what’s inside it.
- No expiry check. A token with no
exp, or a server that ignores it, means a stolen token never stops working. - Missing
audorissvalidation. If the server doesn’t check audience and issuer, a token issued for one service, or one environment, can be replayed against another that happens to share a signing key. This is a common failure in microservice setups where every service trusts the same identity provider’s keys without checking who the token was actually meant for. - Replay with no
jtitracking. Without a unique token ID and a record of which ones have been used or revoked, a captured token (from a log, a proxy, a shared machine) can be replayed indefinitely until it expires. - Token type confusion. An authorisation server typically issues more than one kind of token: an ID token, an access token, a refresh token. If an endpoint doesn’t check which type of token it received, swapping in an ID token where an access token is expected (or vice versa) can bypass scope restrictions, since the two often carry different, and differently trusted, claim sets.
- Sensitive data disclosure. The payload is readable by anyone holding the token. Putting secrets, internal IDs you don’t want enumerated, or personal data in claims is a straightforward disclosure bug, not a clever attack, just a mistake worth checking for every time.
How to test JWT security: the commands
Reading about these is one thing. Running them is what makes the flaw click. First, find the token. JWTs usually ride in the Authorization: Bearer ... header, but they also hide in cookies, in localStorage, and in request bodies, so watch traffic in Burp Suite while you log in and note every place a dotted eyJ... string appears.
The go-to tool is jwt_tool (ticarpi/jwt_tool), which decodes, tampers, forges, and cracks tokens, and has dedicated modes for most of the attacks above. Install it, grab a valid token from your authorised target, and start by decoding it:
python3 jwt_tool.py eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoxMDQyLCJyb2xlIjoidXNlciJ9.KmA3...
Token header values:
[+] alg = "HS256"
[+] typ = "JWT"
Token payload values:
[+] user = 1042
[+] role = "user"
[+] exp = 1720000000
Now you know the algorithm and the claims. That tells you which attacks are worth trying first.
Test 1: forge an unsigned token (alg:none)
jwt_tool’s exploit mode builds the none variants, including the case-mangled ones, for you:
python3 jwt_tool.py <TOKEN> -X a
[+] alg:none forged token (variant "none"):
eyJhbGciOiJub25lIn0.eyJ1c2VyIjoxMDQyLCJyb2xlIjoidXNlciJ9.
To make yourself an admin, run tamper mode (-T) first, change role to admin, then re-issue with the none algorithm. Send the forged token in place of the real one and watch the response. If the server treats you as an admin, the signature isn’t being verified. That’s a critical finding.
Test 2: crack a weak HMAC secret
If the token uses HS256, its whole security rests on the secret. Put the token in a file and let hashcat run a wordlist against it in JWT mode:
echo '<TOKEN>' > jwt.txt
hashcat -m 16500 -a 0 jwt.txt /usr/share/wordlists/rockyou.txt
-m 16500 is hashcat’s JWT (HMAC) mode. If the secret is weak, it falls out in seconds:
<TOKEN>:secret123
You now own the signing key, and can mint a token with any claims you like and have it validate perfectly:
python3 jwt_tool.py <TOKEN> -T -S hs256 -p "secret123"
This is why HMAC secrets have to be long, random, and unguessable. rockyou.txt should never land anywhere near your signing key.
Test 3: algorithm confusion (RS256 to HS256)
The subtle one, and often the highest impact. If the app signs with RS256, grab the public key, usually published at a JWKS endpoint:
curl -s https://target.com/.well-known/jwks.json -o jwks.json
Convert that JWK to a PEM public key (jwt_tool ships helpers for this), then run the key-confusion exploit, which re-signs the token as HS256 using the public key as the HMAC secret:
python3 jwt_tool.py <TOKEN> -X k -pk public.pem
If the server was written to “verify with the public key” without pinning the algorithm, it validates the forged HS256 token. From there you forge any user, including administrators.
Test 4: abuse the key-selection headers (kid, jku, jwk, x5u)
jwt_tool automates the key-injection family directly:
python3 jwt_tool.py <TOKEN> -X i # inject an inline JWKS (jwk) into the header
python3 jwt_tool.py <TOKEN> -X s # host and point at a spoofed JWKS (jku)
For kid, check manually: tamper the header’s kid value to a path-traversal string (../../../../dev/null) or a classic SQL injection payload, re-sign with an empty or known key, and watch whether verification succeeds, which tells you the server is loading a key you influenced.
Test 5: check for psychic ECDSA signatures
If the target uses ES256, jwt_tool’s built-in checks include a test for the zero-value r/s forgery. Run the tool’s scan mode against an ES256 token and review the ECDSA-specific findings before assuming the signature is solid just because it’s asymmetric.
Also check the boring stuff
Between the exotic attacks, work the checklist: does an expired token still work? Does a token with a wrong aud or iss get accepted? Does changing a claim without touching the signature get through, proving the signature isn’t checked at all? Can an ID token be submitted where an access token is expected? Does the server have any revocation path, or does a stolen token simply work until it expires no matter what you do? A missing expiry check is a less glamorous finding than algorithm confusion, but it’s a real one, and it shows up constantly.
Storage and transport: the attack doesn’t have to touch the token itself
A JWT can be implemented perfectly on the server and still get stolen because of where and how it’s handled on the client.
- XSS token theft. Anything readable by JavaScript on an origin, including a JWT sitting in
localStorageorsessionStorage, is readable by an attacker’s script the moment that origin has a cross-site scripting flaw. The token walks straight out in the response of a single injectedfetch()call. - CSRF when stored in a cookie. Storing the token in a cookie instead removes the XSS-readable path, since an HttpOnly cookie can’t be read by script, but it reopens CSRF if the cookie is sent automatically on cross-site requests without
SameSiteenforcement or a separate CSRF token. - Leakage through logs, history, and Referer headers. A JWT placed in a URL query string ends up in server access logs, browser history, and the
Refererheader sent to any third-party resource loaded on that page. Tokens belong in headers or request bodies, never in the URL. - No practical revocation. Even a flawlessly verified, correctly scoped token is a liability once it’s stolen, because a plain JWT stays valid until it expires no matter what the server does afterward. This is a design property of the format, not a bug, but it’s one every threat model needs to account for.
How to secure JWTs
Securing JWTs is mostly strict configuration and using a vetted library correctly, applied consistently. The rule that ties every attack above together: pin the algorithm and the key, and verify before you trust anything in the payload. Decide the exact algorithm your app uses, reject everything else including none, and never let the token’s own header, whether that’s alg, kid, jku, jwk, or x5u, choose how or with what key it gets verified.
In practice, that means configuring your library explicitly rather than accepting its defaults. A strict verification in Python with PyJWT:
import jwt
def verify(token: str) -> dict:
return jwt.decode(
token,
key=PUBLIC_KEY,
algorithms=["RS256"], # reject none, HS256, and everything else
audience="my-api", # validate aud
issuer="https://auth.example", # validate iss
options={"require": ["exp", "iat"]}, # force expiry to be present and checked
)
Around that core, apply the rest:
- Use strong keys and secrets: long, random HMAC secrets, and properly managed private keys for asymmetric signing, rotated on a schedule.
- Validate all claims on every request:
exp,nbf,aud,iss, and the token’s type if your system issues more than one kind. - If your app uses
kid,jku,jwk, orx5u, resolve keys against a server-side allowlist you control, never against whatever the token’s header names. - Keep tokens short-lived and pair them with refresh tokens plus a revocation path (a
jtidenylist for anything sensitive), since a plain JWT can’t be invalidated early on its own. - Never store secrets in the payload. Treat it as public, because it is.
- Prefer an HttpOnly, SameSite cookie over
localStoragefor client-side storage, and add CSRF protection if you do. - Always send tokens over HTTPS. Verify your TLS setup with the SSL/TLS Checker.
One connection worth noting: because JWTs are often sent in an Authorization header rather than a cookie, cookie-based attacks like CSRF usually don’t apply to them, but only if you don’t store the token in a cookie. Your storage choice changes which half of this guide actually matters for your app.
Key takeaways
- A JWT’s header and payload are encoded, not encrypted, and readable by anyone holding the token. The signature only proves integrity.
- Header attacks (alg:none, algorithm confusion, kid, jku, jwk, x5u/x5c injection) all work the same way: the token tells the server which key to trust, and the server obeys.
- Signature attacks (weak secrets, missing verification, psychic ECDSA signatures) break the cryptographic check itself, even when the server does call verify.
- Claim attacks (missing exp, aud, or iss checks, replay, token type confusion) abuse a token that’s genuinely, correctly signed.
- Storage and transport (XSS theft from localStorage, CSRF from cookies, URL leakage) can defeat a flawless server implementation.
- Test with jwt_tool and hashcat: forge alg:none, crack weak secrets, try RS256-to-HS256 confusion, inject kid/jku/jwk, and check ECDSA implementations for zero-value forgeries.
- Secure JWTs by pinning the algorithm and the key, validating every claim, keeping tokens short-lived with a real revocation path, and choosing client-side storage based on which risk, XSS or CSRF, your app is better equipped to close.
When the token is the identity, its verification logic is the lock on the entire application. Get it right and JWTs are a clean way to do stateless auth. Get it wrong and it’s a single header field away from admin.
For implementation guidance, the OWASP JSON Web Token Cheat Sheet covers every validation step your library should be doing, and if your tokens are issued through OAuth 2.0, RFC 6749 is the spec governing the flow. For cracking weak HMAC secrets, Hashcat supports JWT mode natively.
Continue with the API security testing guide and the IDOR and access control explainer. Build these skills hands-on with our training, and if you want your own API’s token handling reviewed properly, that’s what our security assessments are for.