
# JWK Sets

> A JWK Set is a JSON object with a `keys` array. Pass one to `verify()` or `decrypt()` and the token's header picks the key.

```ts
import { generateJWK } from "unjwt/jwk";
import { sign, verify } from "unjwt/jws";

const q3 = await generateJWK("ES256", { kid: "2026-q3" });
const q4 = await generateJWK("ES256", { kid: "2026-q4" });
const jwks = { keys: [q3.publicKey, q4.publicKey] };

const token = await sign({ sub: "u2" }, q4.privateKey);
const { protectedHeader } = await verify(token, jwks);
// { kid: "2026-q4", alg: "ES256", typ: "JWT" }
```

A set ([RFC 7517 §5](https://www.rfc-editor.org/rfc/rfc7517#section-5)) is how an identity provider publishes its keys at `/.well-known/jwks.json`, and how you keep an old key and a new key valid side by side during a rotation. Sets are for the consuming side: [`verify()`](/jwt/jws/verify), [`decrypt()`](/jwt/jwe/decrypt) and their multi variants accept one. [`sign()`](/jwt/jws/sign) and [`encrypt()`](/jwt/jwe/encrypt) take a single key, and write that key's `kid` into the header.

## The type

```ts
interface JWKSet<T extends readonly JWK[] = JWK[]> {
  keys: T;
  [parameter: string]: unknown;
}
```

The default `JWK[]` fits a set fetched over the network. Pass a tuple, such as `JWKSet<[JWK_EC_Public<"ES256">, JWK_RSA_Public<"RS256">]>`, to keep the exact entries when you build one in code.

## How a key is picked

Given a set, `verify()` and `decrypt()` keep an entry as a candidate only when all of the following hold:

1. It is a JWK. `null`, strings and junk objects in `keys` are skipped.
2. It is not an `oct` key, unless the call passes `allowSymmetricJWKSet: true`.
3. Its `kid` equals the header's `kid`, or the header has no `kid`.
4. Its `alg` equals the header's `alg`, or the entry declares no `alg`.
5. Its `use` is absent or matches the operation: `"sig"` for verify, `"enc"` for decrypt.
6. Its `key_ops` is absent or permits the operation: `verify` for verify; `unwrapKey`, `decrypt`, `deriveBits` or `deriveKey` for decrypt.

The `kid` is a filter ([RFC 7515 §4.1.4](https://www.rfc-editor.org/rfc/rfc7515#section-4.1.4)). It narrows the set; it never vouches for a key on its own, and the chosen key still has to verify the signature or unwrap the key. The candidates are then tried in order, and the first one that works wins.

Before any of that, the header's `alg` is checked against the algorithms the set's keys can serve (or against `options.algorithms` when you pass it). A token signed with an algorithm none of the keys support fails there.

## What happens when nothing matches

| Situation                                              | Result                                                    |
| ------------------------------------------------------ | --------------------------------------------------------- |
| No entry survives the filter                           | `ERR_JWK_KEY_NOT_FOUND`, before any crypto runs           |
| More than 8 candidates survive                         | `ERR_JWK_KEY_NOT_FOUND`, before any crypto runs           |
| Candidates exist but none verifies                     | `ERR_JWS_SIGNATURE_INVALID`, with each attempt in `cause` |
| Candidates exist but none decrypts                     | `ERR_JWE_DECRYPTION_FAILED`, with each attempt in `cause` |
| The header's `alg` is not served by any key in the set | `ERR_JWS_ALG_NOT_ALLOWED` / `ERR_JWE_ALG_NOT_ALLOWED`     |

```ts
const stranger = await generateJWK("ES256", { kid: "unknown" });
await verify(await sign({}, stranger.privateKey), jwks);
// JWTError ERR_JWK_KEY_NOT_FOUND: No key found in JWK Set with kid "unknown".

const nameless = await generateJWK("ES256", { kid: false });
await verify(await sign({}, nameless.privateKey), jwks);
// JWTError ERR_JWS_SIGNATURE_INVALID: JWS signature verification failed.
// cause: [ERR_JWS_SIGNATURE_INVALID, ERR_JWS_SIGNATURE_INVALID]
```

The second token has no `kid`, so both keys were tried. Each candidate costs a signature check or an unwrap, which is why the count is capped. Give keys and tokens a `kid` and the count is one whatever the set's size. To try more, raise the cap per call:

```ts
await verify(token, bigSet, { limits: { keyCandidates: 16 } });
// with 9 nameless keys and the default: ERR_JWK_KEY_NOT_FOUND: 9 candidate keys exceed the limit of 8; narrow the JWK Set with a "kid".
```

## Symmetric entries

A published set is public. If its `oct` entries counted, anyone able to add an entry to it could add a shared secret and mint tokens you accept. So `oct` entries are skipped by default, and a set of only `oct` keys leaves nothing to infer an algorithm from:

```ts
const hs = await generateJWK("HS256", { kid: "hs-1" });
await verify(await sign({}, hs), { keys: [hs] });
// JWTError ERR_JWS_ALG_NOT_ALLOWED: Cannot infer allowed algorithms from this key; pass "options.algorithms" explicitly.

await verify(await sign({}, hs), { keys: [hs] }, { allowSymmetricJWKSet: true });
// { protectedHeader: { kid: "hs-1", alg: "HS256", typ: "JWT" }, … }
```

::warning
Pass `allowSymmetricJWKSet: true` only for a set your own application controls. Never for one fetched from a `jwks_uri` or any other party.
::

An `oct` key whose `alg` is a content encryption algorithm (a `dir` key such as `A256GCM`) never matches in a set, because the header's `alg` is `"dir"` and rule 4 compares the two literally. Pass such a key directly, or return it from a lookup function.

## getJWKsFromSet()

```ts
function getJWKsFromSet(jwkSet: JWKSet, filter?: (jwk: JWK) => boolean): JWK[];
```

Returns the set's JWKs, dropping entries that are not JWKs, then narrowed by `filter` when given. A value that is not a set throws `ERR_JWK_INVALID`.

```ts
import { getJWKsFromSet } from "unjwt/jwk";

getJWKsFromSet(jwks).map((k) => k.kid); // ["2026-q3", "2026-q4"]
getJWKsFromSet({ keys: [null, "x", { foo: 1 }, q3.publicKey] }).map((k) => k.kid); // ["2026-q3"]
getJWKsFromSet({ nope: [] });
// JWTError ERR_JWK_INVALID: Invalid JWK Set provided
```

Use it to pick the current signing key from a stored set, or to choose recipients for a [multi-recipient JWE](/jwt/jwe/multi).

## Sets from a lookup function

A `JWKLookupFunction` passed to `verify()` or `decrypt()` receives the decoded header and may return a whole set; the same selection rules apply. Returning `undefined` or `null`, or throwing, is `ERR_JWK_KEY_NOT_FOUND`.

```ts
const { payload } = await verify(token, async (header) => {
  // header: { kid: "2026-q4", alg: "ES256", typ: "JWT" }
  return jwks; // in practice, a cached fetch of the issuer's jwks_uri
});
// payload: { sub: "u2" }

await verify(token, () => undefined);
// JWTError ERR_JWK_KEY_NOT_FOUND: Key lookup returned no key for the JWS.
```

## Rotating keys

1. Sign with key A. Publish a set holding A.
2. Generate key B. Publish a set holding A and B, and keep signing with A while verifiers refresh their copy.
3. Switch signing to B. Keep A in the set so tokens already issued still verify.
4. Once A's longest-lived token has expired, remove A from the set.

The verifier runs the same `verify(token, jwks)` through every step. With [thumbprint `kid`s](/jwk/generate#the-default-kid-is-a-thumbprint) from `generateJWK`, no naming scheme is needed. The [JWKS endpoint example](/examples/jwks-endpoint) puts this together.

## Errors

| Cause                                             | `code`                                                    |
| ------------------------------------------------- | --------------------------------------------------------- |
| No candidate, or more than `limits.keyCandidates` | `ERR_JWK_KEY_NOT_FOUND`                                   |
| A lookup function that returns nothing or throws  | `ERR_JWK_KEY_NOT_FOUND`                                   |
| The header's `alg` is not served by the set       | `ERR_JWS_ALG_NOT_ALLOWED` / `ERR_JWE_ALG_NOT_ALLOWED`     |
| Every candidate failed                            | `ERR_JWS_SIGNATURE_INVALID` / `ERR_JWE_DECRYPTION_FAILED` |
| `getJWKsFromSet` on something that is not a set   | `ERR_JWK_INVALID`                                         |

## See also

- [verify()](/jwt/jws/verify) and [decrypt()](/jwt/jwe/decrypt) for the other options.
- [Multi-signature JWS](/jwt/jws/multi), where each signature is matched to a key from the set.
