JWT Attacks
A JSON Web Token is trusted because its signature checks out. The JWT scanner takes the real token from the login session, builds forged variants that should fail that check, and sends each one. A forged token the server accepts is a finding.
Anatomy of a JWT
Three base64url strings joined by dots. The first two are plain data anyone can decode; only the third proves who made the token. Every attack on this page is a way of getting past that third part.
1A JWT is three base64url strings joined by dots. base64url is an encoding, not encryption: anyone holding the token can read parts 1 and 2 without any key.
2Header. alg names the signing algorithm: HS256 is an HMAC with a shared secret, RS256 an RSA signature with a private key. The server reads this field before it checks anything else, which is the root of two of the attacks below.
3Payload. The claims. sub says who the caller is, role what they may do, iat when the token was issued, exp when it stops being valid, iss who issued it. None of this is protected by anything except the signature.
4Signature. HMAC-SHA256 over header.payload with the secret, or an RSA signature with the private key. Only the key holder can produce it, so changing a single claim invalidates it.
5The server's verify step. Read alg, recompute the signature with its own key, compare, then check exp against the clock. Every test on this page tries to make that comparison succeed without the key.
Forging tokens: three ways to fake a signature
The scanner starts from a real token, the access_token of the session the authenticated crawl logged in with. It keeps the payload, replaces the signature three different ways, and sends each forgery to one authenticated endpoint. The decision rule is the same every time.
1Capture. The authenticated crawl logged in as User A; the scanner takes that session's access_token, splits it on the dots and decodes the payload. No request has been sent yet.
2 · ALG NONEThe header is rewritten to {"alg":"none"} and the signature is dropped, leaving a token that ends in a dot. It goes to one authenticated endpoint as Authorization: Bearer. Accepted means anyone can forge a token for any user without knowing the secret.
3 · WEAK SECRETThe same payload is signed with HS256 under each of fourteen common secrets: "secret", "password", "123456", "your-256-bit-secret", the empty string and more. One request each. Accepted means the secret can be brute-forced offline, and the finding names it.
4 · KEY CONFUSIONIf /.well-known/jwks.json is served, the RSA public key is taken from it, encoded as the PEM text that jwt.verify(token, pem) receives (also tried stripped and as DER), and used as an HMAC secret to sign an HS256 token. EC keys are skipped. A server that lets the header choose the algorithm verifies it with the same public bytes. Accepted means RS256 to HS256 confusion, CRITICAL.
5The rule never changes: a forged token that gets a 2xx while a garbage-token negative control is rejected proves the server did not verify the signature it should have; if the control is accepted too, the endpoint ignores the token and nothing is reported. The controls are fetched once per scan; if a control request fails, a forged 2xx is still reported but at 0.2 lower confidence. A public endpoint with dynamic bodies (timestamps, nonces) can hide a real bypass. A 4xx is a pass and produces nothing. The payload is never escalated during these three tests; the server's check is what is being measured.
Passive checks: claims and URLs
Two of the five tests send nothing. One reads the captured token's claims; the other reads the URLs that discovery pulled out of the JavaScript bundles.
1 · CLAIMSThe captured payload is checked for the three claims the scanner requires: exp, iat, iss. This one has iat only.
2No exp means a stolen token works forever, so it is reported on its own at confidence 0.75. Any other missing required claim is bundled into a second finding at 0.60.
3 · TOKEN IN URLDiscovery already extracted the app's endpoint URLs from its JavaScript bundles. Each unique URL is matched against one pattern: a query parameter named token, access_token, jwt or auth whose value has the three-part dotted shape of a JWT.
4A match is a finding at confidence 0.85. A token in the URL is logged by proxies and cached by browsers, so it leaks to anyone who can read those logs, with no attack on the signature needed.
The fixed verify call
Three settings close all three forgeries: pin the algorithm list, use a secret long enough that guessing is hopeless, and give every token an expiry so a stolen one is bounded in time.
1The fixed call pins algorithms, uses a 64-byte random secret and issues every token with expiresIn. The three forged tokens from above are about to arrive.
2alg:none is rejected before any signature work: "none" is not in the allowed list. A token claiming HS256 against an RS256 key fails the same check, which closes key confusion too.
3The guessed-secret token is verified against the real 512-bit secret and the HMAC does not match. No wordlist reaches a random 64-byte value.
4A stolen but genuine token stops working one hour after issue. The leak is bounded; without exp it would have been permanent.
Why it matters
JWTs are the authentication foundation of most modern web apps. If the implementation is broken, an attacker can forge tokens as any user (alg:none or a weak secret produce valid tokens for any user id, including admins), gain permanent access (a token without expiry never stops working, so a stolen one works forever) and remain undetectable, because a forged token is indistinguishable from a real one in every log.
The session scanner covers the storage side of the same token: localStorage.setItem with a token-like key (readable by any script on the page, so one XSS is an account takeover), auth cookies without HttpOnly or Secure, and JWTs whose exp is more than 24 hours out. A perfectly signed token in localStorage is still one XSS away from an attacker.
Real-world breaches
The jsonwebtoken npm library, with millions of downloads, accepted alg: "none" by default. Any app using it without an explicit algorithm restriction could be fully bypassed: test 2 on this page, as a default.
Improper SAML/JWT signature validation in PAN-OS allowed authentication bypass when the "Validate Identity Provider Certificate" option was disabled. A signature that is not actually verified is the same failure the forge scene measures.
Vulnerable vs fixed code
The same three lines decide everything: the options passed to verify, the value of the secret, and whether sign sets an expiry.
// BAD: No algorithm restriction: accepts alg:none
const payload = jwt.verify(token, SECRET)
// BAD: Weak secret
const JWT_SECRET = "secret"
const token = jwt.sign(payload, JWT_SECRET)
// BAD: No expiration
const token = jwt.sign({ userId: "123", role: "admin" }, SECRET)
// Missing: expiresIn option// GOOD: Restrict algorithms explicitly
const payload = jwt.verify(token, SECRET, { algorithms: ["HS256"] })
// GOOD: Strong random secret (256+ bits)
const JWT_SECRET = crypto.randomBytes(64).toString("hex")
// GOOD: Set expiration
const token = jwt.sign(payload, SECRET, { expiresIn: "1h" })
// GOOD: Use asymmetric keys (RSA) for production
const token = jwt.sign(payload, PRIVATE_KEY, { algorithm: "RS256", expiresIn: "1h" })
const decoded = jwt.verify(token, PUBLIC_KEY, { algorithms: ["RS256"] })Limits and what it needs
| Test | Needs | Reports when | Severity · confidence |
|---|---|---|---|
| Missing claims | a session JWT | exp, iat or iss absent from the payload | no exp 0.75 · others 0.60 |
| alg:none | session JWT + one authenticated endpoint | forged token gets a 2xx and the garbage-token control does not | Critical · 0.95 |
| Weak secret | session JWT + endpoint | any of 14 secrets yields a 2xx the control did not get; first hit stops | Critical · 0.90 |
| Key confusion | a reachable /.well-known/jwks.json | HS256 token signed with the PEM-encoded public key gets a 2xx the control did not; the open JWKS alone (valid JSON with a keys array) is Info | Critical · 0.95 (JWKS 0.50) |
| Token in URL | discovered endpoints only | a URL matches the token-in-query pattern | 0.85 |
No session token means every forgery test is skipped and only the URL check runs. Nothing is reported on a 4xx, so a server that rejects every forgery produces no JWT findings at all. The three signature forgeries keep the captured payload unchanged; a separate claim-manipulation pass (role escalation and identity swap, signed with an empty secret, HS256 family tokens only) is documented in the scanner doc as a sixth check.