JWK cache

Every time sign(), verify(), encrypt(), or decrypt() touches a JWK, unjwt must call crypto.subtle.importKey to produce a CryptoKey. That import is not free — especially for RSA keys, where parsing modulus and exponent takes a few milliseconds.

To avoid paying that cost on every call, unjwt keeps a small per-JWK cache. By default, it's a WeakMap keyed by the JWK object reference; no tuning required.

import { configureJWKCache, clearJWKCache, WeakMapJWKCache } from "unjwt/jwk";

How it works — the default

WeakMapJWKCache stores WeakMap<JWK, Record<string, CryptoKey>>:

  • Outer key — the JWK object reference itself.
  • Inner key — the algorithm string passed to importKey (e.g. "HS256", "RS256").
  • Value — the resulting CryptoKey.

Because the outer map is a WeakMap, the cache entry is garbage-collected automatically when the JWK object becomes unreachable. No manual invalidation needed.

// First call: imports, caches
await verify(token, myJwk);

// Second call with SAME object: cache hit, skip import
await verify(token2, myJwk);

// With a copy: CACHE MISS — different object reference
await verify(token3, { ...myJwk }); // re-imports
Cache hits require the same object variable to be passed. A spread copy ({ ...jwk }) or a JSON.parse of a stringified JWK produces a new object and misses the cache. For predictable hits, keep one JWK object per key and reuse the reference.

configureJWKCache

Replace or disable the active cache:

custom.ts
import { configureJWKCache } from "unjwt/jwk";

// Custom kid-keyed cache with LRU semantics
const map = new Map<string, CryptoKey>();
configureJWKCache({
  get: (jwk, alg) => map.get(`${jwk.kid}:${alg}`),
  set: (jwk, alg, key) => map.set(`${jwk.kid}:${alg}`, key),
});

// Disable caching entirely
configureJWKCache(false);

The adapter interface

Any object matching this shape is accepted:

interface JWKCacheAdapter {
  get(jwk: JWK, alg: string): CryptoKey | undefined;
  set(jwk: JWK, alg: string, key: CryptoKey): void;
}

No TTL, no delete — cache invalidation is up to the adapter. The default implementation leans on JavaScript's garbage collector via WeakMap; an LRU or Redis-backed version would need its own eviction strategy.

When to replace

  • You parse JWKs from strings on every request — the default WeakMap cache misses, so a kid-keyed map gives you real hits again.
  • Multi-tenant environments — an LRU bound prevents unbounded memory growth in long-lived processes.
  • Distributed workers — a shared (Redis, etc.) cache lets you amortize imports across a pool.

When to disable

  • Memory-constrained environments where the cache outweighs the wins.
  • Tests, especially when you want each test's imports to be observable (but see clearJWKCache below — usually sufficient).

clearJWKCache

import { clearJWKCache } from "unjwt/jwk";

clearJWKCache();

Resets the cache to a fresh WeakMapJWKCache. Mostly used in tests to ensure no cross-test key memoization. Call it in beforeEach if your tests re-use JWK objects across runs.

WeakMapJWKCache

The default implementation, exported for reference:

export class WeakMapJWKCache implements JWKCacheAdapter {
  private cache = new WeakMap<JWK, Record<string, CryptoKey>>();

  get(jwk: JWK, alg: string): CryptoKey | undefined {
    /* ... */
  }
  set(jwk: JWK, alg: string, key: CryptoKey): void {
    /* ... */
  }
}

Internally it uses Record<string, CryptoKey> instead of Map<string, CryptoKey> for the per-JWK bucket — plain objects are slightly faster than Map for the typical 1–2 algorithm entries per key (V8 hidden-class optimization).

See also