mrkeyoor.com_
Thu 06 Aug 10:59 UTC
npmSecurityupdated 06 Aug 2026

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.

Verdict

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.

API stability4/5The client factory, getSigningKey, and the four framework adapters have not changed shape in years, and version 4 added options without removing any. The majors are still disruptive for reasons outside the API: v4 dropped Node 18 and the ES256K curve, and moved to jose v6 with a require(esm) dependency that breaks some test runners.
Docs3/5EXAMPLES.md documents every option with its default and covers caching, rate limiting, stale fallback, proxies, and the key interceptor, with runnable demos in the examples folder. The README itself is thin and leans on Auth0 marketing, and nothing states clearly at the top that this package does not verify tokens, which is the misunderstanding that sends people to the issue tracker.
Maintenance4/5Auth0 owns it, the repository was pushed on the day of this review, and only 2 issues are open against 8 items when pull requests are counted. Feature work is slow, and the CHANGELOG shows long stretches of dependency bumps between releases, but security-relevant upgrades such as the jose v6 move do land.
Ecosystem5/5About 14.5M downloads a week and adapters for the four Node auth middlewares people actually run. Nearly every provider tutorial that shows JWT validation in Express uses this package, so error messages are searchable and integration examples exist for most identity providers.

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
Skip it if

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 JwksRateLimitError

Caching 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: 9

This 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

PackageRegistryPick it when
josenpmYou want one dependency that fetches the key set and verifies the token, with createRemoteJWKSet handling cache and cooldown for you
express-oauth2-jwt-bearernpmYou are on Express and want Auth0's supported middleware that does discovery, key lookup, and issuer and audience checks in one place
get-jwksnpmYou are on Fastify and want a smaller JWKS fetcher designed to pair with fastify-jwt
openid-clientnpmYou are implementing the full OpenID Connect flow, not just validating bearer tokens at an API boundary