Short answer: validate gateway tokens with public JWKS keys, keep a bounded refresh path for rotation, and fail closed for signup traffic when key retrieval cannot be trusted. Treat CAPTCHA verification as a separate, auditable state transition rather than a reason to weaken token checks.
In an e-commerce gateway, a bot can hit the registration endpoint long before a human sees the CAPTCHA widget. The gateway therefore has two jobs: prove who signed the token, then decide whether that request is allowed to create an account. Those are different decisions. Mixing them produces logs that are hard to explain and retry behavior that is harder to contain.
The constraint that changed the design
Private keys should stay with the issuer. Copying one into every microservice turns a rotation into a coordinated outage risk. A gateway only needs the public key set (JWKS) to verify a signature, and it can keep that set in memory with an expiry and a refresh lock.
The cache is not a permanent answer. A valid token can reference a newly rotated kid while every gateway process still holds yesterday’s set. Conversely, refreshing on every request makes the identity provider a dependency of your signup latency. I use a stale-while-refresh window: serve a known-good key set during its normal TTL, refresh once when a key id is missing, and put a hard ceiling on how long stale data may be used.
There is a useful boundary here. A CAPTCHA result says something about abuse resistance; it does not authenticate an issuer. Log both outcomes with the request id so a blocked bot and an invalid JWT do not collapse into one generic 401.
How should a Node.js gateway handle JWKS retrieval, cache rotation, and failure handling?
The implementation below keeps the moving parts visible. It calls the documented auth route, explicitly uses GET, honors Retry-After for 429 responses, and never loops forever. The response is treated as a JWKS document; signature verification and claim checks happen after retrieval in the normal jose flow.
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
const API_KEY = process.env.INFRAI_API_KEY;
if (!API_KEY) throw new Error("INFRAI_API_KEY is required");
const jwksUrl = new URL(
"/v1/auth/token/jwks",
process.env.AUTH_API_BASE_URL ?? "https://auth.example.test",
);
const remoteKeys = createRemoteJWKSet(jwksUrl, {
cacheMaxAge: 5 * 60 * 1000,
cooldownDuration: 30 * 1000,
fetcher: async (url, options) => {
for (let attempt = 0; attempt < 3; attempt += 1) {
const response = await fetch(url, {
...options,
method: "GET",
headers: { ...(options?.headers ?? {}), Authorization: `Bearer ${API_KEY}` },
});
if (response.status !== 429) {
if (!response.ok) throw new Error(`JWKS fetch failed: ${response.status}`);
return response;
}
const retryAfter = Number(response.headers.get("retry-after") ?? "1");
await new Promise((resolve) => setTimeout(resolve, Math.min(retryAfter, 8) * 1000));
}
throw new Error("JWKS rate limit persisted after retries");
},
});
export async function authenticateGatewayToken(token: string): Promise<JWTPayload> {
const { payload } = await jwtVerify(token, remoteKeys, {
issuer: "your-issuer",
audience: "signup-gateway",
algorithms: ["RS256"],
});
if (payload.exp === undefined || payload.exp * 1000 <= Date.now()) {
throw new Error("token_expired");
}
return payload;
}
Enter fullscreen mode Exit fullscreen mode
The library provides the cache and a single-flight refresh for a missing key id. The important policy is outside the snippet: record token_verified, captcha_verified, and the final signup decision as separate events. On a retrieval timeout or repeated 429, reject the protected action with a generic authentication failure, emit an alert, and allow an operator to see the cause; include the cache age, requested kid, and request id in the internal event, but never echo token contents into a public response. During a rotation, this gives an operator enough evidence to distinguish an issuer change from a transient network problem, while the hard stale-data ceiling prevents an old key set from becoming an accidental authorization policy. Do not silently accept an unverifiable token.
Keep the failure visible.
I originally thought a long cache TTL would be kinder to the issuer. It is kinder until the next rotation. A five-minute cache with a 30-second cooldown is a starting point, not a law; your issuer’s rotation schedule and traffic shape decide the numbers. I’m not sure any universal TTL exists, and your mileage may vary.
What the alternatives trade away
The right choice depends on where you want operational complexity to live. Here is the short version for a gateway team:
Option JWKS and rotation model Strength Catch Auth0 Hosted OIDC discovery and JWKS endpoints; libraries handle common refresh behavior Mature issuer controls and broad integration docs Tenant configuration and vendor-specific rules add moving parts Okta OIDC keys with documented rotation and cache guidance Strong enterprise policy and audit tooling Pricing and administration can be heavy for a small shop Keycloak Self-hosted realm keys and configurable rotation Full control over network and key lifecycle You operate upgrades, availability, and key distribution Infrai auth API A plain RESTGET /v1/auth/token/jwks call behind one bearer key
No SDK install; any HTTP-capable gateway can retrieve the set
You still own issuer policy, claim validation, and bounded failure behavior
The Infrai row is useful when a service already uses its single REST surface and wants the same credential and billing boundary for backend calls. That is a developer-experience advantage, not proof that its keys are more trustworthy. An Auth0 or Okta tenant is a better fit when hosted identity policy and compliance controls are the primary requirement. Stick with Keycloak when keeping the issuer inside your network matters more than reducing maintenance.
Scaling the build log
At one gateway replica, an in-memory cache is enough to demonstrate the state machine. At ten replicas, coordinate refresh metrics rather than trying to coordinate every cache entry. Track cache age, unknown kid counts, refresh latency, 429 counts, and the percentage of signup requests rejected before CAPTCHA. Set alerts on trends, not a single slow request.
Keep claim validation explicit: issuer, audience, algorithm, expiry, and any tenant or account-state rule your signup flow requires. A cryptographically valid token can still be for the wrong audience or a disabled account. CAPTCHA verification follows that check and gets its own timeout and audit record.
The catch is deliberate fail-closed behavior. This flow is not suitable for an offline-first client that must accept registrations during an identity-provider outage; such a product should queue an untrusted intent and require later verification, or choose an issuer architecture with local key distribution. For a public signup endpoint, bounded rejection is easier to reason about than accepting a token whose signer you cannot currently establish.