DAST scanner · authenticated

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.

DAST (special)CriticalAuth Weakness5 tests

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.

1 · header 2 · payload 3 · signature eyJhbGciOiJIUzI1NiIsInR5… . eyJzdWIiOiI0MiIsInJvbGUi… . SflKxwRJSMeKKF2QT4fwpM… base64url, no padding readable by anyone, nothing is encrypted Header {"alg": "HS256","typ": "JWT"} alg: HS256 = HMAC with a shared secret RS256 = RSA with a private key Payload (claims) "sub": "42"who "role": "user"permissions "iat": 1718000000issued at "exp": 1718003600expires "iss": "app.example"issuer Signature HMAC-SHA256( header + "." + payload, secret ) or RSA-SHA256 with a private key only the key holder can produce part 3 Server: verify(token) 1 read alg from header 2 recompute signature with its key 3 compare bytes · check exp > now match and not expired → the request runs as sub 42 · anything else → 401 Attack goal make step 3 say yes without holding the key

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.

Captured: User A's token access_token of the login session eyJhbGciOiJIUzI1NiJ9 .eyJzdWIiOiI0MiIsInJv… .SflKxwRJSMeKKF2Q… decoded: alg HS256 · sub 42role user · exp +1h Forged variant same payload, new header or new signature, built locally with base64 + json + hmac {"alg":"none","typ":"JWT"} {"sub":"42","role":"user"} <signature removed> header rewritten, third part empty eyJhbGciOiJub25lIn0.eyJzdWIi…. the token ends with a dot {"alg":"HS256","typ":"JWT"} {"sub":"42","role":"user"} HMAC-SHA256(…, secret) secret = "secret" 14 candidates: "secret", "password", "123456", "your-256-bit-secret", "changeme", "", … {"alg":"HS256","typ":"JWT"} {"sub":"42","role":"user"} HMAC-SHA256(…, key) key = RSA public key (PEM text) built from n and e in jwks.json the server verifies RS256 with the same key text alg:none → 1 + 2 control requests weak secrets → up to 14 + 2 control key confusion → up to 3 per RSA JWK same payload every time: sub 42, role user the server, not the payload, is under test Target endpoint GET /api/meAuthorization: Bearer <forged> response: one authenticated endpointfrom discovery, reused forevery forgery 200 OKaccepted → CRITICALconfidence 0.95 401 ×5, then200 OK with "changeme"accepted → CRITICAL · 0.90 200 OKaccepted → CRITICAL · 0.95the open JWKS itself: INFO 2xx unlike control = findingany 4xx = nothing reportedfirst hit stops the loop Decision rule Status 200 to 299 after a forged token (while a garbage-token control is rejected) = the server trusted a signature it never really checked = vulnerable. Any 4xx = rejected, no finding. Without a session token the forgery tests are skipped entirely; only the token-in-URL check below still runs. The library sees alg "none", skips signature verification, and trusts whatever the payload says. jsonwebtoken did this by default (CVE-2015-9235). One request per candidate secret. The first 2xx stops the loop and the finding names the secret that worked. A library that takes alg from the header and is handed the RSA public key will happily use those bytes as an HMAC secret. One confirmed key is enough. One accepted forgery is enough: a token for sub 42 can be rewritten into a token for any sub, including an admin.

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.

Captured payload "sub": "42" "role": "user" "iat": 1718000000 "exp": absent "iss": absent required: exp · iat · iss Missing exp valid forever once stolen confidence 0.75 Missing iss recommended claim absent confidence 0.60 Discovered endpoints pulled from the JS bundles /api/report /api/export?format=csv /share?token=eyJhbG… /share?token=eyJhbG… /api/me every URL, once Pattern [?&](token|access_token|jwt|auth)=xxx.yyy.zzz Token in URL proxy and server logs browser history and cache confidence 0.85 The claims come straight from the captured token. This check sends no request. No exp is its own finding. Other required claims that are absent are grouped into a second, lower-confidence one. Every discovered endpoint URL is matched once against the JWT-in-query pattern: a token, access_token, jwt or auth parameter holding a three-part token. A JWT in a query string is written to every log between the browser and the server, and sits in browser history and caches long after the session.

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.

alg: none, no signatureforged without the secret HS256, secret "changeme"signed with a guessed secret valid signature, exp 2d agostolen and replayed later jwt.verify(token, SECRET, options) algorithms: ["HS256"]"none" and RS256-as-HS256 are not in the list SECRET = randomBytes(64).toString("hex")512 random bits: no wordlist contains it sign(…, { expiresIn: "1h" })exp is set at issue and checked at verify Result 401 alg not allowed 401 invalid signature 401 jwt expired

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

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.

Where the token lives

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

Auth0 / jsonwebtoken · CVE-2015-9235 (2015)

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.

Palo Alto Networks · CVE-2020-2021 (2020)

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

Limits and what it needs

TestNeedsReports whenSeverity · confidence
Missing claimsa session JWTexp, iat or iss absent from the payloadno exp 0.75 · others 0.60
alg:nonesession JWT + one authenticated endpointforged token gets a 2xx and the garbage-token control does notCritical · 0.95
Weak secretsession JWT + endpointany of 14 secrets yields a 2xx the control did not get; first hit stopsCritical · 0.90
Key confusiona reachable /.well-known/jwks.jsonHS256 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 InfoCritical · 0.95 (JWKS 0.50)
Token in URLdiscovered endpoints onlya URL matches the token-in-query pattern0.85
What it stays quiet about

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.