In short

Verify every JWT before reading its claims: pin an algorithm allowlist (never "none"), take keys only from the issuer's trusted JWKS (never the token header), and check issuer, audience, type and expiry with exact matches. Use a maintained JOSE library, prefer asymmetric signatures, keep HMAC secrets at full length, and bound token lifetime with a revocation path. A valid signature is authentication, not authorization.

  • Do pass an explicit algorithm allowlist of one family; never accept alg "none" and never let one verifier accept both HMAC and public-key algorithms.
  • Don't take the verification key from the token's kid, jku, x5u, jwk or x5c; resolve keys from pre-configured issuer metadata.
  • Do check iss, aud, exp and token type (typ) with exact comparisons, and require the claims you depend on.
  • Don't sign with a password or short HMAC secret; use a CSPRNG secret at least the hash size, or prefer Ed25519/ES256.
  • Do keep access tokens short-lived with revocation (status list or per-user cutoff); a signed JWT is not confidential, so keep secrets out of it.
  • Don't treat a verified signature as authorization; re-check tenant and object ownership on every request.

Verify it

  1. Submit a token with the header altered to {"alg":"none"} and an empty signature; the verifier must reject it without reading claims.
  2. Re-sign a token with HS256 using the issuer's public key bytes as the secret and present it; an RS/ES verifier must reject it (algorithm not allowed, or key type refused).
  3. Alter iss, aud, and typ one at a time, and submit an expired token; each must fail independently even when the signature is valid.
  4. Present a token whose kid names an unknown key; confirm key refresh is bounded (cooldown/cache) and the request still fails when no trusted key matches.
  5. Point jku or x5u at an internal URL you control; confirm the verifier never fetches it.
  6. Confirm an authenticated request still fails object/tenant authorization when the token belongs to a different subject.
Applies to
  • jose (Node) 6.2.x
  • PyJWT 2.15.x
  • Spring Security resource server 7.1.x
  • nimbus-jose-jwt 10.x

Services that issue or verify JSON Web Tokens. Library defaults differ, so set the algorithm allowlist, issuer, audience and type explicitly.

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 36 minutes: 14 to read and 22 for the exercise

What you will learn

  • Define a JWT validation profile before reading claims.
  • Close alg "none", key confusion, and header key injection.
  • Choose secret length, token type, lifetime and revocation deliberately.

Before you start

Engineers who issue or accept JWTs, and reviewers verifying a resource server's token handling.

  • Read a short request or token trace.
  • Distinguish public data, secrets, and attacker-controlled inputs.
  • Know the difference between authentication and authorization.
Go to the guided practice

A signature proves who signed, not that the token is yours

A compact JWT is three base64url segments: a header, a payload, and a signature or MAC over the first two. Decoding the header and payload is just parsing; it exposes attacker-controlled data with nothing checked. Verification establishes a narrower fact: that the signed bytes were produced by a holder of a particular key. It does not establish that the token was minted by your issuer, for your service, as the token type you expect, inside its validity window, or that the subject may do what the request asks.

So decide a validation profile before you read a single claim: the expected issuer, an algorithm allowlist, a trusted key source, the expected audience, the required claims, the token type, and a time policy with bounded skew. The header may select a candidate key by kid from an already-trusted set. It must never choose an arbitrary network location, and it must never let an attacker turn an asymmetric verification key into a symmetric secret.

Almost every JWT attack is an attempt to make verification assert more than it should: strip the signature, swap the algorithm, redirect key lookup, guess a weak secret, or reuse a token past its purpose. The defenses below are the inverse of that list. This guide is about accepting and rejecting tokens; signing-key rollover and JWKS propagation are covered in the companion guide at /domains/identity-crypto/authentication/jwt-validation-rollover/.

Close alg "none" and RS/HS key confusion with an allowlist

The alg "none" attack sends a header of {"alg":"none"} with an empty signature. A library that honours it treats unsigned attacker data as authenticated. Algorithm confusion is subtler: an issuer verifies RS256 with an RSA public key, which is not secret. An attacker re-signs a token with HS256, using the PEM of that public key as the HMAC secret. A verifier that picks the algorithm from the token header will compute an HMAC with the public key and accept the forgery. This is the flaw behind CVE-2015-9235 (jsonwebtoken), CVE-2022-29217 (PyJWT), and CVE-2023-48223 (fast-jwt).

The fix is one rule: the application, not the token, chooses the algorithm. Pass an explicit allowlist of a single family and bind each key to exactly one algorithm. A maintained JOSE library then refuses "none" outright and refuses to use a public key for an HMAC algorithm. The jose verifier below never accepts unsecured JWTs, and the allowlist of ['ES256'] rejects any HS* token before a key is touched.

Prefer asymmetric signatures (Ed25519, ES256, PS256) so the public key can be published without becoming a signing secret; reserve HMAC for cases where the issuer is the only verifier. If you genuinely support both symmetric and asymmetric issuers, separate them by key and never evaluate both algorithm classes in the same verifier.

Fixedverify-access-token.mjsJavaScript
import { jwtVerify, createRemoteJWKSet } from 'jose'; // jose 6.2.x

// Keys come from the issuer's published JWKS, selected by the header "kid"
// within that trusted set -- never from a token-supplied jku/x5u/jwk.
const JWKS = createRemoteJWKSet(new URL('https://issuer.example/.well-known/jwks.json'));
// Defaults: 5s HTTP timeout, 30s cooldown between refreshes, 10min key cache.

export async function verifyAccessToken(token) {
  const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
    algorithms: ['ES256'],            // one family only; "none" is never accepted
    issuer: 'https://issuer.example', // exact string match
    audience: 'invoices-api',         // this service's identifier
    typ: 'at+jwt',                    // RFC 9068 access-token type
    requiredClaims: ['exp', 'iat', 'sub'],
    clockTolerance: 5,                // seconds; default is 0
  });
  // A good signature is not authorization. Compare payload.sub / tenant claims
  // against the resource you are about to return before answering.
  return { sub: payload.sub, header: protectedHeader };
}

Never resolve keys from the token's own headers

JWS defines headers that point at key material: jwk embeds a key, jku and x5u give URLs to a key set or certificate, x5c carries a certificate chain, and kid names a key. All of them are attacker-controlled. If verification follows jku or x5u, an attacker supplies a URL to a key they generated, or an internal URL that turns the verifier into a server-side request forgery primitive against metadata services and internal hosts. An embedded jwk or x5c is simply the attacker's own key asking to be trusted.

Resolve keys only from issuer configuration you control: a pinned key, or the issuer's jwks_uri discovered out of band. Use kid to select among keys you already trust, and validate or sanitize it first, because it reaches lookups and can carry SQL or LDAP injection. If a deployment must accept jku or x5u, constrain them to an exact allowlist of issuer URLs, never send credentials on the fetch, and resolve addresses to block loopback and link-local ranges. A certificate that chains to a public CA still is not proof that it is your issuer's signing key.

Bound the cost of unknown kid values. A verifier that fetches the JWKS for every invented kid lets an attacker force unbounded network traffic. Maintained clients cache the key set and apply a cooldown between refreshes (jose caches for ten minutes with a thirty-second cooldown; PyJWT's client caches for five minutes and limits refetching on unknown kids).

  • Reject jku, x5u, jwk and x5c from the token unless a documented profile requires one, then allowlist exact URLs.
  • Select keys by kid from the trusted set; validate kid as untrusted input before any lookup.
  • Cache the JWKS and apply a cooldown so unknown-kid tokens cannot drive repeated fetches.
  • Treat a CA-chaining certificate as candidate key material, not as issuer authorization.

Keep HMAC secrets full-length, or prefer asymmetric keys

When a token is signed with HS256, anyone who knows the secret can mint valid tokens. A short, low-entropy, or password-derived secret can be recovered offline: capture one token, then run a wordlist or brute-force tool (hashcat mode 16500, jwt_tool) against the signing input until a candidate secret reproduces the signature. Once the secret is found, every claim is forgeable. RFC 7518 Section 3.2 requires the HMAC key to be at least the hash output size; the OWASP JWT Cheat Sheet adds at least 160 bits of entropy and generation from a CSPRNG.

The PyJWT verifier below enforces the practical controls: a mandatory algorithms allowlist, required claims, strict audience comparison, and enforce_minimum_key_length, which rejects an HS256 key shorter than 32 bytes instead of only warning. PyJWT also refuses to load a PEM or other asymmetric key as an HMAC secret, which blocks the key-confusion path from the previous section even if HS256 were mistakenly allowed.

The stronger move is to stop sharing a signing secret at all. Asymmetric signatures mean the resource server holds only a public key, so a leaked verification key cannot sign anything. Rotate HMAC secrets like any other credential, and never reuse one secret across issuers, audiences, or purposes; secret lifecycle is covered in the secrets-management guide at /domains/identity-crypto/secrets/secrets-lifecycle/.

Fixedverify_access_token.pyPython
import jwt  # PyJWT 2.15.x
import secrets

# If you must use HMAC, the shared secret must be at least the hash output size
# (32 bytes for HS256, RFC 7518 Section 3.2) and come from a CSPRNG -- never a
# password or a hard-coded string, which offline cracking defeats in seconds.
SECRET = secrets.token_bytes(32)

def verify_access_token(token: str) -> dict:
    # "algorithms" is mandatory in PyJWT; an allowlist of one family blocks both
    # "none" and RS/HS confusion. PyJWT also refuses to load a PEM/asymmetric key
    # as an HMAC secret, so a public-key confusion token cannot be verified.
    return jwt.decode(
        token,
        SECRET,
        algorithms=["HS256"],
        issuer="https://issuer.example",
        audience="invoices-api",
        options={
            "require": ["exp", "iat", "sub"],  # reject tokens missing them
            "strict_aud": True,                 # aud must equal, not merely contain
            "enforce_minimum_key_length": True, # refuse under-length HMAC keys
        },
    )

Check issuer, audience, type and time on a trusted layer

A valid signature on the wrong token is still the wrong token. Pin the issuer with an exact string comparison, require the audience to name this service, and verify time claims (exp, and nbf when present) with a small, defined clock skew rather than a blanket outage exemption. Require the claims your authorization depends on, because most libraries validate a claim such as exp only when it is present; a missing exp silently passes unless you require it.

Token type prevents cross-JWT confusion: an ID Token is for the client's authentication processing, an access token is for the resource server, and a logout token is for the back-channel logout endpoint. They share JWT syntax but are not interchangeable. RFC 9068 gives access tokens the type at+jwt and lists their required claims (iss, exp, aud, sub, client_id, iat, jti); a resource server must verify typ is at+jwt or application/at+jwt and reject any other value.

The Spring Security resource server below shows the Java path. NimbusJwtDecoder trusts only RS256 by default, so the algorithm is pinned explicitly to ES256; createAtJwtValidator() applies the RFC 9068 profile, requiring at+jwt plus the mandatory claims and checking issuer and audience. Spring's default clock skew is sixty seconds. Validation runs on the server for every request; never move it to the browser.

FixedResourceServerConfig.javaJava
// Spring Security 7.1.x resource server (nimbus-jose-jwt under the hood).
// Validate a JWT access token: algorithm, signature, type, issuer, audience, time.
@Bean
JwtDecoder jwtDecoder() {
    NimbusJwtDecoder decoder = NimbusJwtDecoder
        .withIssuerLocation("https://issuer.example") // discovers jwks_uri + keys
        .jwsAlgorithm(SignatureAlgorithm.ES256)        // allowlist; RS256 is the default
        .validateType(false)                           // let the validator check typ
        .build();

    // RFC 9068 profile: requires typ=at+jwt plus exp, sub, iat, jti, client_id,
    // and checks iss + aud. Timestamps use Spring's default 60s clock skew.
    decoder.setJwtValidator(JwtValidators.createAtJwtValidator()
        .issuer("https://issuer.example")
        .audience("invoices-api")
        .clientId("invoices-web")
        .build());
    return decoder;
}

How long should access tokens and refresh tokens live?

Minutes for access tokens, and hours to days for refresh tokens with a hard limit. A self-contained access token works until it expires, so its lifetime is the time a stolen token keeps working and the delay before a disabled user loses access; 5 to 15 minutes is a reasonable range for sensitive APIs, and RFC 10017's worked example uses 10 minutes. Common identity providers default to about an hour: Microsoft Entra ID issues access tokens valid for 60 to 90 minutes, and Amazon Cognito lets you choose from 5 minutes to 1 day. Longer lifetimes are safer only when the resource server can check revocation on each request.

A self-contained token is valid until it expires, whether or not the user has logged out, had a session revoked, or been deprovisioned. That is the cost of statelessness. Keep access-token lifetime short (minutes, not days) so the exposure window from a stolen token is small, and obtain fresh access through a refresh token or a new login rather than a long-lived bearer token.

For explicit revocation, do not key a denylist on the raw token or its hash: JWT malleability (non-strict JSON parsing, ECDSA signature malleability) lets an attacker alter the bytes while keeping the same meaning, bypassing a hash match. Key a denylist on jti plus iss, stored until the token's exp. At scale, the OWASP cheat sheet points to the Token Status List mechanism, where the token carries a status claim and index into a published revocation list. A per-user "reject tokens issued before T" cutoff, or a rotated per-user signing key, also terminates outstanding tokens.

On compromise of the signing key, waiting for ordinary expiry preserves the attacker's ability to mint fresh tokens; remove the key promptly and revoke affected sessions. The mechanics of planned versus emergency key rollover are in the JWT key-rollover guide; session-level termination and idle/absolute timeouts are in the session-lifecycle guide.

  • Keep access tokens short-lived; renew through refresh tokens or re-login.
  • Denylist on jti + iss until exp, not on the token string or its hash.
  • Use a Token Status List or a per-user issued-before cutoff for scalable revocation.
  • On key compromise, revoke the key now; do not wait for tokens to expire.
How long should access tokens and refresh tokens live?
TokenLifetimeWhy
Access token checked only by signature (JWT)Minutes: 5 to 15 for sensitive APIs, rarely more than 60Nothing stops a stolen token before exp unless you add a deny list or a session-version check.
Access token checked online (introspection or continuous access evaluation)Can be longerThe resource server learns about revocation on each request.
Refresh token for a browser or mobile appRotate on every use or sender-constrain it; expire it after inactivity and at an absolute maximum, such as 8 hours to a few daysRFC 9700 section 4.14.2 and RFC 10017 section 6.3.2.3; a rotated token must not extend the original maximum.
Refresh token for a confidential server-side clientLonger, bound to the client's authenticationA stolen refresh token is useless without the client's credentials.
ID tokenShort; used once at loginIt proves a login to the client and is never an API credential.

A signed JWT is readable; use JWE only for confidentiality

A JWS (signed JWT) provides integrity and authenticity, not confidentiality. The payload is only base64url-encoded, so anyone who holds the token, or reads it from a log, a browser store, a Referer header, or an intermediary, can read every claim. Classify the payload as sensitive if it is (ASVS 5.0 treats the plaintext inside a JWT as data to classify), and keep secrets and excessive personal data out of it. When you only need a reference, an opaque token backed by server-side state leaks nothing.

A JWE (encrypted JWT) provides confidentiality and ciphertext integrity, but not issuer authentication: never make an authorization decision on claims from an unsigned JWE, because anyone can produce one. When you need both secrecy and proof of origin, use a nested JWT: sign first (JWS), then encrypt the result (JWE), set cty to JWT on the outer header, and on receipt verify the decryption and the inner signature, rejecting the token if either fails.

For JWE, apply the same discipline as for JWS: allowlist the alg and enc pair, bind each key to one algorithm, and never let the token header choose. Do not compress plaintext before encryption (no zip header), and cap decompressed size to defeat decompression bombs; maintained libraries default this cap (jose uses 250 KB).

Should you store a JWT in localStorage?

No. Anything in localStorage or sessionStorage can be read by every script that runs in your origin, so one cross-site scripting bug or compromised dependency can copy the token and use it from anywhere until it expires. Keep tokens out of the browser with a backend for frontend that gives the browser only an HttpOnly session cookie. If a browser-only app must hold tokens, keep them in memory, make access tokens short-lived and sender-constrained with DPoP, and rotate refresh tokens on every use.

If a single-page app keeps tokens in localStorage or sessionStorage, any script that runs in the origin, including one injected through cross-site scripting or a compromised dependency, can read and exfiltrate them. IndexedDB is no safer, and a JavaScript-set cookie is sent to the server automatically, so it leaks on ordinary requests. The browser-based apps BCP (RFC 10017) is explicit that no browser storage choice stops an attacker who can run code in the app; storage only decides whether existing tokens can be read directly.

The strongest pattern keeps tokens out of the browser entirely: a backend-for-frontend holds the tokens as a confidential OAuth client and exposes the app through a cookie-based session, so JavaScript never sees a token. Mark the session cookie Secure, HttpOnly, and SameSite=Strict, scope it with Path=/ and no Domain, and add a CSRF defense. This pattern and its cookie and CSRF details are the subject of the OAuth/OIDC attacks guide at /domains/identity-crypto/authentication/oauth-oidc-attacks/.

If tokens must stay in the browser, reduce their authority: short lifetimes, narrow scopes, refresh-token rotation, and, where it fits, DPoP with a non-extractable WebCrypto key so a stolen token cannot be replayed from another device. Treat these as harm reduction, not prevention, and invest first in keeping untrusted script out of the origin.

Prove the rejections, with tooling and tests

Turn each attack into a regression test that runs in CI. A verifier is only as good as the tokens it refuses, so assert on the refusals, not only on the happy path. Keep a fixture of hostile tokens and the first binding each one should fail.

Use jwt_tool or a library's own test helpers to generate the hostile cases; use your JOSE library to mint the legitimate ones with real keys. The JWT inspector at /tools/jwt-inspector/ decodes and lints a token locally, and the JWT verification lab at /labs/jwt-verification/ practices the rejections. Review the verifier configuration in code review: the algorithm allowlist, the key source, and the claim checks are the lines that matter, and a single permissive option (an empty allowlist, verification disabled, key taken from the header) undoes the rest.

Prove the rejections, with tooling and tests
AttackWhat the verifier must doFailure to inject
alg "none"Reject unsecured JWTs unconditionallyHeader {"alg":"none"}, empty signature
RS/HS confusionAllow one algorithm family; never HMAC a public keyHS256 signed with the RSA public key PEM
Header key injectionResolve keys from trusted config onlyjku/x5u to an attacker or internal URL
Weak HMAC secretRequire full-length CSPRNG secretHS256 with a short or dictionary secret
Missing/wrong claimsPin iss, aud, typ; require expWrong aud; absent exp; typ not at+jwt
Replay after logoutShort lifetime plus revocationReuse a token after session termination

Guided practice

A reviewer hands you the verification function for an invoices API and a bundle of tokens an attacker could send. Decide which tokens are accepted and name the first control each rejected token fails.

Supplied scenario material
Plain text
Verifier policy: algorithms=["ES256"]; issuer="https://issuer.example"; audience="invoices-api"; typ="at+jwt"; require exp, iat, sub; keys from https://issuer.example/.well-known/jwks.json (kid selects within the set); clock skew 5s. Current time 12:00:00Z.
T1: alg=ES256, kid=k1 (in JWKS), typ=at+jwt, iss/aud correct, sub=alice, iat=11:59:00Z, exp=12:05:00Z, valid signature.
T2: alg=none, same claims as T1, empty signature.
T3: alg=HS256, signed with the issuer's ES256 public-key PEM as the HMAC secret, claims as T1.
T4: alg=ES256, header jku="http://169.254.169.254/jwks" with an attacker key, claims as T1.
T5: alg=ES256, kid=k1, typ=at+jwt, iss correct, aud="reports-api", valid signature.
T6: alg=ES256, kid=k1, typ=JWT (an ID Token), iss/aud correct, valid signature.
T7: alg=ES256, kid=k1, typ=at+jwt, iss/aud correct, no exp claim, valid signature.
  1. 1. Accept the legitimate token

    Evaluate T1.

    Expected result and reasoning

    Accepted. It matches the algorithm, trusted key, type, issuer, audience, required claims, and time window. Authentication only; authorization against the invoice still runs.

  2. 2. Reject stripped and confused signatures

    Evaluate T2 and T3.

    Decide each token

    T2: alg=none, empty signature
    T3: alg=HS256, signed with the ES256 public key as the HMAC secret

    Expected result and reasoning

    Both rejected. T2 fails because unsecured JWTs are never accepted. T3 fails the allowlist (HS256 not in ["ES256"]); even if HS256 were allowed, an ES256 public key cannot be used as an HMAC secret.

  3. 3. Reject header-driven key lookup

    Evaluate T4.

    Expected result and reasoning

    Rejected. The verifier uses the trusted JWKS selected by kid and ignores jku; it must never fetch the attacker/internal URL. The signature does not match a trusted key.

  4. 4. Reject wrong audience and wrong type

    Evaluate T5 and T6.

    Expected result and reasoning

    Both rejected despite valid signatures. T5 fails the audience check (reports-api is not invoices-api). T6 fails the typ check (an ID Token is not an access token).

  5. 5. Reject a missing required claim

    Evaluate T7.

    Expected result and reasoning

    Rejected. exp is required, so its absence fails validation rather than passing as 'no expiry'.

Your deliverable: A per-token accept/reject table naming the first failed control, plus the one-line verifier configuration you would flag in review if any control were missing.

The fixture states signature validity; it does not compute signatures. It does not cover JWKS rollover timing (see the key-rollover guide) or JWE.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Repair the code

JWT verification lab: Verify a JWT before trusting it

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. RFC 8725: JSON Web Token Best Current Practices (Sec. 3.1-3.12). February 2020 (BCP 225).
  2. draft-ietf-oauth-rfc8725bis-10: JWT BCP update (PBES2, decompression, format checks). Internet-Draft, revision 10.
  3. RFC 7519: JSON Web Token (claims). May 2015.
  4. RFC 7518: JSON Web Algorithms (HMAC key length, Sec. 3.2). May 2015; updated by RFC 9864.
  5. RFC 9068: JWT Profile for OAuth 2.0 Access Tokens (at+jwt, Sec. 4). October 2021.
  6. OWASP JSON Web Token Cheat Sheet
  7. OWASP ASVS 5.0.0 V9: Self-contained Tokens. Version 5.0.0 (May 2025).
  8. jose (Node) jwtVerify and createRemoteJWKSet API. jose v6.2.12.
  9. RFC 10017: OAuth 2.0 for browser-based applications, refresh token lifetimes. August 2026 (BCP 212).
  10. Microsoft Entra: configurable token lifetimes
  11. Amazon Cognito: user pool access tokens
  12. RFC 9864: Fully-specified algorithms for JOSE and COSE (Ed25519 replaces EdDSA). October 2025.

Reference information

SDLC phase
Development

Download this guide as Markdown