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.

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) 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(), decrypt() and their multi variants accept one. sign() and encrypt() take a single key, and write that key's kid into the header.

#The type

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:

It is a JWK. null, strings and junk objects in keys are skipped.
It is not an oct key, unless the call passes allowSymmetricJWKSet: true.
Its kid equals the header's kid, or the header has no kid.
Its alg equals the header's alg, or the entry declares no alg.
Its use is absent or matches the operation: "sig" for verify, "enc" for decrypt.
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). 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

SituationResult
No entry survives the filterERR_JWK_KEY_NOT_FOUND, before any crypto runs
More than 8 candidates surviveERR_JWK_KEY_NOT_FOUND, before any crypto runs
Candidates exist but none verifiesERR_JWS_SIGNATURE_INVALID, with each attempt in cause
Candidates exist but none decryptsERR_JWE_DECRYPTION_FAILED, with each attempt in cause
The header's alg is not served by any key in the setERR_JWS_ALG_NOT_ALLOWED / ERR_JWE_ALG_NOT_ALLOWED
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:

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:

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()

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.

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.

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

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

Sign with key A. Publish a set holding A.
Generate key B. Publish a set holding A and B, and keep signing with A while verifiers refresh their copy.
Switch signing to B. Keep A in the set so tokens already issued still verify.
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 kids from generateJWK, no naming scheme is needed. The JWKS endpoint example puts this together.

#Errors

Causecode
No candidate, or more than limits.keyCandidatesERR_JWK_KEY_NOT_FOUND
A lookup function that returns nothing or throwsERR_JWK_KEY_NOT_FOUND
The header's alg is not served by the setERR_JWS_ALG_NOT_ALLOWED / ERR_JWE_ALG_NOT_ALLOWED
Every candidate failedERR_JWS_SIGNATURE_INVALID / ERR_JWE_DECRYPTION_FAILED
getJWKsFromSet on something that is not a setERR_JWK_INVALID

#See also