← mohdarif.com
Web Security · Authentication

JWT Security: Attacks and Server-Side Validation

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.

1. Anatomy of a JWT

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.

2. Where JWTs fit, and where they don't

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 cookieJWT
Server stateSession store lookup per requestNone, signature check only
Logout / revocationImmediateValid until exp unless you add a denylist
Permission changesTake effect on next requestStale until the token expires
FitsBrowser apps, fast revocationMultiple 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.

3. Attacks, in the order a request is processed

3.1alg: none (unsigned tokens)

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.

Mitigation

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.

3.2Algorithm confusion (RS256 → HS256)

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))

Mitigation

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.

3.3Weak HMAC secret

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.

Mitigation

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

3.4kid injection

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" }

Mitigation

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()

3.5jku: attacker-hosted key set

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.

Mitigation

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.

3.6jwk: key embedded in the token

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>" } }

Mitigation

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.

3.7Missing or loose claim validation

A valid signature only says a trusted issuer signed these claims. It does not say they are meant for your API.

Mitigation

Require iss, aud, exp and sub to be present, match iss and aud exactly, and allow only a small clock-skew leeway.

3.8Valid token, wrong permission (broken authorization)

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.

Mitigation

Use the token to learn who is calling. Then check the specific resource against current data on every request: tenant, ownership, state, and permission.

3.9Token theft and long-lived tokens

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.

4. Storing tokens securely

Where the client keeps the token decides who can steal it.

LocationExposed toVerdict
localStorage / sessionStorageAny JavaScript on the page: XSS, a compromised third-party script, a malicious browser extensionAvoid 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 closeGood for short-lived access tokens
HttpOnly cookieNot readable by JavaScript. Sent automatically by the browser, so CSRF appliesGood for refresh tokens, with CSRF controls
URL / query stringServer logs, browser history, Referer headerNever

Recommended pattern for a browser app

Set-Cookie: refresh_token=<opaque random value>;
            HttpOnly; Secure; SameSite=Strict;
            Path=/auth/refresh; Max-Age=1209600

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.

Other clients

5. Refresh token rotation

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.

How it works

  1. On login, the server issues an access token (JWT, 10 minutes) and a refresh token (opaque random string, not a JWT) and starts a token family: a record that ties every refresh token descended from this login together.
  2. When the access token expires, the client sends the refresh token to /auth/refresh.
  3. The server checks it, then returns a new access token and a new refresh token. The old refresh token is marked used and can never be accepted again.
  4. The chain continues: login → R1 → R2 → R3 → … All belong to one family.
Family F1:   R1 ──used──▶ R2 ──used──▶ R3 (current)
                │
                └─ if R1 shows up again after R2 was issued → reuse detected

Reuse detection

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.

Why this method is useful

Implementation notes

6. Server-side validation, step by step

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.

  1. Bound the input. Require Authorization: Bearer, cap the token size, and reject tokens in query strings. (3.9)
  2. Select the key from configuration. Use the JWKS URL from your own settings or the issuer's discovery document, cache it, and pick the key by kid with a plain lookup. Ignore jku, jwk, x5u and x5c. (3.4 to 3.6)
  3. Verify the signature with a fixed algorithm. The allowlist lives in server code. Reject none and any algorithm that does not match the key. (3.1 to 3.3)
  4. Validate the claims. Exact match on iss and aud, require exp (and sub), apply a small clock-skew leeway, and check the token type. (3.7)
  5. Authorize the action. Load the resource and check tenant, ownership, state and permission, using current data rather than only the token's roles. Return 404 for objects the caller should not know exist. (3.8)
  6. Fail closed and quietly. Any failure returns the same generic 401 or 403, with details in server logs only (never the token itself).

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.

7. Quick reference

Field / flawWhat the attacker doesServer-side fix
alg: noneRemoves the signatureFixed algorithm allowlist
alg confusionRS256 → HS256, signs with public keyAllowlist, key bound to one algorithm
Weak secretCracks HS256 offline256-bit random secret or asymmetric keys
kidSQLi / path traversal in key lookupDictionary lookup in a trusted key set
jku / x5uHosts own keys, or triggers SSRFIgnore, use configured JWKS URL only
jwk / x5cEmbeds own public keyNever verify with a token-supplied key
ClaimsReplays token meant for another APIExact iss, aud, require exp
AuthorizationUses valid token on another user's objectPer-resource check on every request
Token theftReuses a stolen access or refresh tokenShort expiry, secure storage, refresh rotation with reuse detection

8. Testing it

For a code review or pentest, these checks cover the list above:

9. Pre-ship checklist

Summary: verify the token with settings the server owns, then authorize the action against current data for the specific resource.

References