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:
null, strings and junk objects in keys are skipped.oct key, unless the call passes allowSymmetricJWKSet: true.kid equals the header's kid, or the header has no kid.alg equals the header's alg, or the entry declares no alg.use is absent or matches the operation: "sig" for verify, "enc" for decrypt.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
| 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 |
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 providedUse 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
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
| 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() and decrypt() for the other options.
- Multi-signature JWS, where each signature is matched to a key from the set.