What goes wrong when an API trusts a JWT, shown attack by attack, with the server-side fix for each.
Most JWT vulnerabilities come from two root causes: the server lets the token decide how it gets verified, or the server treats a valid token as permission to do anything. The attacks below follow the order a request is processed: parse the header, pick a key, verify the signature, check the claims, authorize the action. After that come secure token storage, refresh token rotation, and the server-side validation steps that tie it together.
A JWT is three Base64URL strings joined by dots: header.payload.signature.
eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI2LTAyIiwidHlwIjoiSldUIn0
.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUiLCJzdWIiOiJ1c2VyLTQyIiwiYXVkIjoib3JkZXJzLWFwaSIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJleHAiOjE5MDAwMDAwMDB9
.<signature bytes>
// header: tells the verifier how the token was signed
{ "alg": "RS256", "kid": "key-2026-02", "typ": "JWT" }
// payload: the claims
{ "iss": "https://login.example", // issuer
"sub": "user-42", // subject (who)
"aud": "orders-api", // audience (which API it is for)
"scope": "orders:read",
"exp": 1900000000 } // expiry (unix time)
Signed is not encrypted. Anyone holding the token can read it, so keep secrets out of the payload.
Decoding is not verifying. Base64-decoding takes one line and proves nothing. Only a successful signature check makes the claims trustworthy.
The important point for everything below: the header is written by whoever sends the token. Every header field is attacker-controlled input.
A session cookie is a random ID that the server looks up on every request. A JWT carries its claims and is checked with a signature, so no lookup is needed.
| Session cookie | JWT | |
|---|---|---|
| Server state | Session store lookup per request | None, signature check only |
| Logout / revocation | Immediate | Valid until exp unless you add a denylist |
| Permission changes | Take effect on next request | Stale until the token expires |
| Fits | Browser apps, fast revocation | Multiple APIs validating one short-lived token |
A common design is a short-lived JWT access token (5 to 15 minutes) paired with a server-tracked refresh token. Verification stays fast, and you can still cut an account off.
alg names the signing algorithm. The spec defines none for tokens with no signature. Early libraries honored it, so an attacker could remove the signature and edit any claim.
// forged header, empty signature
{ "alg": "none", "typ": "JWT" }
{ "sub": "user-42", "role": "admin" }
eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyLTQyIiwicm9sZSI6ImFkbWluIn0.
Pass an explicit algorithm allowlist to the verify call (algorithms=["RS256"]). Never derive the algorithm from the token. Reject any token with none, and some libraries also need case variants like None blocked.
RS256 verifies with a public key. HS256 verifies with a shared secret. If the server calls verify(token, key) and lets the header choose the algorithm, an attacker switches the header to HS256 and signs the token using the server's public key as the HMAC secret. The public key is, by design, public.
# vulnerable: algorithm comes from the token
jwt.decode(token, PUBLIC_KEY)
# attacker side: sign HS256 with the public key bytes
header = {"alg": "HS256", "typ": "JWT"}
forged = hmac_sha256(key=PUBLIC_KEY_PEM, msg=b64(header) + "." + b64(payload))
Use the same fixed allowlist, and bind each key to one algorithm. Modern libraries (jose, PyJWT 2.x) refuse to use a PEM public key as an HMAC secret, but older versions and custom code may not, so test for it.
HS256 tokens are verified with a shared secret. If it is guessable (secret, changeme, the app name), the attacker takes one token they received legitimately and brute-forces the secret offline with hashcat (-m 16500). No requests reach your server during the attack. Once cracked, they can mint any token.
Generate at least 256 random bits, store it in a secrets manager, and rotate it. Where several services only need to verify, use RS256 or ES256 so that a leak of a verifying service cannot be used to issue tokens.
openssl rand -base64 48
kid (key ID) is a label that tells the verifier which key signed the token. It matters when an issuer rotates keys and publishes several at once. Example header: { "alg": "RS256", "kid": "key-2026-02" }.
The flaw is in how the server uses it. If kid is placed into a SQL query or a file path, the attacker controls the lookup.
# vulnerable: SQL injection through kid
key = db.query(f"SELECT secret FROM keys WHERE id = '{header['kid']}'")
# attacker header: the UNION returns a value they chose, then they sign HS256 with it
{ "alg": "HS256", "kid": "x' UNION SELECT 'AAAA' -- " }
# vulnerable: path traversal through kid
key = open(f"/keys/{header['kid']}").read()
# attacker header: points at a file with known content (empty), sign with empty secret
{ "alg": "HS256", "kid": "../../../../dev/null" }
Treat kid as a lookup key into a set you already trust, never as a query or path fragment.
KEYS = load_jwks_from_config() # {"key-2026-02": <public key>, ...}
key = KEYS.get(header.get("kid")) # dict lookup, nothing is interpolated
if key is None:
reject()
jku (JWK Set URL) is a header that points to a URL where the verifier can download the signing keys. The attacker sets it to their own server, hosts a key pair they generated, and signs the token with their private key. A server that follows the URL verifies a forged token successfully.
{ "alg": "RS256",
"kid": "attacker-key",
"jku": "https://evil.example/jwks.json" } // attacker's JWKS, attacker's key
The same field is an SSRF primitive: "jku": "http://169.254.169.254/latest/meta-data/" makes the server send a request to an internal address. Weak allowlists (substring checks, https://login.example@evil.example, open redirects on the trusted host) get bypassed.
Ignore jku entirely. Fetch keys from one JWKS URL taken from your own configuration (or the issuer's discovery document), cache it, and look keys up by kid. If you must honor a header URL, compare it to an exact-match allowlist after parsing, and block private IP ranges on the outbound fetch.
jwk carries the public key inside the header itself. The attacker generates a key pair, embeds the public half, signs with the private half, and the token verifies against the key it brought. This was the root of CVE-2018-0114 in the node-jose library.
{ "alg": "RS256",
"typ": "JWT",
"jwk": { "kty": "RSA", "e": "AQAB", "n": "<attacker's public modulus>" } }
Never verify against a key supplied by the token. Related fields (x5c, x5u) have the same problem. Trusted keys come only from your issuer configuration.
A valid signature only says a trusted issuer signed these claims. It does not say they are meant for your API.
aud check: a token issued for billing-api is accepted by orders-api. This enables cross-service token reuse.iss check: a token from another tenant or environment (staging key, same format) is accepted.exp check, or a very long lifetime: stolen tokens stay useful for days.typ (at+jwt for RFC 9068 access tokens).Require iss, aud, exp and sub to be present, match iss and aud exactly, and allow only a small clock-skew leeway.
This is the most common real-world flaw because every earlier check can pass. A token with scope: orders:write says the caller may write orders in general. It does not say they may cancel order 42, which belongs to another customer. This is broken object level authorization (BOLA / IDOR), number one on the OWASP API Security Top 10.
Fragile: "the token says role=admin, so allow it." Roles in a token are a snapshot from login time, and they say nothing about ownership of a specific record.
Use the token to learn who is calling. Then check the specific resource against current data on every request: tenant, ownership, state, and permission.
A bearer token works for whoever holds it. If it is stolen through XSS, a leaked log, a proxy, or a compromised device, the server cannot tell the thief from the user, and a JWT stays valid until exp. You cannot make theft impossible, so the design goal is to limit how long a stolen token is useful and to detect the theft. Sections 4 and 5 cover how.
Where the client keeps the token decides who can steal it.
| Location | Exposed to | Verdict |
|---|---|---|
localStorage / sessionStorage | Any JavaScript on the page: XSS, a compromised third-party script, a malicious browser extension | Avoid for anything long-lived |
| JavaScript memory (a variable) | XSS can still call your code, but nothing persists. The token is gone on refresh or tab close | Good for short-lived access tokens |
| HttpOnly cookie | Not readable by JavaScript. Sent automatically by the browser, so CSRF applies | Good for refresh tokens, with CSRF controls |
| URL / query string | Server logs, browser history, Referer header | Never |
Authorization: Bearer header.Set-Cookie: refresh_token=<opaque random value>;
HttpOnly; Secure; SameSite=Strict;
Path=/auth/refresh; Max-Age=1209600
HttpOnly: JavaScript cannot read it.Secure: sent over HTTPS only.SameSite=Strict (or Lax): the browser withholds it on cross-site requests, which blocks most CSRF. Add a CSRF token or an Origin header check on the refresh endpoint as a second layer.Path=/auth/refresh: limits where the cookie is sent.No storage option stops XSS. If an attacker can run script on your page, they can call your API as the user without ever reading the token. HttpOnly protects the token's value, not the session. A strict Content-Security-Policy, output encoding and avoiding innerHTML are still required.
Short-lived access tokens are only practical if the client can get new ones without logging in again. That is the job of a refresh token: a long-lived credential used only at the token endpoint. Because it lives longer, it is the more valuable target. Refresh token rotation reduces that risk.
/auth/refresh.Family F1: R1 ──used──▶ R2 ──used──▶ R3 (current)
│
└─ if R1 shows up again after R2 was issued → reuse detected
Suppose an attacker steals R1 while the real user also holds it. Whoever refreshes first gets R2, and R1 becomes used. Then the other party presents R1.
In both cases, a used refresh token appearing again means two parties hold the same chain, so the server revokes the entire family. The attacker's current token dies, the user is forced to log in again, and the event is logged as a likely theft.
Every API request that carries an access token goes through the same checks, in this order. Each step closes one of the attacks in section 3.
Authorization: Bearer, cap the token size, and reject tokens in query strings. (3.9)kid with a plain lookup. Ignore jku, jwk, x5u and x5c. (3.4 to 3.6)none and any algorithm that does not match the key. (3.1 to 3.3)iss and aud, require exp (and sub), apply a small clock-skew leeway, and check the token type. (3.7)Use a maintained JOSE library for steps 2 to 4 rather than parsing tokens by hand. Most of the bugs in section 3 come from custom JWT handling.
| Field / flaw | What the attacker does | Server-side fix |
|---|---|---|
alg: none | Removes the signature | Fixed algorithm allowlist |
| alg confusion | RS256 → HS256, signs with public key | Allowlist, key bound to one algorithm |
| Weak secret | Cracks HS256 offline | 256-bit random secret or asymmetric keys |
kid | SQLi / path traversal in key lookup | Dictionary lookup in a trusted key set |
jku / x5u | Hosts own keys, or triggers SSRF | Ignore, use configured JWKS URL only |
jwk / x5c | Embeds own public key | Never verify with a token-supplied key |
| Claims | Replays token meant for another API | Exact iss, aud, require exp |
| Authorization | Uses valid token on another user's object | Per-resource check on every request |
| Token theft | Reuses a stolen access or refresh token | Short expiry, secure storage, refresh rotation with reuse detection |
For a code review or pentest, these checks cover the list above:
alg: none and with the signature removed. Both must return 401.jwt_tool -X k).kid with quotes, ../ and null bytes. Add jku and jwk headers pointing at a server you control and watch for inbound requests.exp.none is rejected.kid lookup only. jku, jwk, x5u, x5c are ignored.iss and aud match exactly. exp is required and lifetimes are short.Summary: verify the token with settings the server owns, then authorize the action against current data for the specific resource.