# HS256 vs RS256, Settled by Spec and Measurement — Choosing a JWT Signing Algorithm, Rotating Keys, Migrating Without Downtime

> Should you sign JWTs with HS256 or RS256? Grounded strictly in RFC 7518/8725, RFC 9068 and NIST SP 800-57/800-131A: the real difference (trust boundaries, not strength), the MUST-level key-size rules, algorithm confusion attacks that are still live in 2026, measured benchmarks for signing/verification/token size, a production-grade verifier in jose v6, key rotation, a zero-downtime HS256→RS256 migration, and regression tests.

- Published: 2026-08-09
- Author: 友田 陽大
- Tags: JWT, 認証・認可, セキュリティ, TypeScript, アーキテクチャ設計
- URL: https://tomodahinata.com/en/blog/jwt-hs256-vs-rs256-signing-algorithm-selection-key-rotation-guide
- Category: Authentication & authorization
- Pillar guide: https://tomodahinata.com/en/blog/auth-platform-selection-2026-cognito-auth0-clerk-supabase

## Key points

- The difference is not speed — it is the number of trust boundaries. With HS256 anyone who can verify can also issue; with RS256 only the holder of the private key can sign, and anyone can verify
- If verification stays inside one trust domain, HS256 is fine. If verifiers multiply, cross organizational lines, or you don't want to hand out a key, use RS256. RFC 7518 marks HS256 Required, RS256 Recommended, ES256 Recommended+
- Key sizes are MUST-level: HS256 needs a key at least as large as the hash output (256 bits), RS256/PS256 need 2048 bits or more. But HS256's real strength comes from entropy — RFC 8725 says human-memorizable passwords MUST NOT be used directly
- The biggest practical risk is algorithm confusion (swapping RS256 for HS256). The NDSS 2026 study of 43 libraries found new CVEs assigned in 2024–2025, so this is not history. The fix is a fixed algorithm allowlist and one key per algorithm
- On my own hardware (Node v26 / Apple Silicon), HS256 verifies about 10× faster than RS256-2048 and signs about 222× faster — but the token is 332 B versus 631 B. Numbers inform the decision; they don't make it

---

"Should we sign our JWTs with HS256 or RS256?"

Answering "RS256, it's more secure" is half right and half wrong. **The two do not differ in strength; they differ in where you place trust.** RFC 7518 marks HS256 as **Required** — every conforming implementation must support it — and RS256 as **Recommended**. The spec does not treat HS256 as the weak option.

Incidents happen anyway, and almost always for one reason: **nobody designed for who receives the key.** The moment you distribute an HS256 shared secret to five verifying microservices, your system has **six token issuers**. That is not an implementation bug. It is the consequence of a choice.

This article exists to make that choice on the basis of **spec text and measurements from my own machine**, rather than on vibes. It covers:

- The essential difference — the number of trust boundaries — in a single table
- The **MUST-level requirements** imposed by RFC 7518, RFC 8725 and RFC 9068
- Measured signing/verification cost and token length, so you know the orders of magnitude
- Evidence that algorithm confusion is **still alive** (NDSS 2026)
- A production-grade, type-safe verification layer built on `jose` v6
- **Key rotation** and a **zero-downtime HS256 → RS256 migration**, as procedures
- Regression tests that pin the attacks down mechanically

> **Ground rules for this article.** Specification claims come from primary sources: IETF RFCs, NIST Special Publications and the OpenID Foundation. The benchmarks are **measurements from my own machine**, and the script is in the body so you can reproduce them. Numbers depend heavily on hardware — **measure on your own environment rather than quoting mine.** Library APIs were checked against the type definitions in `jose@6.2.8`.

---

## 0. The answer: three questions

It's a long article, but the conclusion lives here. Answer in order.

```text
Q1. Is the party that verifies this token allowed to issue one?
    ├─ NO  → RS256 / ES256 (asymmetric)  ← most decisions end here
    └─ YES → go to Q2

Q2. Will verification stay inside one trust domain, indefinitely?
    (No planned service split, BFF, or hand-off to another team?)
    ├─ NO  → RS256 / ES256
    └─ YES → go to Q3

Q3. Do you have operations that can distribute and rotate a 32-byte
    random key safely through a secrets manager?
    ├─ NO  → RS256 (distributing public keys survives operational mistakes)
    └─ YES → HS256 is enough. Fast, short, simple key management.
```

| | **HS256** | **RS256** |
| --- | --- | --- |
| Class | Symmetric MAC (HMAC-SHA256) | Asymmetric signature (RSASSA-PKCS1-v1_5 + SHA-256) |
| Key needed to verify | **the same secret used to sign** | the public key (no secret) |
| Anyone who can verify | **can also forge** | cannot forge |
| Trust boundary | issuer and verifier collapse into one domain | issuance and verification are separable |
| RFC 7518 requirement | Required | Recommended |
| MUST-level key size | at least the hash output (**≥ 256 bits**) | **≥ 2048 bits** |
| Key distribution | sharing a secret (riskier with each recipient) | publish a public key over JWKS |
| Rotation | every verifier must be updated in lockstep | add a new `kid` to the JWKS |
| Typical use | monolith session tokens, short-lived internal tokens, webhook signatures | OIDC ID Tokens, OAuth 2.0 access tokens, multi-service / multi-tenant |

**"HS256 because it's faster" is not a reason.** (The measured verification gap below is 0.014 milliseconds per request.) **"RS256 because I don't want to hand out a key" is a reason.**

---

## 1. The mental model: what needs a secret to read, and what needs a secret to change

The clearest framing I know comes from Tom Tervoort's Black Hat USA 2023 whitepaper, which lays out the JOSE forms along two axes: **does reading require a secret, and does changing require a secret?**

| | `alg:none` JWS | **symmetric JWS (HS256)** | **asymmetric JWS (RS256)** | symmetric JWE | asymmetric JWE |
| --- | --- | --- | --- | --- | --- |
| Needs secret to read | no | no | no | yes | yes |
| **Needs secret to change** | **no** | **yes** | **yes** | yes | **no** |

The paper states the distinction directly: "when using a symmetric JWS algorithm (like HS256) the JWT can both be created and validated with the same shared secret; meanwhile, when using an asymmetric algorithm (like RS256) the JWT can only be created by the private key owner but validated by everyone."

Which gives you the one-line consequence:

> **With HS256, "handing over the verification key" is identical to "handing over issuance rights."**

Note also that Base64url is not encryption, so **the payload is readable by anyone** under either algorithm (the whole first row is `no`). Whether it is acceptable to put personal data or internal identifiers in a JWT is a question that is **independent** of the HS256/RS256 choice. Don't conflate them.

### 1.1 Trust boundaries, drawn

```text
[HS256] shared-secret model — every recipient of the key becomes an issuer
                    ┌──────────────┐
     ┌─────────────►│ orders svc   │ holds K → can forge ⚠
     │              └──────────────┘
┌────┴─────┐        ┌──────────────┐
│ auth      │───────►│ inventory    │ holds K → can forge ⚠
│ server K  │        └──────────────┘
└────┬─────┘        ┌──────────────┐
     └─────────────►│ notifications│ holds K → can forge ⚠
                    └──────────────┘
     Compromise of one service = compromise of the whole auth system

[RS256] public-key model — there is exactly one issuer
                    ┌──────────────┐
     ┌─────────────►│ orders svc   │ public key only → verify only ✅
     │  JWKS (pub)  └──────────────┘
┌────┴─────┐        ┌──────────────┐
│ auth      │───────►│ inventory    │ public key only → verify only ✅
│ server sk │        └──────────────┘
└────┬─────┘        ┌──────────────┐
     └─────────────►│ notifications│ public key only → verify only ✅
                    └──────────────┘
     One service compromised, tokens still cannot be forged
```

This shows up as a difference in **blast radius** during an incident. If an HS256 key leaks through service A's logs, you must halt the auth system and swap keys across every service at once. With RS256, what leaked was a public key, and nothing happens.

---

## 2. What the specifications actually require

To avoid designing on "feels secure," here is the text.

### 2.1 RFC 7518 Section 3.1: implementation requirements

RFC 7518 (JSON Web Algorithms) Section 3.1 defines the JWS `alg` values and their implementation requirements. The main entries:

| `alg` | Meaning | Implementation requirement |
| --- | --- | --- |
| **HS256** | HMAC using SHA-256 | **Required** |
| HS384 / HS512 | HMAC using SHA-384 / SHA-512 | Optional |
| **RS256** | RSASSA-PKCS1-v1_5 using SHA-256 | **Recommended** |
| RS384 / RS512 | RSASSA-PKCS1-v1_5 using SHA-384 / SHA-512 | Optional |
| **ES256** | ECDSA using P-256 and SHA-256 | **Recommended+** |
| ES384 / ES512 | ECDSA using P-384 / P-521 | Optional |
| PS256 / PS384 / PS512 | RSASSA-PSS with MGF1 | Optional |
| **none** | No digital signature or MAC performed | Optional |

Three things to take from this table.

1. **HS256 is "Required"** — it is the most interoperable algorithm in the ecosystem, not a deprecated one.
2. **ES256 is the only "Recommended+".** In RFC 7518's terminology the `+` indicates that the requirement strength is likely to be increased in a future version of the specification. For long-lived new designs, ES256 is where the spec is pointing.
3. **`none` exists in the spec as Optional.** That is the source of every incident described below.

### 2.2 Key sizes are MUST, not SHOULD

No room to negotiate here:

- **HS256 (Section 3.2):** "A key of the same size as the hash output (for instance, 256 bits for 'HS256') or larger MUST be used with this algorithm." And: "The comparison of the computed HMAC value to the JWS Signature value MUST be done in a constant-time manner to thwart timing attacks."
- **RS256 (Section 3.3):** "A key of size 2048 bits or larger MUST be used with these algorithms."
- **PS256 (Section 3.5):** likewise 2048 bits or larger.

I have repeatedly seen `"my-secret-key"` (13 bytes = 104 bits) pass for "256 bits or more" in someone's head. **The visible length of a string and the entropy of a key are different quantities.**

### 2.3 Entropy: what really determines HS256's strength

Table 3 of NIST SP 800-57 Part 1 Rev. 5 (maximum security strengths for hash and hash-based functions) carries a note that is easy to miss:

> "Note that in the case of HMAC and KMAC, which require keys, the estimated security strength assumes that **the length and entropy used to generate the key are at least equal to the security strength**."

So HMAC-SHA256's strength is not "256 bits because it's SHA-256" — it is **determined by the entropy of the key**. Read RFC 8725 with that in mind and its strictness makes sense:

- **RFC 8725 Section 2.2 (Weak symmetric keys):** "Some applications use a keyed Message Authentication Code (MAC) algorithm, such as 'HS256', to sign tokens but supply a weak symmetric key with insufficient entropy (such as a human-memorable password). Such keys are vulnerable to offline brute-force or dictionary attacks once an attacker gets hold of such a token."
- **RFC 8725 Section 3.5:** "In particular, human-memorizable passwords MUST NOT be directly used as the key to a keyed-MAC algorithm such as 'HS256'."

The word "offline" is what makes this severe. Once an attacker holds a single legitimate token, they can grind keys on their own GPUs without ever touching your server. Rate limits and WAFs are irrelevant.

**Generating an HS256 key properly:**

```bash
# 32 bytes (256 bits) from a cryptographic RNG. This is the floor.
openssl rand -base64 32
# Output is 44 base64 chars. Never reuse an example value — generate your own

# Or in Node.js
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"
```

```ts
// ❌ never do this
const secret = new TextEncoder().encode("supersecret2026");

// ❌ equally bad — env indirection changes nothing if the value is human-chosen
const secret = new TextEncoder().encode(process.env.JWT_SECRET!); // what if it's "changeme"?

// ✅ enforce the entropy requirement at startup and fail fast
import { z } from "zod";

/**
 * Require the JWT signing key to be >= 32 random bytes, base64url-encoded.
 * RFC 7518 3.2 mandates "at least the hash output length", but real strength comes
 * from entropy (NIST SP 800-57 Pt.1 Rev.5, Table 3 note). Constrain origin, not just length.
 */
const HmacSecretSchema = z
  .string()
  .min(43, "JWT_SIGNING_KEY must be >= 32 bytes (base64url). Generate with: openssl rand -base64 32")
  .transform((raw, ctx) => {
    // Node's base64 decoder silently drops characters outside the alphabet, so a naive decode
    // "decodes" a human-chosen string like "MyVeryLongButTotallyMemorablePassword..." into 34
    // bytes. Re-encode and compare to confirm the input is a canonical encoding.
    const normalized = raw.replace(/-/g, "+").replace(/_/g, "/").replace(/=+$/, "");
    const bytes = Buffer.from(normalized, "base64");
    if (!/^[A-Za-z0-9+/]+$/.test(normalized) ||
        Buffer.from(bytes).toString("base64").replace(/=+$/, "") !== normalized) {
      ctx.addIssue({ code: "custom", message: "key must be canonical base64/base64url from a CSPRNG" });
      return z.NEVER;
    }
    if (bytes.byteLength < 32) {
      ctx.addIssue({ code: "custom", message: "decoded key must be >= 256 bits" });
      return z.NEVER;
    }
    return new Uint8Array(bytes);
  });

// Evaluated at module load — the process refuses to start with a weak key
export const HMAC_SECRET = HmacSecretSchema.parse(process.env.JWT_SIGNING_KEY);
```

Returning `z.NEVER` makes the transform failure propagate correctly at the type level too.

**But all this schema can reject is a short key and a string that is not shaped like random output.** Entropy cannot be validated from a string: any 44 characters drawn from the base64 alphabet pass this gate, including something as obviously weak as `aaaa…`. **What guarantees the key's origin is process, not a schema.** Restrict generation to `openssl rand` or a secrets manager and leave no path where a human picks the value. The schema's job is to fail the process at startup, not to prove the key is strong.

---

## 3. Measurements: knowing the orders of magnitude

These debates go in circles because nobody brings numbers. Here are mine. **They come from my machine. Measure yours before deciding.**

**Environment:** Node.js v26.0.0 / darwin arm64 (Apple Silicon), calling `node:crypto` (OpenSSL) directly, 2000 iterations each. JWT-library overhead is excluded.

### 3.1 Signing and verification cost

| Algorithm | Sign (µs/op) | Sign (ops/s) | Verify (µs/op) | Verify (ops/s) |
| --- | --- | --- | --- | --- |
| **HS256** | **1.8** | 541,272 | **1.5** | 652,227 |
| **RS256 (RSA-2048)** | 410 | 2,439 | **15.9** | 63,091 |
| RS256 (RSA-4096) | 2,989.7 | 334 | 57.6 | 17,362 |
| ES256 (P-256) | 27.6 | 36,188 | 52.8 | 18,945 |
| EdDSA (Ed25519) | 24.8 | 40,391 | 83.6 | 11,958 |

This is more nuanced than "RS256 is slow."

1. **Verification — the side that runs per request — differs by 15.9 µs versus 1.5 µs.** Roughly 10× as a ratio, but the gap itself is **0.014 milliseconds per request**. Given that a single database query costs milliseconds, this becomes a bottleneck only at genuinely high throughput.
2. **Signing differs by about 222×** (410 µs versus 1.8 µs). RSA private-key operations are intrinsically expensive. **A design that mints a new token on every request pairs badly with RS256** — that is a fact that actually changes decisions.
3. **ECDSA and EdDSA verify more slowly than they sign.** RSA's small public exponent makes verification very cheap, and in my measurements RS256-2048 verification (15.9 µs) is **faster** than ES256 verification (52.8 µs). "Elliptic curves are always faster" is wrong.
4. **RSA-4096 signing is about 7.3× heavier than RSA-2048.** If you need more strength, moving to ES256 gives a far better return than lengthening the RSA key.

### 3.2 Token length

Compact serialization byte counts for identical claims (`iss` / `sub` / `aud` / `exp` / `iat` / `jti` / `scope`, with `typ: "at+jwt"` and a `kid`):

With identical headers and payloads, only the signature segment differs. Base64url signature lengths are 43 chars for HMAC-256, 86 for P-256/Ed25519, 342 for RSA-2048 and 683 for RSA-4096, so every row is reproducible arithmetically from the 332-byte HS256 baseline.

| Algorithm | Token length (bytes) | vs HS256 |
| --- | --- | --- |
| **HS256** | **332** | 1.00× |
| ES256 (P-256) | 375 | 1.13× |
| EdDSA (Ed25519) | 375 | 1.13× |
| **RS256 (RSA-2048)** | **631** | 1.90× |
| RS256 (RSA-4096) | 972 | 2.93× |

**RS256 tokens are roughly twice the size of HS256 tokens.** In practice this constrains you more often than latency does:

- Headroom against the per-cookie browser limit (commonly 4 KB) if you store it in a cookie
- Reverse-proxy header limits if you send it in `Authorization` (nginx's `large_client_header_buffers` defaults to 8 KB per buffer)
- Egress volume, since it rides on every API request: **299 bytes × request count**

At 10,000 requests per second that is 299 B × 10,000 ≈ 3.0 MB/s of difference. Usually negligible — but you should say "negligible" **after measuring**.

### 3.3 Reproduction script

Copy this and run `node bench.mjs`.

```js
// bench.mjs — measure signing and verification cost for HS256 / RS256 / ES256 / EdDSA
import {
  createSign, createVerify, createHmac, generateKeyPairSync,
  timingSafeEqual, sign as nodeSign, verify as nodeVerify,
} from "node:crypto";

const N = 2000;
const data = Buffer.from("eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1XzAxIn0");

/** Warm up once, then time N iterations; report µs/op and throughput */
const bench = (label, fn) => {
  fn();
  const start = process.hrtime.bigint();
  for (let i = 0; i < N; i++) fn();
  const ms = Number(process.hrtime.bigint() - start) / 1e6;
  return { label, opsPerSec: Math.round(N / (ms / 1000)), usPerOp: +((ms * 1000) / N).toFixed(1) };
};

const rows = [];
const secret = Buffer.alloc(32, 7);
const mac = createHmac("sha256", secret).update(data).digest();
rows.push(bench("HS256 sign", () => createHmac("sha256", secret).update(data).digest()));
rows.push(bench("HS256 verify", () =>
  timingSafeEqual(createHmac("sha256", secret).update(data).digest(), mac)));

for (const bits of [2048, 4096]) {
  const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: bits });
  const sig = createSign("sha256").update(data).sign(privateKey);
  rows.push(bench(`RS256 sign (RSA-${bits})`, () => createSign("sha256").update(data).sign(privateKey)));
  rows.push(bench(`RS256 verify (RSA-${bits})`, () => createVerify("sha256").update(data).verify(publicKey, sig)));
}

const ec = generateKeyPairSync("ec", { namedCurve: "P-256" });
const ecSig = createSign("sha256").update(data).sign(ec.privateKey);
rows.push(bench("ES256 sign (P-256)", () => createSign("sha256").update(data).sign(ec.privateKey)));
rows.push(bench("ES256 verify (P-256)", () => createVerify("sha256").update(data).verify(ec.publicKey, ecSig)));

const ed = generateKeyPairSync("ed25519");
const edSig = nodeSign(null, data, ed.privateKey);
rows.push(bench("EdDSA sign (Ed25519)", () => nodeSign(null, data, ed.privateKey)));
rows.push(bench("EdDSA verify (Ed25519)", () => nodeVerify(null, data, ed.publicKey, edSig)));

console.log(process.version, process.arch, process.platform);
console.table(rows);
```

---

## 4. The danger zone: algorithm confusion is still live

You cannot discuss HS256 and RS256 without addressing the structural weakness that lets you **mix them**.

### 4.1 How the attack works

RFC 8725 Section 2.1 describes it directly:

> "An 'RS256' (RSA, 2048 bit) parameter value can be changed into 'HS256' (HMAC, SHA-256), and some libraries would try to validate the signature using HMAC-SHA256 and using the RSA public key as the HMAC shared secret."

An RS256 public key is, by definition, **public** — anyone can fetch it from the JWKS endpoint. So the attacker forges arbitrary tokens like this:

```text
1. GET https://auth.example.com/.well-known/jwks.json  → obtain public key `pub`
2. Log in legitimately, grab a token, change "role":"user" to "role":"admin"
3. Change the header "alg":"RS256" to "alg":"HS256"
4. Compute HMAC-SHA256(key = bytes of pub, header.payload)
5. The server reads alg, picks HMAC verification, uses pub as the key → valid → privilege escalation
```

The root cause is that **the algorithm used for verification is read from the very token the attacker can rewrite.** Tervoort puts it as a criticism of the JOSE design: the `alg` parameter "is part of the token itself. Because the token could have been spoofed by an attacker, this basically means it's the attacker who tells the verifier what kind of cryptography to use."

### 4.2 Not "already fixed" — the NDSS 2026 evidence

It is tempting to file this under 2015 history. **"Token Time Bomb: Evaluating JWT Implementations for Vulnerability Discovery,"** presented at the NDSS Symposium 2026 by researchers from Tsinghua University and the National University of Defense Technology, shows otherwise.

The paper builds a fuzzing tool called JWTeemo and systematically evaluates **43 JWT libraries across 10 programming languages**:

| Metric | Value |
| --- | --- |
| Scope | **43 libraries / 10 languages** |
| Previously unknown vulnerabilities found | **31** |
| CVEs assigned | **20** |
| Algorithm Confusion | **2** |
| Sign/Encryption Confusion | 2 |
| JWT Format Confusion | 4 implementations |
| Billion Hashes Attack (DoS) | 10 |
| Compression DoS | 13 |

The Algorithm Confusion instances are **CVE-2024-57453 (libjwt, C)** and **CVE-2024-57454 (cpp-jwt, C++)** — **CVEs assigned in 2024 and later**, not history (though as of August 2026 neither ID is published in NVD or MITRE; the source is Table I of the paper). The paper describes the scenario:

> "The attacker first obtains the public key used for validating the RSA signature and logs in normally to get a JWS containing their role. The attacker then modifies the alg claim in the JWT Header to HS256 and changes the payload's role to admin. Finally, the attacker uses the obtained public key as the HMAC secret to sign the JWT and create a forged JWT."

The authors received acknowledgments and bug bounties from Apache, Kubernetes, Let's Encrypt, RedHat and Connect2id, and report that they **discussed their mitigation strategies with the IETF**. JWT implementation quality is a live problem.

### 4.3 The fix: your application decides `alg`, not the token

The fix is simple, and admits no exceptions.

**RFC 8725 Section 3.1 (Perform Algorithm Verification):**

> "Libraries MUST enable the caller to specify a supported set of algorithms and MUST NOT use any other algorithms when performing cryptographic operations."
>
> "The library MUST ensure that the 'alg' or 'enc' header specifies the same algorithm that is used for the cryptographic operation."
>
> "Each key MUST be used with exactly one algorithm, and this MUST be checked when the cryptographic operation is performed."

That third sentence — one key, one algorithm — is what kills algorithm confusion structurally: using an RSA public key as an HMAC secret becomes **impossible by construction**.

**RFC 8725 Section 3.2 (Use Appropriate Algorithms)** allows `none` only where the JWT is cryptographically protected by other means, and then states:

> "JWT libraries SHOULD NOT generate JWTs using 'none' unless explicitly requested to do so by the caller. Similarly, JWT libraries SHOULD NOT consume JWTs using 'none' unless explicitly requested by the caller."

In code, this is enough:

```ts
// ❌ leaving it to library defaults (alg is not being verified)
await jwtVerify(token, key);

// ✅ the application decides. The token's claim about itself is not evidence.
await jwtVerify(token, key, { algorithms: ["RS256"] });
```

`jose` is solid here — its type definitions state explicitly that "**Unsecured JWTs (`{ "alg": "none" }`) are never accepted by this API**." State `algorithms` anyway. **When someone later changes the type of `key`, that one line is the only thing standing guard.**

---

## 5. Production implementation: a type-safe, observable verification layer

The design principles first:

- **Centralize verification in one place** (SRP/DRY). Do not call `jwtVerify` from individual routes.
- **Trap exceptions at the boundary and return a `Result`.** An auth failure is a normal branch, not an exceptional event.
- **Classify failure reasons and emit metrics** — but **never log tokens or PII**.
- **Fail closed when JWKS cannot be fetched.** "We couldn't get the key, so let it through" is never acceptable.
- **Validate claim shape with Zod.** A valid signature and correct contents are separate questions.

### 5.1 Shared types (single source of truth for issuer and verifier)

```ts
// src/auth/claims.ts
import { z } from "zod";

/**
 * The access-token claim contract this application accepts.
 * jose validates presence and equality of iss/aud/exp/nbf/iat, but it cannot know
 * the shape of application-specific claims (scope / tenant). This is the last line.
 */
export const AccessTokenClaimsSchema = z.object({
  iss: z.url(),
  sub: z.string().min(1),
  aud: z.union([z.string(), z.array(z.string()).nonempty()]),
  exp: z.number().int().positive(),
  iat: z.number().int().positive(),
  jti: z.string().min(1),
  /** RFC 6749 space-delimited scope; normalized to an array for use */
  scope: z
    .string()
    .transform((s) => s.split(" ").filter(Boolean))
    .pipe(z.array(z.string()).nonempty()),
  /** Tenant membership. Required, because it is the authorization key for the data layer (RLS). */
  tenant_id: z.uuid(),
});

export type AccessTokenClaims = z.infer<typeof AccessTokenClaimsSchema>;

/** Failure taxonomy. These become metric dimensions, so keep each one single-meaning. */
export type AuthFailureReason =
  | "malformed"        // not shaped like a JWT
  | "alg_not_allowed"  // disallowed alg (i.e. suspected confusion attack)
  | "signature"        // signature mismatch
  | "expired"          // past exp
  | "claim"            // iss/aud/typ/sub mismatch
  | "schema"           // signature valid, but claim shape violates the contract
  | "unknown_kid"      // no key matches the kid (kid is attacker-controlled input)
  | "key_unavailable"; // the JWKS fetch itself failed — our outage (not an invalid token)

export type AuthResult =
  | { readonly ok: true; readonly claims: AccessTokenClaims }
  | { readonly ok: false; readonly reason: AuthFailureReason };
```

Making `AuthResult` a discriminated union means that forgetting `if (!result.ok)` at a call site is a compile error. **The goal is to make a missing auth check impossible to type.**

### 5.2 Verifying RS256 (JWKS + fail-closed)

```ts
// src/auth/verify-rs256.ts
import { createRemoteJWKSet, jwtVerify } from "jose";
// jose v6 does not export the error classes from the root entry — they live at "jose/errors"
import {
  JOSEAlgNotAllowed,
  JWKSNoMatchingKey,
  JWKSTimeout,
  JWSSignatureVerificationFailed,
  JWTExpired,
  JWTClaimValidationFailed,
  JWTInvalid,
} from "jose/errors";
import { AccessTokenClaimsSchema, type AuthFailureReason, type AuthResult } from "./claims";

/**
 * Keep exactly one JWKS resolver at module scope.
 * - cacheMaxAge: how long keys stay cached (default 10 minutes)
 * - cooldownDuration: throttle for repeated fetches (default 30s). Even when an unknown
 *   kid arrives, refetching is bounded — this stops your API being used to DoS the JWKS endpoint
 * - timeoutDuration: fetch timeout (jose defaults to 5s; shortened to 3s here). Too long and it drags the request down with it
 */
export const jwks = createRemoteJWKSet(new URL(process.env.JWKS_URI!), {
  cacheMaxAge: 10 * 60_000,
  cooldownDuration: 30_000,
  timeoutDuration: 3_000,
});

const ISSUER = process.env.TOKEN_ISSUER!;
const AUDIENCE = process.env.TOKEN_AUDIENCE!;

export async function verifyAccessToken(
  token: string,
  // Make the key resolver injectable: production defaults to the JWKS above,
  // tests can pass a local JWKS instead.
  keys: Parameters<typeof jwtVerify>[1] = jwks,
): Promise<AuthResult> {
  try {
    const { payload } = await jwtVerify(token, keys, {
      // (1) The application decides alg. The token's self-declaration is not evidence (RFC 8725 §3.1)
      algorithms: ["RS256"],
      // (2) Pin the token type to prevent cross-JWT confusion (RFC 9068 §4 / RFC 8725 §3.11)
      typ: "at+jwt",
      // (3) Pin issuer and audience (RFC 8725 §3.8 / §3.9)
      issuer: ISSUER,
      audience: AUDIENCE,
      // (4) Keep clock tolerance in seconds. Minutes make expiry meaningless.
      clockTolerance: 5,
      // (5) Reject tokens issued too long ago — a defense independent of exp
      maxTokenAge: "1 hour",
      // (6) Claims that must be present. State the ones that aren't checked by default.
      requiredClaims: ["exp", "sub", "jti", "tenant_id", "scope"],
    });

    // (7) A valid signature and a contract-conformant body are different things. Pin the shape here.
    const parsed = AccessTokenClaimsSchema.safeParse(payload);
    if (!parsed.success) return { ok: false, reason: "schema" };

    return { ok: true, claims: parsed.data };
  } catch (error) {
    return { ok: false, reason: classify(error) };
  }
}

/** Map jose error classes onto a finite, observable set of reason codes */
function classify(error: unknown): AuthFailureReason {
  if (error instanceof JWTExpired) return "expired";
  if (error instanceof JOSEAlgNotAllowed) return "alg_not_allowed";
  if (error instanceof JWSSignatureVerificationFailed) return "signature";
  if (error instanceof JWTClaimValidationFailed) return "claim";
  if (error instanceof JWTInvalid) return "malformed";
  // kid is attacker-controlled input, so "no key for this kid" is a token problem — reject with 401.
  if (error instanceof JWKSNoMatchingKey) return "unknown_kid";
  // A failed fetch is our own outage. It does not imply the token is bad.
  if (error instanceof JWKSTimeout) return "key_unavailable";
  return "signature"; // unknown failures get the most conservative treatment (fail-closed)
}
```

**Four points worth calling out.**

1. **Write out `algorithms`, `typ`, `issuer` and `audience` — all of them.** Omit any one and it becomes attack surface. `typ: "at+jwt"` in particular is exactly the validation step RFC 9068 mandates: verify that "the 'typ' header value is 'at+jwt' or 'application/at+jwt' and reject tokens carrying any other value." That closes off replaying an ID Token as an access token (RFC 8725 Section 2.8, Cross-JWT Confusion) at the type level.
2. **Keep `clockTolerance` in seconds.** Set it to five minutes and a token you revoked stays alive for five minutes. With NTP running, a few seconds is plenty.
3. **Separate `unknown_kid` from `key_unavailable`.** Failing to *fetch* the JWKS is **your outage**, not an attack. Merging it into `signature` means that during an IdP incident, your alerting reads "mass signature failures = attack," and you make the wrong call. But `kid` is **attacker-controlled input**, so "no key matches this kid" is a problem with the token, not an outage. Collapse the two and anyone can force 503s just by sending forged tokens, while the attack itself looks like an IdP incident.
4. **Still don't let it through (fail-closed).** `jose`'s `createRemoteJWKSet` keeps its cache for `cacheMaxAge` after a successful fetch, so blips are absorbed naturally. If it still can't resolve, returning 503 is correct. Passing the request is not.

### 5.3 Verifying HS256 (symmetric)

If you chose HS256, pinning `algorithms` is just as mandatory.

```ts
// src/auth/verify-hs256.ts
import { jwtVerify } from "jose";
import { HMAC_SECRET } from "./env";
import { AccessTokenClaimsSchema, type AuthResult } from "./claims";

export async function verifySessionToken(token: string): Promise<AuthResult> {
  try {
    const { payload } = await jwtVerify(token, HMAC_SECRET, {
      // State it even though only a symmetric key is passed. If `key` ever becomes a JWKS,
      // this single line is the last barrier against algorithm confusion.
      algorithms: ["HS256"],
      typ: "at+jwt",
      issuer: process.env.TOKEN_ISSUER!,
      audience: process.env.TOKEN_AUDIENCE!,
      clockTolerance: 5,
      requiredClaims: ["exp", "sub", "jti", "tenant_id", "scope"],
    });
    const parsed = AccessTokenClaimsSchema.safeParse(payload);
    return parsed.success ? { ok: true, claims: parsed.data } : { ok: false, reason: "schema" };
  } catch {
    return { ok: false, reason: "signature" };
  }
}
```

The constant-time comparison mandated by RFC 7518 Section 3.2 is `jose`'s responsibility here. **Never write your own HMAC computation followed by `===`.**

### 5.4 The issuing side (always emit `kid`)

```ts
// src/auth/issue.ts
import { SignJWT, importPKCS8 } from "jose";
import { randomUUID } from "node:crypto";

/** Import the private key once at startup. Re-parsing per call costs more than the RSA signature. */
const signingKey = await importPKCS8(process.env.SIGNING_PRIVATE_KEY_PEM!, "RS256");
const SIGNING_KID = process.env.SIGNING_KEY_ID!; // e.g. "2026-08-a"

export async function issueAccessToken(input: {
  subject: string;
  tenantId: string;
  scope: readonly string[];
}): Promise<string> {
  return new SignJWT({ tenant_id: input.tenantId, scope: input.scope.join(" ") })
    // kid is the lifeline for rotation. Without it you cannot run two keys in parallel (RFC 7515 §4.1.4)
    .setProtectedHeader({ alg: "RS256", kid: SIGNING_KID, typ: "at+jwt" })
    .setIssuer(process.env.TOKEN_ISSUER!)
    .setAudience(process.env.TOKEN_AUDIENCE!)
    .setSubject(input.subject)
    .setJti(randomUUID())      // key for revocation lists / replay detection
    .setIssuedAt()
    .setExpirationTime("15m")  // keep access tokens short-lived; revocation lives on the refresh side
    .sign(signingKey);
}
```

### 5.5 Observability: what to record, and what never to

The point of observability in the auth layer is to distinguish **signs of attack** from **your own outages**.

```ts
// src/auth/middleware.ts
import { verifyAccessToken } from "./verify-rs256";

/**
 * Safe to record: reason code, alg (readable from the header), kid, elapsed time
 * Never record: the token itself, the payload, sub, email addresses or other PII
 *   → writing tokens to logs turns log-read access into impersonation access
 */
export async function authenticate(req: Request): Promise<Response | AuthContext> {
  const header = req.headers.get("authorization");
  if (!header?.startsWith("Bearer ")) {
    metrics.increment("auth.failure", { reason: "malformed" });
    return unauthorized();
  }

  const result = await verifyAccessToken(header.slice(7));

  if (!result.ok) {
    metrics.increment("auth.failure", { reason: result.reason });

    // A confusion attempt is a rare event, so give it its own alert.
    // A series that should sit at zero going non-zero means an attack — or a serious client bug.
    if (result.reason === "alg_not_allowed") {
      logger.warn("jwt.alg_rejected", { path: new URL(req.url).pathname });
    }
    // Key resolution failure is an IdP-side outage. Return 503 so it isn't mixed in with attacks.
    if (result.reason === "key_unavailable") return serviceUnavailable();

    return unauthorized();
  }

  metrics.increment("auth.success");
  return { userId: result.claims.sub, tenantId: result.claims.tenant_id, scope: result.claims.scope };
}
```

The key idea is having an explicit **series that should be zero in steady state**. `auth.failure{reason="alg_not_allowed"}` will **never** be produced by a correct client. A single data point there means an attack attempt or a serious client defect. That one alert is your post-hoc detection for algorithm confusion.

---

## 6. Key rotation: where HS256 and RS256 diverge the most

The steady-state performance gap was 0.014 milliseconds. **Operationally, the two are not comparable.**

### 6.1 Rotation periods per NIST

Section 5.3.6 of NIST SP 800-57 Part 1 Rev. 5 (cryptoperiod recommendations by key type) gives:

| Key type | Recommended originator-usage period | Recipient-usage period |
| --- | --- | --- |
| **Symmetric authentication key** (the HS256 shared secret) | **no more than 2 years** | no more than 3 years beyond the originator period |
| **Private authentication key** (the RS256 private key) | **no more than 1 or 2 years**, depending on environment and sensitivity | — |
| Public authentication key (the RS256 public key) | no more than 1 or 2 years | — |

On symmetric authentication keys the same section warns: "Note that if a MAC key is compromised, it may be possible for an adversary to modify the data and then recalculate the MAC." A leaked HS256 secret does not merely expose data — it lets an attacker **issue arbitrary tokens**.

### 6.2 Rotating RS256 (no downtime)

With `kid` in place, key replacement requires no interruption on the verifying side.

```text
Phase 1 (T+0)  Generate key pair B. ADD B's public key to the JWKS (keep A).
               → Verifiers can resolve either A or B by kid. Signing still uses A.
               ★ Wait out cacheMaxAge (default 10 min) so every verifier knows B before proceeding.

Phase 2 (T+1h) Switch the issuer's signing key to B (SIGNING_KEY_ID = "2026-08-b").
               → New tokens carry kid=B. Existing kid=A tokens remain valid until expiry.

Phase 3 (T+1h + access-token TTL + margin)
               Confirm via metrics that every A-signed token has expired
               (auth.success{kid="2026-08-a"} reaches 0).

Phase 4        Remove A's public key from the JWKS. Destroy private key A.
```

```json
// .well-known/jwks.json — the state during phases 1–3 (RFC 7517)
{
  "keys": [
    { "kty": "RSA", "use": "sig", "alg": "RS256", "kid": "2026-08-a", "n": "...", "e": "AQAB" },
    { "kty": "RSA", "use": "sig", "alg": "RS256", "kid": "2026-08-b", "n": "...", "e": "AQAB" }
  ]
}
```

Do not forget to **state `alg` on each JWK**. It declares RFC 8725 Section 3.1's one-key-one-algorithm rule from the JWKS side, and `jose`'s `createRemoteJWKSet` respects `alg`, `kid`, `use` and `key_ops` during key selection.

**The wait between Phase 1 and Phase 2 is the operationally critical part.** Skip it and any verifier that hasn't refetched the JWKS cannot resolve `kid=B` and fails with `JWKSNoMatchingKey`. With a library like jose, which refetches on an unknown `kid`, the failure window is bounded by `cooldownDuration`; with one that never refetches, it lasts the full `cacheMaxAge`.

### 6.3 Rotating HS256 (much harder)

With symmetric keys you cannot "publish and wait." You must distribute a **new secret to every verifier, simultaneously, without downtime**.

```text
Phase 1  Generate K2 and distribute it to all verifiers as an ADDITIONAL verification key
         → Verifiers accept both K1 and K2 (start of the dual-acceptance window)
         ★ Do not proceed until distribution to every verifier is confirmed
Phase 2  Switch the issuer's signing key to K2
Phase 3  Wait until all K1-signed tokens have expired
Phase 4  Remove K1 from every verifier
```

Verifiers now need to handle multiple keys:

```ts
// HS256 rotation support. Resolving by kid avoids brute-forcing every key.
import { decodeProtectedHeader } from "jose";

// KEY_A / KEY_B are Uint8Arrays already validated by the HmacSecretSchema from 2.3
const HMAC_KEYS: ReadonlyMap<string, Uint8Array> = new Map([
  ["2026-08-a", KEY_A],
  ["2026-08-b", KEY_B],
]);

export async function verifyWithRotation(token: string): Promise<AuthResult> {
  // Narrow to a single key by kid. Trying every key costs time proportional to key count,
  // and makes "which key was used" unobservable.
  const kid = decodeProtectedHeader(token).kid;
  const key = kid ? HMAC_KEYS.get(kid) : undefined;
  // kid is what the token claims. Failing to resolve it is a token problem, not an outage.
  if (!key) return { ok: false, reason: "unknown_kid" };
  // The rest is as in 5.3: always pass algorithms: ["HS256"] to jwtVerify
  return verifyHs256(token, key);
}
```

**Use `kid` with HS256 too.** Symmetric does not mean you can skip it. Without `kid` you brute-force every key during rotation, verification cost scales with key count, and you lose visibility into which key is in use.

### 6.4 The difference, summarized

| | HS256 | RS256 |
| --- | --- | --- |
| Who receives the new key | **every verifier (a secret)** | nobody — append to the JWKS (public) |
| Distribution channel | secrets manager / redeploy | HTTP (the existing JWKS fetch path) |
| Adding a verifier means | **granting issuance rights** | telling them the JWKS URL |
| Impact of key leak | **full reissue + simultaneous update of all verifiers** | public key: nothing. private key: swap on the issuer only |
| Emergency revocation (kill switch) | attack continues until every verifier is updated | delete the `kid` from the JWKS → propagates within `cacheMaxAge` |

That last row is decisive. **With RS256, deleting one `kid` from the JWKS invalidates every token signed by that key within minutes.** HS256 has no such lever.

---

## 7. Migrating from HS256 to RS256 without downtime

"We started with HS256. Now we have more services and want RS256." This is the most common request I get. Here is the procedure.

### 7.1 Framing the problem

Migration is hard because **changing the signing algorithm invalidates every existing token at once**. Done naively, every user is force-logged-out. So the phases follow one principle: **make the verifying side dual-capable first**.

```text
              Issue alg     Accepted algs           User impact
Phase 0       HS256         HS256                   — (today)
Phase 1       HS256         HS256 + RS256           none  ★ wait for every verifier to deploy
Phase 2       RS256         HS256 + RS256           none (existing tokens live until expiry)
Phase 3       RS256         RS256                   none (HS256 tokens have all expired)
```

**Do not advance to Phase 2 until every verifier has deployed Phase 1.** Respect that one rule and forced logouts are zero.

> **Do not use this procedure if the key has leaked.** The phased migration above is for a *planned* migration. If the shared secret leaked — or you suspect it did — then every moment you keep accepting HS256 is a moment an attacker can mint valid tokens. "Wait out the refresh-token TTL (say 30 days)," below, means tolerating 30 days of forgery in that situation. On a leak, skip the phases, stop accepting HS256 immediately, and accept that every user has to log in again. A forced logout is cheaper than a forged token.

### 7.2 Verification during the migration window

When going dual, do **not** naively write `algorithms: ["HS256", "RS256"]` and pass both keys. That is implementing algorithm confusion yourself.

```ts
// ❌ dangerous: leaves room for the RS256 public key to be used as an HS256 secret
await jwtVerify(token, someKeyResolver, { algorithms: ["HS256", "RS256"] });

// ✅ correct: use the header's alg only to select a mutually exclusive (key, algorithm) pair,
//    then pin to a single algorithm inside that branch
//    (RFC 8725 §3.1: "each key MUST be used with exactly one algorithm")
import { decodeProtectedHeader, jwtVerify, type JWTVerifyOptions } from "jose";
import { jwks } from "./verify-rs256";      // the createRemoteJWKSet from 5.2
import { HMAC_SECRET } from "./env";        // the Zod-validated symmetric key from 2.3
import { AccessTokenClaimsSchema, type AuthResult } from "./claims";

/** Everything except alg is shared (DRY). Only the algorithm and the key differ per branch. */
const COMMON: JWTVerifyOptions = {
  typ: "at+jwt",
  issuer: process.env.TOKEN_ISSUER!,
  audience: process.env.TOKEN_AUDIENCE!,
  clockTolerance: 5,
  requiredClaims: ["exp", "sub", "jti", "tenant_id", "scope"],
};

/** Take a key plus verification options, run the claim contract, return a Result */
async function verifyWith(
  token: string,
  key: Parameters<typeof jwtVerify>[1],
  options: JWTVerifyOptions,
): Promise<AuthResult> {
  try {
    const { payload } = await jwtVerify(token, key, options);
    const parsed = AccessTokenClaimsSchema.safeParse(payload);
    return parsed.success ? { ok: true, claims: parsed.data } : { ok: false, reason: "schema" };
  } catch (error) {
    return { ok: false, reason: classify(error) }; // reuse classify from 5.2
  }
}

export async function verifyDuringMigration(token: string): Promise<AuthResult> {
  let alg: string | undefined;
  try {
    // Reading the header is used ONLY to pick a verification path.
    // The critical part is not feeding that value into the verification parameters.
    alg = decodeProtectedHeader(token).alg;
  } catch {
    return { ok: false, reason: "malformed" };
  }

  switch (alg) {
    case "RS256":
      // Only the JWKS reaches this branch → it can never serve as an HMAC secret
      return verifyWith(token, jwks, { ...COMMON, algorithms: ["RS256"] });
    case "HS256":
      // Only the symmetric key reaches this branch → no path exists for a public key to slip in
      return verifyWith(token, HMAC_SECRET, { ...COMMON, algorithms: ["HS256"] });
    default:
      // none, and every unknown alg, dies here. Defaulting to reject is what fail-closed means.
      return { ok: false, reason: "alg_not_allowed" };
  }
}
```

**Each branch of the `switch` receives only the key that branch can use** — that is one-key-one-algorithm as code. Note that the header's `alg` is used purely for routing, and that inside the chosen route `algorithms` is pinned to an application-side constant.

Phase 3 does not arrive by waiting, though. **It requires actually deleting the HS256 branch and shipping that change.**

```ts
// Phase 3: drop the HS256 route. You are only in Phase 3 once this diff is deployed.
  switch (alg) {
    case "RS256":
      return verifyWith(token, jwks, { ...COMMON, algorithms: ["RS256"] });
    default:
      // HS256 lands here too. To go back, redeploy the Phase 2 build.
      return { ok: false, reason: "alg_not_allowed" };
  }
```

Phase 3 in the table in 7.1 means *this diff is deployed*. Keeping `case "HS256"` and merely watching `auth.success{alg="HS256"}` reach zero is still Phase 2.

### 7.3 Migration metrics and rollback criteria

| Phase | Metric to watch | Condition to advance | Rollback trigger |
| --- | --- | --- | --- |
| Phase 1 | deployed version across all verifying services | 100% on the dual-capable build | — |
| Phase 2 | `auth.success{alg="RS256"}` ramping up | RS256 successes reach the expected rate | `auth.failure` exceeds baseline + 0.1% |
| Phase 2→3 | `auth.success{alg="HS256"}` decaying | **zero for 24 hours** (wait at least the refresh TTL) | HS256 still present → keep waiting |
| Phase 3 | `auth.failure{reason="alg_not_allowed"}` | stays at zero in steady state | non-zero → redeploy the Phase 2 build (waiting does not roll back) |

Note that the wait before Phase 3 is governed by the **refresh token TTL**, not the access token TTL. If refresh tokens live 30 days, the earliest you can drop the HS256 path is 30 days out. Drop it early and you force-log-out everyone who didn't sign in that day.

---

## 8. Beyond RS256: ES256, PS256, and the 2031 question

RS256 isn't the end of the road. For new designs, consider the following.

### 8.1 The NIST transition schedule

The initial public draft of NIST SP 800-131A Rev. 3 (October 2024) states:

> "Deprecate the use of the 112-bit security strength for the classical digital signature and key-establishment mechanisms after December 31, 2030 (rather than requiring a transition to the 128-bit security strength)."
>
> "Currently, a 112-bit security strength for the classical digital signature and key-establishment algorithms does not appear to be in imminent danger of becoming insecure in the near future, so this approach should allow an orderly transition to quantum-resistant algorithms without unnecessary effort for the cryptographic community."

Table 2 of NIST SP 800-57 Part 1 Rev. 5 maps key sizes to security strengths:

| Security strength | Symmetric | RSA (IFC) k | Elliptic curve (ECC) f |
| --- | --- | --- | --- |
| 112 | 3TDEA | **k = 2048** | f = 224–255 |
| 128 | AES-128 | **k = 3072** | **f = 256–383** (P-256) |
| 192 | AES-192 | k = 7680 | f = 384–511 |
| 256 | AES-256 | k = 15360 | f = 512+ |

Which means:

- **RS256 + RSA-2048 = 112-bit strength** → deprecated after end of 2030
- **RS256 + RSA-3072 = 128-bit strength** → viable, but signing gets heavier still
- **ES256 (P-256) = 128-bit strength** → 128 bits with small keys and small tokens

In my measurements, RSA-4096 signing was about 7.3× the cost of RSA-2048 (2,989.7 µs versus 410 µs). RSA-3072 sits between them, but **moving to ES256 beats lengthening the RSA key on performance, token size and lifespan simultaneously.**

There is no reason to panic about this deadline — NIST itself says there is no imminent danger. **What matters is whether your design can migrate**, and that comes from using `kid`, keeping `algorithms` as an application-side constant, and having the mutually exclusive verification paths shown in 7.2.

### 8.2 Comparing the options (with measurements)

| | RS256 (2048) | RS256 (3072) | **ES256** | EdDSA (Ed25519) |
| --- | --- | --- | --- | --- |
| NIST security strength | 112 (deprecated after end of 2030) | 128 | **128** | — |
| RFC 7518 requirement | Recommended | Recommended | **Recommended+** | defined in RFC 8037 |
| Signing cost (measured) | 410 µs | (between 2048 and 4096) | **27.6 µs** | 24.8 µs |
| Verification cost (measured) | **15.9 µs** | — | 52.8 µs | 83.6 µs |
| Token length (measured) | 631 B | — | **375 B** | 375 B |
| Interoperability | **highest** (RFC 9068 mandates support) | high | high | medium (not universally supported) |

**RFC 9068 requires that "authorization servers and resource servers conforming to this specification MUST include RS256 (as defined in [RFC7518]) among their supported signature algorithms."** If interoperability is a requirement, RS256 remains necessary. Even when centering on ES256, offering RS256 in parallel is the practical answer — a JWKS can hold both.

### 8.3 Do you need PS256?

RS256 is RSASSA-PKCS1-v1_5; PS256 is RSASSA-PSS. PSS is the modern padding with a security proof, and RFC 8017 (PKCS #1 v2.2) recommends PSS for new applications. But in RFC 7518 Section 3.1, **PS256 is Optional while RS256 is Recommended**. RS256 wins on interoperability, and there is no known practical attack on PKCS1-v1_5 signature verification.

**Verdict: there is no urgency to replace existing RS256 with PS256.** If you are moving for reasons of key size, lifespan or token length, the destination is ES256.

---

## 9. Testing: pin the attacks with regression tests

Everything above should be defended mechanically rather than by code review. **Only tests written from the attacker's point of view survive refactoring.**

```ts
// src/auth/verify.test.ts — vitest
import { describe, expect, it, beforeAll } from "vitest";
import { SignJWT, createLocalJWKSet, exportJWK, exportSPKI, generateKeyPair } from "jose";
import { createHmac } from "node:crypto";
import { verifyAccessToken } from "./verify-rs256";

let privateKey: CryptoKey;
let publicKeyPem: string;
let localJwks: ReturnType<typeof createLocalJWKSet>;

beforeAll(async () => {
  const pair = await generateKeyPair("RS256", { extractable: true });
  privateKey = pair.privateKey;
  publicKeyPem = await exportSPKI(pair.publicKey);
  // Swap out the production createRemoteJWKSet: no network, no JWKS_URI dependency.
  localJwks = createLocalJWKSet({
    keys: [{ ...(await exportJWK(pair.publicKey)), kid: "2026-08-a", alg: "RS256" }],
  });
});

const base = () =>
  new SignJWT({ tenant_id: "3f1b7c8e-0000-4000-8000-000000000000", scope: "orders:read" })
    .setIssuer("https://auth.example.com")
    .setAudience("https://api.example.com")
    .setSubject("u_01")
    .setJti("t1")
    .setIssuedAt()
    .setExpirationTime("15m");

describe("verifyAccessToken", () => {
  it("accepts a legitimate RS256 token", async () => {
    const token = await base()
      .setProtectedHeader({ alg: "RS256", kid: "2026-08-a", typ: "at+jwt" })
      .sign(privateKey);
    await expect(verifyAccessToken(token, localJwks)).resolves.toMatchObject({ ok: true });
  });

  it("[confusion] rejects an HS256 token signed with the public key as the HMAC secret", async () => {
    // Build the RFC 8725 §2.1 attack for real. The public key is assumed to be obtainable by anyone.
    const header = Buffer.from(
      JSON.stringify({ alg: "HS256", kid: "2026-08-a", typ: "at+jwt" }),
    ).toString("base64url");
    const payload = Buffer.from(
      JSON.stringify({
        iss: "https://auth.example.com", aud: "https://api.example.com",
        sub: "u_01", jti: "t1", tenant_id: "3f1b7c8e-0000-4000-8000-000000000000", scope: "admin:all",
        iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 900,
      }),
    ).toString("base64url");
    const forged = `${header}.${payload}.${createHmac("sha256", publicKeyPem)
      .update(`${header}.${payload}`)
      .digest("base64url")}`;

    await expect(verifyAccessToken(forged, localJwks)).resolves.toEqual({
      ok: false,
      reason: "alg_not_allowed",
    });
  });

  it("[alg:none] rejects an unsigned token", async () => {
    const header = Buffer.from(JSON.stringify({ alg: "none", typ: "at+jwt" })).toString("base64url");
    const payload = Buffer.from(JSON.stringify({ sub: "admin" })).toString("base64url");
    await expect(verifyAccessToken(`${header}.${payload}.`, localJwks)).resolves.toEqual({
      ok: false,
      reason: "alg_not_allowed",
    });
  });

  it("[type confusion] rejects a token whose typ is not at+jwt (RFC 9068 §4)", async () => {
    const idToken = await base()
      .setProtectedHeader({ alg: "RS256", kid: "2026-08-a", typ: "JWT" })
      .sign(privateKey);
    await expect(verifyAccessToken(idToken, localJwks)).resolves.toEqual({ ok: false, reason: "claim" });
  });

  it("[wrong audience] rejects a token with a different aud (RFC 8725 §3.9)", async () => {
    const token = await base()
      .setAudience("https://other-api.example.com")
      .setProtectedHeader({ alg: "RS256", kid: "2026-08-a", typ: "at+jwt" })
      .sign(privateKey);
    await expect(verifyAccessToken(token, localJwks)).resolves.toEqual({ ok: false, reason: "claim" });
  });

  it("[expired] rejects a token past exp", async () => {
    const token = await base()
      .setExpirationTime(Math.floor(Date.now() / 1000) - 60)
      .setProtectedHeader({ alg: "RS256", kid: "2026-08-a", typ: "at+jwt" })
      .sign(privateKey);
    await expect(verifyAccessToken(token, localJwks)).resolves.toEqual({ ok: false, reason: "expired" });
  });

  it("[contract violation] rejects a correctly signed token that lacks tenant_id", async () => {
    const token = await new SignJWT({ scope: "orders:read" })
      .setProtectedHeader({ alg: "RS256", kid: "2026-08-a", typ: "at+jwt" })
      .setIssuer("https://auth.example.com").setAudience("https://api.example.com")
      .setSubject("u_01").setJti("t1").setIssuedAt().setExpirationTime("15m")
      .sign(privateKey);
    await expect(verifyAccessToken(token, localJwks)).resolves.toEqual({ ok: false, reason: "claim" });
  });
});
```

The second test is the important one. **Actually construct a token signed with the public key as the HMAC secret, and assert that it is rejected.** With that single test in place, CI breaks the moment someone deletes `algorithms` or widens it to `["HS256", "RS256"]`.

---

## 10. Pre-production checklist

| # | Item | Basis |
| --- | --- | --- |
| 1 | `algorithms` is stated explicitly, as an application-side constant | RFC 8725 §3.1 |
| 2 | Each key is bound to exactly one algorithm | RFC 8725 §3.1 |
| 3 | `alg: "none"` is rejected on every path (the default branch rejects) | RFC 8725 §3.2 / RFC 9068 §4 |
| 4 | The HS256 key is ≥ 32 bytes from a cryptographic RNG (not password-derived) | RFC 7518 §3.2 / RFC 8725 §3.5 |
| 5 | RSA keys are 2048 bits or larger | RFC 7518 §3.3 |
| 6 | `iss` and `aud` are validated | RFC 8725 §3.8 / §3.9 |
| 7 | `typ` is validated to prevent token-type confusion | RFC 8725 §3.11 / RFC 9068 §2.1 |
| 8 | Issued tokens carry `kid` | RFC 7515 §4.1.4 |
| 9 | A `kid`-based rotation procedure is documented and rehearsed | NIST SP 800-57 Pt.1 Rev.5 §5.3.6 |
| 10 | JWKS fetch failure fails closed (does not pass the request) | — |
| 11 | JWKS cache, cooldown and timeout are configured explicitly | `jose` `RemoteJWKSetOptions` |
| 12 | `clockTolerance` is in seconds, not minutes | — |
| 13 | Failure reasons are metricized, with a dedicated alert on `alg_not_allowed` | — |
| 14 | Tokens, payloads and PII are never logged | — |
| 15 | Regression tests cover algorithm confusion and `alg:none` | — |
| 16 | The JWT library is tracked by Dependabot or equivalent | NDSS 2026, "Token Time Bomb" |

Item 16 gets underrated, but as the NDSS 2026 study shows, **vulnerabilities in JWT libraries themselves continue to be discovered through 2024 and beyond.** Correct code cannot protect you if the library underneath is stale.

---

## 11. Summary

- **HS256 and RS256 differ in trust boundaries, not strength.** With HS256, anyone who can verify can forge. Choose knowing that distributing the key means distributing issuance rights.
- **Honor the MUST requirements.** HS256 needs ≥ 256 bits **of entropy**; RS256 needs ≥ 2048 bits. Human-memorizable passwords are MUST NOT.
- **Performance is a weak deciding factor.** The measured verification gap is 0.014 milliseconds per request. What actually matters is signing cost (~222×) and token size (~2×).
- **Algorithm confusion is a current threat.** NDSS 2026 reports 31 new vulnerabilities and 20 CVEs across 43 libraries. The defense is pinning `algorithms` and one key per algorithm.
- **Operations is where the gap opens.** RS256 rotates with zero downtime by adding a `kid` to the JWKS, and deleting a `kid` is an emergency kill switch. HS256 has no such lever.
- **Migration is achievable.** Making the verifying side dual-capable first lets you move HS256 → RS256 with zero forced logouts — but measure the waiting period in refresh-token TTLs.
- **Looking toward 2031.** RSA-2048 is 112-bit strength, deprecated after the end of 2030. No need to rush, but keep `kid` and constant `algorithms` so the design *can* migrate.

When I applied Cognito RS256 + JWKS verification across all 221 endpoints of the [B2B SaaS for lumber-distribution DX that won the METI Minister's Award](/case-studies/lumber-industry-dx), what ultimately mattered was not cryptographic knowledge. It was the unglamorous design work: **collapse verification into a single path, make failure reasons observable, and pin the attacks with regression tests.** Zero missing-authentication findings from third-party penetration testing was the outcome of that accumulation.

Choosing an algorithm is only the **entrance** to that design. But get the entrance wrong, and everything after it becomes harder.
