---
key: "jwt-attacks"
title: "Defend JWTs against forgery, confusion and replay"
url: "https://softwaresecurity.com/domains/identity-crypto/authentication/jwt-attacks/"
api: "https://softwaresecurity.com/api/v1/articles/jwt-attacks.json"
domain: "identity-crypto"
topic: "Identity \u0026 cryptography"
type: "Guide"
identifiers: ["CWE-347","CWE-287","CWE-290","CWE-613","CWE-326","CWE-918","A07:2025","A04:2025","A01:2025","API2:2023","V9","V7","V10","CAPEC-475","CAPEC-196","CAPEC-593"]
tags: ["JSON Web Tokens (JWT)","API security"]
stages: ["build"]
roles: ["developer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Defend JWTs against forgery, confusion and replay

Verify a JWT as an issuer-, type- and audience-bound assertion with an algorithm allowlist and trusted keys, closing the alg "none", key-confusion, header-injection, weak-secret and replay attacks.

Canonical URL: https://softwaresecurity.com/domains/identity-crypto/authentication/jwt-attacks/

## 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.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| Algorithm policy | Allowlist one family, e.g. `["ES256"]` or `["RS256"]`; reject `"none"` | Bind each key to exactly one algorithm; never mix HMAC and public-key verification. | RFC 8725 Sec. 3.1-3.2 |
| Preferred signature | `Ed25519`, `ES256` or `PS256`; `RS256` acceptable but not preferred | Public-key signatures over HMAC for multi-audience issuers. RFC 9864 replaces the deprecated `EdDSA` identifier with `Ed25519`; older libraries still use `EdDSA`. | OWASP JWT Cheat Sheet; RFC 9864 |
| HMAC secret | &gt;= hash output (32 B `HS256`, 48 B `HS384`, 64 B `HS512`); &gt;= 160 bits entropy | From a CSPRNG; never a password. Enforced by PyJWT `enforce_minimum_key_length`. | RFC 7518 Sec. 3.2 |
| Access-token type | `typ` = `at+jwt`, validated at the resource server | Reject other `typ` values to prevent cross-JWT confusion. | RFC 9068 Sec. 4 |
| Required claims | `iss`, `aud`, `exp` always; for `at+jwt` also `sub`, `iat`, `jti`, `client_id` | Reject the token if a depended-on claim is absent. | RFC 9068 Sec. 2.2 |
| Clock skew | &lt;= 60 seconds | jose default 0; Spring Security default 60s. | jose / Spring Security |
| Key source headers | Ignore token `jku`/`x5u`/`jwk`/`x5c`, or allowlist exact `jku`/`x5u` URLs | Key discovery belongs to trusted issuer configuration. | RFC 8725 Sec. 3.10 / ASVS 5.0 V9.1.3 |
| JWE decompression cap | \~250 KB | jose `maxDecompressedLength` default 250000; mitigates decompression bombs. | jose / draft-ietf-oauth-rfc8725bis Sec. 3.15 |

## 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.

## 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.

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

Before you start:

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

Time: About 36 minutes: 14 to read and 22 for the exercise

## 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.

```js
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/.

```python
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.

```java
// 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.

| Token | Lifetime | Why |
| --- | --- | --- |
| Access token checked only by signature (JWT) | Minutes: 5 to 15 for sensitive APIs, rarely more than 60 | Nothing 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 longer | The resource server learns about revocation on each request. |
| Refresh token for a browser or mobile app | Rotate on every use or sender-constrain it; expire it after inactivity and at an absolute maximum, such as 8 hours to a few days | RFC 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 client | Longer, bound to the client's authentication | A stolen refresh token is useless without the client's credentials. |
| ID token | Short; used once at login | It 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.

| Attack | What the verifier must do | Failure to inject |
| --- | --- | --- |
| `alg` `"none"` | Reject unsecured JWTs unconditionally | Header `{"alg":"none"}`, empty signature |
| RS/HS confusion | Allow one algorithm family; never HMAC a public key | `HS256` signed with the RSA public key PEM |
| Header key injection | Resolve keys from trusted config only | `jku`/`x5u` to an attacker or internal URL |
| Weak HMAC secret | Require full-length CSPRNG secret | `HS256` with a short or dictionary secret |
| Missing/wrong claims | Pin `iss`, `aud`, `typ`; require `exp` | Wrong `aud`; absent `exp`; `typ` not `at+jwt` |
| Replay after logout | Short lifetime plus revocation | Reuse 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 material

```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. Accept the legitimate token

Evaluate T1.

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

### 2. Reject stripped and confused signatures

Evaluate T2 and T3.

Worked answer: 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. Reject header-driven key lookup

Evaluate T4.

Worked answer: 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. Reject wrong audience and wrong type

Evaluate T5 and T6.

Worked answer: 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. Reject a missing required claim

Evaluate T7.

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

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.

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

## Executable practice

- [JWT verification lab: Verify a JWT before trusting it](https://softwaresecurity.com/labs/jwt-verification/)

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [RFC 8725: JSON Web Token Best Current Practices (Sec. 3.1-3.12)](https://www.rfc-editor.org/rfc/rfc8725.html#section-3). February 2020 (BCP 225).
- [draft-ietf-oauth-rfc8725bis-10: JWT BCP update (PBES2, decompression, format checks)](https://www.ietf.org/archive/id/draft-ietf-oauth-rfc8725bis-10.html). Internet-Draft, revision 10.
- [RFC 7519: JSON Web Token (claims)](https://www.rfc-editor.org/rfc/rfc7519.html#section-4). May 2015.
- [RFC 7518: JSON Web Algorithms (HMAC key length, Sec. 3.2)](https://www.rfc-editor.org/rfc/rfc7518.html#section-3.2). May 2015; updated by RFC 9864.
- [RFC 9068: JWT Profile for OAuth 2.0 Access Tokens (at+jwt, Sec. 4)](https://www.rfc-editor.org/rfc/rfc9068.html#section-4). October 2021.
- [OWASP JSON Web Token Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_Cheat_Sheet.html)
- [OWASP ASVS 5.0.0 V9: Self-contained Tokens](https://github.com/OWASP/ASVS/blob/v5.0.0_release/5.0/en/0x18-V9-Self-contained-Tokens.md). Version 5.0.0 (May 2025).
- [jose (Node) jwtVerify and createRemoteJWKSet API](https://github.com/panva/jose/blob/v6.2.12/docs/jwt/verify/functions/jwtVerify.md). jose v6.2.12.
- [RFC 10017: OAuth 2.0 for browser-based applications, refresh token lifetimes](https://www.rfc-editor.org/rfc/rfc10017.html). August 2026 (BCP 212).
- [Microsoft Entra: configurable token lifetimes](https://learn.microsoft.com/en-us/entra/identity-platform/configurable-token-lifetimes)
- [Amazon Cognito: user pool access tokens](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-the-access-token.html)
- [RFC 9864: Fully-specified algorithms for JOSE and COSE (Ed25519 replaces EdDSA)](https://www.rfc-editor.org/rfc/rfc9864.html). October 2025.