jwks-rsa
When an identity provider signs a JWT it puts a key id in the token header and publishes the matching public keys at a JWKS endpoint, usually /.well-known/jwks.json. jwks-rsa is the client that turns that kid into a public key you can hand to a verifier. It fetches the key set over HTTPS, converts each JWK into a PEM public key, caches the result in an LRU so you are not making an HTTP call per request, and can rate limit lookups so an attacker sending tokens with random kid values cannot use your API to hammer your provider. It also ships small adapters for express-jwt, passport-jwt, hapi-auth-jwt2, and koa-jwt so the key lookup slots straight into whichever middleware you already use. It does not verify tokens; it only supplies the key.
A small, careful, well-scoped client that has been the standard way to resolve JWKS keys in Node for years, and the caching and rate limiting defaults are the only real thinking it asks of you. If you are starting fresh today, check whether jose alone or your provider's own middleware already covers this before adding it.
Use it if
- Your API validates JWTs from an external issuer (Auth0, Okta, Entra ID, Cognito, Keycloak, Firebase) and you need to resolve the signing key by kid rather than pinning a static public key
- You are already using express-jwt, passport-jwt, koa-jwt, or hapi-auth-jwt2 and want a drop-in secret provider instead of writing the fetch, parse, and cache yourself
- Your issuer rotates keys and you want new kids picked up automatically, with cached keys expiring on a timer instead of on a redeploy
- You need the availability knobs: cacheMaxAgeFallback keeps serving the last known good key while the JWKS endpoint is down, and onStaleCacheFallback tells your metrics that it happened
- You have to reach the JWKS endpoint through a corporate proxy or with a private CA, which requestAgent covers by taking a plain Node http.Agent
- You think this validates tokens. It does not. It hands you a public key and nothing else, so the parts where authentication bugs actually live, checking algorithms, issuer, audience, and expiry, are still yours to get right in jsonwebtoken or jose
- You already use jose. jwks-rsa depends on jose internally, and jose's own createRemoteJWKSet does the fetching, caching, and cooldown in two lines and verifies the token in the same call. Running jwks-rsa on top of it is a wrapper around a dependency you already have
- You are an Auth0 customer on Express. Auth0 maintains express-oauth2-jwt-bearer, which wires the JWKS lookup, signature check, issuer, and audience validation into one middleware, and it is the path their own docs point at now
- You run Jest without ESM support. Version 4 relies on Node's require(esm), and the changelog says outright that non-standard module runtimes such as Jest, which uses vm.Script, may fail to load it. That is tracked as issue 493 and it turns a dependency bump into a test infrastructure project
- You are on Node 18 or older, or you have keys on the secp256k1 curve. Version 4 dropped both: the engines field requires ^20.19.0 || ^22.12.0 || >=23.0.0, and ES256K keys are now ignored rather than rejected loudly
- You expect safe defaults. Caching is on but cacheMaxEntries defaults to 5, so a provider with more active kids than that evicts constantly and refetches, and rate limiting defaults to off, which is the setting that stops a random-kid flood from turning your API into a load generator against your identity provider
Setup reality
npm install jwks-rsa pulls in jose, lru-cache, lru-memoizer, limiter, debug, and, oddly, @types/jsonwebtoken as a runtime dependency rather than a dev one, so a JavaScript-only project still downloads it. Node 20.19 or newer is required at version 4 and there is no native build step. Configuration is where the time goes. Create exactly one client at module scope: a client built per request throws away the cache and turns every API call into a round trip to your identity provider. The defaults deserve a read rather than a copy-paste, because cacheMaxEntries is 5, cacheMaxAge is ten minutes, the request timeout is thirty seconds, and rateLimit is false. The Express adapter is quieter than you want when something goes wrong: its default error handler converts SigningKeyNotFoundError into a callback with no key, so express-jwt then reports that a secret was not provided, which reads like a configuration mistake rather than an unknown kid. Set DEBUG=jwks to see the fetches and cache decisions. Only RS, PS, ES, and EdDSA algorithms are accepted by the framework adapters, and a token signed with anything else is rejected before a key is ever looked up.
Patterns
Create one client and fetch a key by kidcreate-client
const jwksClient = require('jwks-rsa');
// module scope, created once for the life of the process
const client = jwksClient({
jwksUri: 'https://your-tenant.example.com/.well-known/jwks.json',
cache: true,
cacheMaxEntries: 10,
cacheMaxAge: 600000,
rateLimit: true,
jwksRequestsPerMinute: 10,
timeout: 30000
});
const key = await client.getSigningKey(kid);
const publicKey = key.getPublicKey();Building the client inside a request handler is the single most common mistake here: each instance carries its own cache, so a per-request client fetches the JWKS on every call. cacheMaxEntries defaults to 5, which is low for providers that keep several keys live during rotation.
Verify a token with jsonwebtokenverify-with-jsonwebtoken
const jwt = require('jsonwebtoken');
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
if (err) return callback(err);
callback(null, key.getPublicKey());
});
}
jwt.verify(token, getKey, {
algorithms: ['RS256'],
issuer: 'https://your-tenant.example.com/',
audience: 'https://api.example.com'
}, (err, decoded) => { /* ... */ });The algorithms, issuer, and audience options are not optional in practice. Omitting algorithms is how algorithm confusion attacks get in, and omitting issuer or audience means a valid token minted for a different application of the same tenant passes your check.
Wire it into express-jwtexpress-jwt
const { expressjwt } = require('express-jwt');
const { expressJwtSecret } = require('jwks-rsa');
app.use(
expressjwt({
secret: expressJwtSecret({
jwksUri: 'https://your-tenant.example.com/.well-known/jwks.json',
cache: true,
rateLimit: true
}),
algorithms: ['RS256'],
issuer: 'https://your-tenant.example.com/',
audience: 'https://api.example.com'
})
);expressJwtSecret works with both express-jwt 6 and 7 by inspecting its own argument count. Its default error handler swallows SigningKeyNotFoundError and returns no key, so an unknown kid surfaces from express-jwt as a missing secret rather than an unknown key; pass handleSigningKeyError if you want the real cause in your logs.
Wire it into koa-jwtkoa-jwt
const koaJwt = require('koa-jwt');
const { koaJwtSecret } = require('jwks-rsa');
app.use(
koaJwt({
secret: koaJwtSecret({
jwksUri: 'https://your-tenant.example.com/.well-known/jwks.json',
cache: true
}),
algorithms: ['RS256'],
issuer: 'https://your-tenant.example.com/',
audience: 'https://api.example.com'
})
);koaJwtSecret throws ArgumentError at construction time if jwksUri is missing, which is better than failing on the first request. It also rejects any token whose alg is outside RS, PS, ES, and EdDSA before touching the network.
Wire it into passport-jwt or hapipassport-and-hapi
const { passportJwtSecret, hapiJwt2KeyAsync } = require('jwks-rsa');
const { Strategy, ExtractJwt } = require('passport-jwt');
passport.use(new Strategy({
secretOrKeyProvider: passportJwtSecret({ jwksUri, cache: true }),
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
algorithms: ['RS256'],
issuer,
audience
}, verifyCallback));
// hapi-auth-jwt2
server.auth.strategy('jwt', 'jwt', {
key: hapiJwt2KeyAsync({ jwksUri, cache: true }),
verifyOptions: { algorithms: ['RS256'], issuer, audience }
});hapiJwt2KeyAsync resolves to an object shaped { key }, while the older callback-style hapiJwt2Key hands back (err, publicKey, signingKey). Use the async form on current hapi; the callback form exists for older strategies.
Keep serving keys while the JWKS endpoint is downsurvive-jwks-outage
const client = jwksClient({
jwksUri,
cache: true,
cacheMaxAge: 600000, // normal freshness window
cacheMaxAgeFallback: 3600000, // extra hour of stale keys if the endpoint fails
onStaleCacheFallback: (err, kid, staleKey) => {
metrics.increment('jwks.stale_fallback');
log.warn({ kid, err: err.message }, 'serving stale JWKS key');
}
});Without this option, a provider outage lasting longer than cacheMaxAge fails every authenticated request. With it you are choosing availability over freshness: if the endpoint goes down at the same moment a compromised key is revoked, you keep trusting that key for the whole fallback window.
Stop a random-kid flood reaching your providerrate-limit
const client = jwksClient({
jwksUri,
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 10 // default once rateLimit is on
});
// exceeding the budget throws JwksRateLimitErrorCaching does not cover this on its own, because an unrecognised kid is a cache miss and triggers a fetch. rateLimit is false by default, so this is opt-in, and turning it on means legitimate traffic during a key rotation can also be rejected briefly if your budget is too tight.
Tell the failure modes aparterror-handling
const {
ArgumentError,
JwksError,
JwksRateLimitError,
SigningKeyNotFoundError
} = require('jwks-rsa');
try {
const key = await client.getSigningKey(kid);
} catch (err) {
if (err instanceof SigningKeyNotFoundError) return res.status(401).end();
if (err instanceof JwksRateLimitError) return res.status(503).end();
if (err instanceof JwksError) return res.status(503).end(); // endpoint problem
throw err;
}SigningKeyNotFoundError means the token is bad and belongs in a 401. JwksError and JwksRateLimitError mean your side is unhealthy and should be a 5xx, because returning 401 for a JWKS outage looks to clients like every credential in your system was revoked at once.
Serve keys from a file or cache before hitting the networkkeys-from-local-source
const client = jwksClient({
jwksUri,
getKeysInterceptor: async () => {
const file = JSON.parse(fs.readFileSync(jwksFile, 'utf8'));
return file.keys;
}
});The interceptor runs first and only falls through to jwksUri when the kid is not in what it returned, which makes it a clean way to pin known keys in an air-gapped environment or to share a warm key set across processes through Redis.
Reach an internal issuer through a proxy or private CAprivate-ca-and-proxy
const https = require('https');
const { HttpsProxyAgent } = require('https-proxy-agent');
const client = jwksClient({
jwksUri: 'https://idp.internal/.well-known/jwks.json',
requestAgent: new https.Agent({ ca: fs.readFileSync(caFile) })
// or: requestAgent: new HttpsProxyAgent(process.env.HTTPS_PROXY)
});requestAgent takes any Node http or https Agent, which is how both private certificate authorities and proxies are handled; there is no separate TLS option bag. Do not reach for rejectUnauthorized: false here, since that removes the check that makes the fetched key trustworthy at all.
Replace the HTTP call entirelycustom-fetcher
const client = jwksClient({
jwksUri,
fetcher: async (uri) => {
const res = await instrumentedFetch(uri, { signal: AbortSignal.timeout(5000) });
if (!res.ok) throw new Error(`JWKS ${res.status}`);
return res.json(); // must resolve to { keys: [...] }
}
});A custom fetcher lets you add tracing, your own retry policy, or a shorter timeout than the thirty second default. It must resolve to an object with a keys array, and supplying one makes jwksUri optional, which is how you serve a key set that never lived at a URL.
See what the client is actually doingdebug-logging
DEBUG=jwks node server.js
# jwks Configured caching of signing keys. Max: 5 / Age: 600000
# jwks Retrieving keys from https://.../.well-known/jwks.json
# jwks Requests to the JWKS endpoint available for the next minute: 9This is the fastest way to answer the two questions that matter during an incident: is it fetching on every request (cache misconfigured or client rebuilt per request), and is it being rate limited. The output includes the key material summary, so keep it out of production logs.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| jose | npm | You want one dependency that fetches the key set and verifies the token, with createRemoteJWKSet handling cache and cooldown for you |
| express-oauth2-jwt-bearer | npm | You are on Express and want Auth0's supported middleware that does discovery, key lookup, and issuer and audience checks in one place |
| get-jwks | npm | You are on Fastify and want a smaller JWKS fetcher designed to pair with fastify-jwt |
| openid-client | npm | You are implementing the full OpenID Connect flow, not just validating bearer tokens at an API boundary |