---
key: "jwt-validation-rollover"
title: "JWT validation: algorithms, keys, claims and key rollover"
url: "https://softwaresecurity.com/domains/identity-crypto/authentication/jwt-validation-rollover/"
api: "https://softwaresecurity.com/api/v1/articles/jwt-validation-rollover.json"
domain: "identity-crypto"
topic: "Identity \u0026 cryptography"
type: "Guide"
identifiers: ["CWE-347","CWE-290","CWE-918","A07:2025","A04:2025","API2:2023","V9.1.1","V9.1.2","V9.1.3","V9.2.1","V9.2.2","V9.2.3","CAPEC-475"]
tags: ["OAuth and OpenID Connect","JSON Web Tokens (JWT)","Key management"]
stages: ["build"]
roles: ["developer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# JWT validation: algorithms, keys, claims and key rollover

Validate a JWT against a fixed algorithm, trusted keys and the expected issuer, audience and type, reject the known header attacks, and rotate signing keys without accepting arbitrary keys or breaking valid tokens.

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

## In short

Verify every JWT with a maintained library against an explicit algorithm allowlist, keys from the issuer’s configured JWKS only, and the expected issuer, audience, token type and expiry. Never accept `alg: none`, never let a header choose the key (`jku`, `x5u`, `jwk`) or turn a public key into an HMAC secret, and treat valid claims as authenticated, not authorized. Publish a new key before signing with it; remove a compromised key at once.

- Decoding is not verification: reject the token before using claims if the algorithm, key or signature fails.
- Pin the algorithms per issuer, for example `["RS256"]`, and bind each key to one algorithm and key type.
- Load keys only from configured trust; treat `kid` as a lookup into that set, never as a path, query or URL.
- Check `iss`, `aud`, `exp`, `nbf` and the token type, such as `at+jwt` for RFC 9068 access tokens, with a small clock tolerance.
- `HS256` secrets need at least 256 random bits; JWT payloads are readable, so keep secrets and unnecessary personal data out of claims.
- Planned rollover publishes before use and retires after the last token expires; a compromised key is removed now.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| Algorithms | One explicit list per issuer, such as `RS256`, `ES256` or `Ed25519` | Never `none`; never mix HMAC and public-key algorithms for one key. | RFC 8725 section 3.1; ASVS 5.0 V9.1.2 |
| Key source | The issuer’s configured JWKS URL | Ignore `jku`, `x5u`, `jwk` and `x5c` from the token. | RFC 8725 section 3.10; ASVS 5.0 V9.1.3 |
| JWKS refresh | Unknown `kid`: at most one fetch per 30 seconds; cache up to 10 minutes | These are the jose `createRemoteJWKSet` defaults. | jose documentation |
| HMAC secret (`HS256`) | At least 256 random bits | Longer for `HS384` and `HS512`; prefer asymmetric keys across services. | RFC 7518 section 3.2 |
| Access token type and claims | `typ` of `at+jwt`; `iss`, `exp`, `aud`, `sub`, `client_id`, `iat` and `jti` present | Reject ID tokens and other JWT kinds at the API. | RFC 9068 sections 2.1 and 2.2 |
| Clock tolerance | 60 seconds or less | Spring Security's default is 60 seconds; the Node.js and .NET examples use 30. | This guide |

## Verify it

1. Send a token with `alg: none` and one signed `HS256` with the public key as the secret; both must return 401.
2. Send an otherwise valid token with a `jku` or `jwk` header pointing at your own key; it must fail and trigger no outbound fetch.
3. Send an ID token, a token for another audience and an expired token; each must fail with a distinct internal reason code.
4. Send ten tokens with unknown `kid` values in a second and confirm at most one JWKS fetch.
5. Rehearse a rollover: publish K2, wait out the cache bound, sign with K2, then confirm K1 tokens fail after the cutoff.

## What you will learn

- Define a JWT validation profile before parsing claims.
- Recognize and block the common JWT attacks by name.
- Sequence planned versus emergency signing-key rollover.
- Distinguish real JOSE verification from authorization policy.

Audience: API developers who accept JWTs, and reviewers who check token validation and signing-key operations.

Before you start:

- Read issuer, audience, expiration, key ID and the difference between decoding and verification.

Time: About 31 minutes: 9 to read and 22 for the exercise

## Decoding is parsing; validation establishes a narrower claim

A compact signed JWT has three base64url parts: protected header, payload, and signature. Decoding the first two reveals attacker-controlled data before verification, and anyone holding the token can read them: signing does not make claims confidential. A valid signature establishes that the signed bytes were produced by a holder of a particular key; it does not by itself establish that this token belongs to your issuer, audience, token type, time window, or authorization decision.

Choose a validation profile before reading claims: expected issuer, allowed algorithm set, trusted key source, expected audience, required claims, token purpose, and time policy. The header can select a candidate key by `kid` within an already trusted key set. It must not choose an arbitrary network location or turn an asymmetric verification key into a symmetric secret.

For an invoices API, a validated token might assert `iss=https://issuer.example`, `aud=invoices-api`, `sub=alice`, `tenant=acme`, and `typ=at+jwt` under the chosen profile. Authorization still has to compare alice and acme with the trusted invoice record. A signed tenant claim does not mean every invoice in that tenant is readable.

## Which JWT attacks must a verifier reject?

Each attack below has been exploited in real libraries or services. A verifier configured as in this guide rejects all of them; the tested examples further down include tokens for the first three. The JWT attacks guide at /domains/identity-crypto/authentication/jwt-attacks/ explains each one.

| Attack | What goes wrong | Defense |
| --- | --- | --- |
| `alg: none` | An unsecured token, which RFC 7519 allows, is accepted with no signature. | Pass an explicit algorithm list; jose and PyJWT then reject `none`. |
| Algorithm confusion (`RS256` to `HS256`) | The verifier uses the RSA public key, which anyone can read, as an HMAC secret. | Bind each key to one algorithm and key type; never take the algorithm from the header. |
| Header-supplied keys (`jwk`, `jku`, `x5u`, `x5c`) | The token carries or points to the attacker’s key, or makes the server fetch an attacker URL. | Ignore key material in headers; load keys only from the configured JWKS URL. |
| `kid` injection | A `kid` used as a file path or database query enables traversal, injection or an empty key. | Look up `kid` by exact match in the key set you already loaded. |
| Weak HMAC secret | One captured token lets an attacker guess a short secret offline and then mint tokens. | Use random secrets of at least 256 bits for `HS256`, or asymmetric keys. |
| Cross-JWT confusion | An ID token, or a token issued for another API, is accepted as an access token. | Check `typ`, `aud` and `iss` exactly, with separate rules per token kind. |
| Keys trusted beyond their issuer | In 2023 a consumer Microsoft account signing key was accepted for enterprise Azure AD tokens because of a validation issue (Storm-0558, /domains/identity-crypto/authentication/case-study-storm-0558/). | Bind each key to the one issuer that publishes it, and reject a token whose `iss` does not own the key. |
| Signature-check bugs | Java 15 to 18 before the April 2022 updates accepted all-zero ECDSA signatures (CVE-2022-21449). | Patch runtimes and test with invalid signatures, not only valid ones. |
| Newer cases (RFC 8725 update draft) | A case-variant `alg` such as `noNE` slips past a blocklist; JWE decompression bombs; huge PBES2 `p2c` counts; JSON-serialized JWS read as compact. | Use allowlists, cap decompression and iteration counts, and accept only the compact form. |

## Make failures bounded and distinguishable internally

Use a maintained JOSE implementation with explicit algorithm and claim validation. Reject malformed encodings, unsupported algorithms, missing required fields, ambiguous key selection, and invalid signatures before using the payload as authenticated identity. Enforce exact issuer matching and the profile’s audience semantics; a substring match is not an audience check.

Apply a defined clock-skew allowance to time validation, and bound token lifetime where the application requires it. Avoid a blanket “ignore expiration during outages” fallback. The verifier’s unavailable key source is an availability problem, not evidence that an unverified token is acceptable.

Unknown `kid` values can trigger bounded refresh of the configured JWKS. Without caching, cooldowns, and limits, attackers can force a network fetch for every invented key ID. A token-supplied `jku` or `x5u` must not turn verification into arbitrary URL fetching. Key discovery belongs to trusted issuer configuration.

The downloadable “Secure a real HTTP API” project performs actual JOSE signing and verification in its tests. The learner edits authorization and profile-field selection in `exercise.mjs`, not the verifier. This is a useful boundary demonstration: bad signatures and audiences fail before the learner’s authorization function runs; same-tenant ownership errors occur afterward.

## Verify an access token in Node.js with jose

This verifier pins the algorithm, issuer, audience and RFC 9068 token type, and takes keys only from the issuer’s JWKS with a refresh cooldown. It was tested with jose 6.2.12 on Node.js 26 against a local JWKS: valid tokens pass, and `alg: none`, an `HS256` token signed with the RSA public key, attacker keys in `jwk` or `jku`, wrong audience, issuer or type, and expired tokens all fail.

```javascript
import {createRemoteJWKSet, jwtVerify} from 'jose';

// Keys come only from the issuer's configured JWKS URL, never from token headers
// (jku, x5u, jwk). Unknown kid values refresh the set at most once every 30 seconds.
const jwks = createRemoteJWKSet(new URL('https://issuer.example/.well-known/jwks.json'), {
  cooldownDuration: 30_000,
  cacheMaxAge: 600_000,
});

export async function verifyAccessToken(token) {
  const {payload} = await jwtVerify(token, jwks, {
    algorithms: ['RS256'], // exact allowlist: rejects "none", HS256 and anything else
    issuer: 'https://issuer.example',
    audience: 'invoices-api',
    typ: 'at+jwt', // RFC 9068 access tokens, so an ID token is refused
    requiredClaims: ['sub', 'iat'],
    maxTokenAge: '15m',
    clockTolerance: 30,
  });
  return payload; // authenticated claims; authorization still happens per request
}

```

## Verify an access token in Python with PyJWT

PyJWT checks the signature, algorithm, issuer, audience and required claims in `jwt.decode`; the token type is checked afterwards because the header is covered by the signature. Tested with PyJWT 2.15.0 against the same attack tokens as the Node.js example.

```python
import jwt  # PyJWT 2.x

# Keys come only from the issuer's JWKS URL; get_signing_key_from_jwt matches the kid there.
jwks = jwt.PyJWKClient("https://issuer.example/.well-known/jwks.json", cache_keys=True, lifespan=600)


def verify_access_token(token: str) -> dict:
    key = jwks.get_signing_key_from_jwt(token)
    claims = jwt.decode(
        token,
        key.key,
        algorithms=["RS256"],  # never take this list from the token header
        audience="invoices-api",
        issuer="https://issuer.example",
        leeway=30,
        options={"require": ["exp", "iat", "iss", "aud", "sub"]},
    )
    typ = str(jwt.get_unverified_header(token).get("typ", "")).lower().removeprefix("application/")
    if typ != "at+jwt":  # RFC 9068 access token; the header is covered by the verified signature
        raise jwt.InvalidTokenError("not an access token")
    return claims

```

## Verify an access token in Java with Spring Security

Spring Security's resource server checks every bearer token with the `JwtDecoder` bean. `createAtJwtValidator()` (Spring Security 6.5 and later) enforces the RFC 9068 profile: `typ` of `at+jwt`, the required claims, and the exact issuer and audience. The decoder's own type check is off by default since Spring Security 7.0; `validateType(false)` keeps it off on 6.5, where it would reject `at+jwt`. The method is `validateType` even though Spring's reference documentation shows `validateTypes`. Tested with Spring Boot 4.1.1 (Spring Security 7.1.1, Nimbus JOSE + JWT 10.9.1) on JDK 21 against a local JWKS: a valid token passes, and `alg: none`, an `HS256` token signed with the RSA public key, an ID-token type, a wrong audience or issuer, an expired token, a missing `jti`, and attacker keys in `jku` or `jwk` all fail, with no request to the `jku` URL.

```java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
class ResourceServerConfig {

    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(requests -> requests.anyRequest().authenticated())
                .oauth2ResourceServer(server -> server.jwt(Customizer.withDefaults())); // uses the bean below
        return http.build();
    }

    @Bean
    JwtDecoder jwtDecoder() {
        // Keys come only from the issuer's JWKS URL; the decoder ignores jku, x5u and jwk headers.
        NimbusJwtDecoder decoder = NimbusJwtDecoder
                .withJwkSetUri("https://issuer.example/.well-known/jwks.json")
                .jwsAlgorithm(SignatureAlgorithm.RS256) // exact allowlist: rejects none, HS256, ES256
                .validateType(false) // the RFC 9068 validator below checks typ instead
                .build();
        // RFC 9068: typ at+jwt; exp, iat, sub, jti and client_id present; exact iss and aud;
        // 60 seconds of clock skew by default.
        decoder.setJwtValidator(JwtValidators.createAtJwtValidator()
                .issuer("https://issuer.example")
                .audience("invoices-api")
                .build());
        return decoder;
    }
}

```

## Verify an access token in ASP.NET Core

`AddJwtBearer` loads the issuer's metadata and signing keys from `Authority` and validates each token with `TokenValidationParameters`. `ValidAlgorithms` and `ValidTypes` are the two settings most often missing: without them any algorithm the key supports, and any token type, are accepted. The default clock skew is 5 minutes, so set it explicitly. These parameters were tested with Microsoft.AspNetCore.Authentication.JwtBearer 10.0.12 (Microsoft.IdentityModel 8.19.2) on .NET 10: a valid `at+jwt` token passes, and a `typ` of `JWT`, a wrong audience or issuer, an expired token, `alg: none`, `HS256` with the public key, attacker keys in `jwk` or `jku` and an `ES256` token all fail.

```csharp
// Program.cs, ASP.NET Core 10 with Microsoft.AspNetCore.Authentication.JwtBearer
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        // Signing keys come from the issuer's discovery document and JWKS, never from the token.
        options.Authority = "https://issuer.example";
        options.MapInboundClaims = false; // keep "sub" and "client_id" as issued
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidIssuer = "https://issuer.example",
            ValidAudience = "invoices-api",
            ValidAlgorithms = [SecurityAlgorithms.RsaSha256], // exact allowlist: no "none", no HS256
            ValidTypes = ["at+jwt"], // RFC 9068 access tokens only, so an ID token is refused
            ClockSkew = TimeSpan.FromSeconds(30), // the default is 5 minutes
            RequireExpirationTime = true,
            RequireSignedTokens = true,
        };
    });
builder.Services.AddAuthorization();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/invoices", () => Results.Ok()).RequireAuthorization();
app.Run();

```

## Publish before use and retire after the old acceptance window

For planned rollover, publish new public key K2 alongside K1, allow verifiers to refresh, then begin issuing with K2. Continue accepting K1 only for the period when legitimately issued K1 tokens can remain valid. After their maximum expiry plus the configured clock-skew allowance, retire K1 from normal validation and ensure caches converge.

For example, if the last K1 token was issued at 10:00 with a ten-minute lifetime and a thirty-second skew allowance, the normal overlap must account for acceptance until just before 10:10:30 under an exclusive cutoff rule. The publication lead time depends on cache behavior, so write it down rather than assuming immediate propagation.

Compromise rollover is different: an attacker with K1 can issue fresh-looking tokens. Waiting for an ordinary expiry window may preserve the attacker’s access. Remove or deny K1 promptly, revoke affected sessions or grants where possible, and accept the controlled disruption required by the incident. Merely changing the signer while every verifier still trusts K1 is incomplete. JWTs stay valid until they expire, so keep access-token lifetimes short and keep server-side state, such as session versions or a deny list keyed by `jti`, for revocation before expiry.

## Exercise key state and token semantics independently

A valid K1 token before cutoff and valid K2 token after propagation should pass. Wrong issuer, audience, purpose, signature, and expired tokens must fail even when their `kid` is known. Unknown `kid` refresh should be bounded, then fail if no authorized key matches.

Test stale caches and a JWKS outage using a defined policy for already cached trusted keys. Verify that a removed compromised key cannot persist beyond the stated revocation target. Record which key ID and policy version made the decision, but avoid logging whole bearer tokens.

## Use the real verification projects for a concrete boundary

Two local projects on this page run real JOSE verification. In the “Secure a real HTTP API” project, use a supported Node.js release (22 or later) and npm, run `npm ci --ignore-scripts` and then `npm test`. The tests start a loopback HTTP service, generate temporary signing keys, and issue real tokens; they exercise wrong audience, expired token, invalid signature, wrong token purpose, and missing credentials. The “Verify tokens through rotation and revocation” project adds 24 tests against a real local JWKS, including a cached key that outlives its removal from the key set.

Repair the policy functions as instructed by each project: owner and tenant must match trusted invoice fields, and profile updates must select the permitted `displayName` field. Keep the verifier tests intact. The JWT validation lab at /labs/token-validation/ practices the claim checks in the browser.

## Guided practice

Your invoices API accepts RFC 9068 access tokens from https://issuer.example with audience invoices-api, `RS256` only, keys from the JWKS (currently k1 and k2) and 30 seconds of clock skew, rejecting at `now >= exp + 30`. At 14:00:00 UTC on 10 October 2026 (epoch `1791640800`) five tokens arrive. Decode them yourself, decide each one, then plan the k1 retirement. Signatures: assume T1, T2 and T4 verify with the key their `kid` names; T3 and T5 were forged.

### Supplied material

```text
T1 eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6ImsyIn0.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwiYXVkIjoiaW52b2ljZXMtYXBpIiwic3ViIjoiYWxpY2UiLCJjbGllbnRfaWQiOiJ3ZWIiLCJpYXQiOjE3OTE2NDA1MDAsImV4cCI6MTc5MTY0MTEwMCwianRpIjoiN2YzYSJ9.c2lnbmF0dXJl
T2 eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImsyIn0.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwiYXVkIjoiY2xpZW50LTciLCJzdWIiOiJhbGljZSIsIm5vbmNlIjoibi0wUzZfV3pBMk1qIiwiaWF0IjoxNzkxNjQwNzQwLCJleHAiOjE3OTE2NDEzNDB9.c2lnbmF0dXJl
T3 eyJhbGciOiJIUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6ImsyIn0.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwiYXVkIjoiaW52b2ljZXMtYXBpIiwic3ViIjoiYWxpY2UiLCJjbGllbnRfaWQiOiJ3ZWIiLCJpYXQiOjE3OTE2NDA1MDAsImV4cCI6MTc5MTY0MTEwMCwianRpIjoiN2YzYSJ9.c2lnbmF0dXJl
T4 eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6ImsxIn0.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwiYXVkIjoiaW52b2ljZXMtYXBpIiwic3ViIjoiYWxpY2UiLCJjbGllbnRfaWQiOiJ3ZWIiLCJpYXQiOjE3OTE2NDAxODAsImV4cCI6MTc5MTY0MDc4MCwianRpIjoiNmIyMSJ9.c2lnbmF0dXJl
T5 eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6ImV2aWwiLCJqa3UiOiJodHRwczovL2F0dGFja2VyLmV4YW1wbGUvandrcy5qc29uIn0.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwiYXVkIjoiaW52b2ljZXMtYXBpIiwic3ViIjoiYWRtaW4iLCJjbGllbnRfaWQiOiJ3ZWIiLCJpYXQiOjE3OTE2NDA1MDAsImV4cCI6MTc5MTY0MTEwMCwianRpIjoiOWMwZCJ9.c2lnbmF0dXJl
Rollover so far: k2 published 13:40:00; the issuer switched signing from k1 to k2 at 13:50:00; access tokens live 10 minutes; verifiers cache the JWKS for at most 5 minutes.
```

### 1. Decode the tokens

Decode the header and payload of each token, and convert `iat` and `exp` to UTC times.

```bash
# Header and payload of one token: put the token in TOKEN first
node -e 'for (const part of process.argv[1].split(".").slice(0, 2)) console.log(Buffer.from(part, "base64url").toString())' "$TOKEN"
# An epoch time as UTC
node -e 'console.log(new Date(1791640780 * 1000).toISOString())'

```

Worked answer: T1: `RS256`, `typ` `at+jwt`, `kid` `k2`; `iss` and `aud` correct, `sub` `alice`, `exp` 14:05:00. T2: `RS256`, `typ` `JWT`, `kid` `k2`; `aud` `client-7` and a nonce, so it is an ID token for the client. T3: `HS256` with `kid` `k2`, otherwise T1's claims. T4: `RS256`, `at+jwt`, `kid` `k1`; `iat` 13:49:40, `exp` 13:59:40. T5: `RS256`, `kid` `evil`, a `jku` pointing at `attacker.example`, `sub` `admin`.

### 2. Decide each token at 14:00:00

Apply the validation profile to T1 to T5 and name the first check each rejected token fails.

Worked answer: T1 passes. T2 fails the type check (`JWT`, not `at+jwt`) and the audience check (`client-7`). T3 fails the algorithm allowlist before any key is used, which is what stops the RS256-to-HS256 key-confusion attack. T4 passes: `exp` 13:59:40 plus 30 seconds of skew means it is rejected only from 14:00:10, and k1 is still trusted. T5 fails because no key with `kid` `evil` is in the configured JWKS; the `jku` header is ignored and no request goes to `attacker.example`.

### 3. Plan the planned retirement of k1

Compute the earliest time k1 can be removed from every verifier, and check the publication lead time.

Worked answer: The last k1 token can be issued just before 13:50:00 and expires just before 14:00:00; with 30 seconds of skew it is acceptable until just before 14:00:30, so remove k1 at 14:00:30 at the earliest and allow for the 5-minute JWKS cache when removing it from the published set. k2 was published 10 minutes before first use, more than the 5-minute cache, so no verifier saw a k2 token before it could fetch k2.

### 4. Change it to a compromise

At 14:00:00 you learn that k1 was stolen. Decide what happens to T4 and what to do.

Worked answer: Remove k1 from the published JWKS and from verifier caches now, and reject T4 even though it is inside its skew window, because anyone holding k1 can mint new tokens that look like T4. Revoke sessions or refresh tokens issued during the exposure and accept the short disruption.

### 5. Write the regression tests

Turn the five decisions into tests for your verifier.

Worked answer: One test per token with a real signature from test keys: T1-like passes; an ID token is refused; `HS256` signed with the public key is refused; a k1 token passes until `exp` + 30 seconds and fails at that instant; a token with an unknown `kid` and a `jku` header fails with no outbound request (assert the mock server received nothing). The Java and Node.js examples on this page were tested this way.

Deliverable: A table of T1 to T5 with decoded claims, decision and failing check, a k1 retirement time with the reasoning, and five regression tests.

Scope: The tokens carry placeholder signatures, so they decode but do not verify; the signature outcomes are stated. Use the code on this page, or the token projects, to generate real signed tokens for the tests.

## Executable practice

- [JWT validation lab: Validate the purpose and audience of a token](https://softwaresecurity.com/labs/token-validation/)
- [JWT verification lab: Verify a JWT before trusting it](https://softwaresecurity.com/labs/jwt-verification/)
- [Secure a real HTTP API: project ZIP](https://softwaresecurity.com/downloads/projects/express-api-519f6cf7960d8d96.zip) — Node.js 22+ · npm
- [Verify tokens through rotation and revocation: project ZIP](https://softwaresecurity.com/downloads/projects/token-lifecycle-2351d64b881f0c32.zip) — Node.js 22+ · npm

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

## Sources

- [RFC 8725: JWT best current practices](https://www.rfc-editor.org/rfc/rfc8725.html). February 2020 (BCP 225).
- [IETF draft: JWT best current practices update (rfc8725bis)](https://datatracker.ietf.org/doc/draft-ietf-oauth-rfc8725bis/). draft-ietf-oauth-rfc8725bis-10.
- [RFC 9068: JWT profile for OAuth 2.0 access tokens](https://www.rfc-editor.org/rfc/rfc9068.html). October 2021.
- [RFC 7519: JWT claims](https://www.rfc-editor.org/rfc/rfc7519.html). May 2015.
- [RFC 7518: JSON Web Algorithms, HMAC key size](https://www.rfc-editor.org/rfc/rfc7518.html). May 2015; updated by RFC 9864.
- [RFC 7517: JSON Web Key](https://www.rfc-editor.org/rfc/rfc7517.html). May 2015.
- [OWASP JSON Web Token Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_Cheat_Sheet.html)
- [jose: jwtVerify options](https://github.com/panva/jose/blob/main/docs/jwt/verify/functions/jwtVerify.md)
- [Neil Madden: psychic signatures in Java (CVE-2022-21449)](https://neilmadden.blog/2022/04/19/psychic-signatures-in-java/). 19 April 2022.
- [Spring Security: OAuth 2.0 resource server JWT, RFC 9068 validation](https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html)
- [Microsoft: configure JWT bearer authentication in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/security/authentication/configure-jwt-bearer-authentication)