Skip to content

fix(auth): reuse the remote JWKS key set and bound every auth fetch - #41

Merged
jbiskur merged 1 commit into
mainfrom
fix/jwks-remote-set-reuse-and-timeouts
Sep 1, 2026
Merged

fix(auth): reuse the remote JWKS key set and bound every auth fetch#41
jbiskur merged 1 commit into
mainfrom
fix/jwks-remote-set-reuse-and-timeouts

Conversation

@jbiskur

@jbiskur jbiskur commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Problem

authenticate() built a new JWKS resolver on every single request:

const jwks = createRemoteJWKSet(new URL(jwksUrl))

createRemoteJWKSet is stateful. The returned resolver owns the cached keys, the cooldown window, and the coalescing of concurrent refreshes. Constructing one per request discards all three, so every authenticated request made its own round trip to the identity provider.

Separately, both api-key validation calls used a bare fetch with no timeout.

Production impact

Found while investigating the Flowcore UI failing to list data pathways on 2026-09-01.

GET /api/v1/pathways on data-pathways-control-plane: 6 requests, 6 failures, each returning 401 at exactly 5.00 s.

Time (UTC) Status Duration
12:47:31.220 401 5.002826752 s
12:47:30.005 401 5.003562496 s
12:45:31.028 401 5.005999616 s
12:45:21.401 401 5.001255424 s

Every one is a root span with no children — jose's internal fetch is not instrumented. The pod log names it:

{"code":"ERR_JWKS_TIMEOUT","message":"request timed out","name":"JWKSTimeout",
 "stack":"JWKSTimeout: … at jose/dist/webapi/jwks/remote.js:24:19"}

5000 ms is jose's default timeoutDuration, which matches the measured latency to three decimals.

The endpoint itself was never slow. Probing the same URL from a fresh process inside the same failing pod:

attempt 1: HTTP 200 in 23 ms      attempt 5: HTTP 200 in 2 ms
attempt 2: HTTP 200 in  3 ms      attempt 6: HTTP 200 in 3 ms
attempt 3: HTTP 200 in  3 ms      attempt 7: HTTP 200 in 3 ms
attempt 4: HTTP 200 in  3 ms      attempt 8: HTTP 200 in 2 ms

Ruled out by direct measurement:

Candidate Measurement Verdict
Keycloak overloaded 7m CPU, 0 restarts
Network / egress broken 8/8 probes returned 200 in 2–23 ms
Service CPU-bound 23m against a 1-core limit
Event loop globally wedged unauthenticated route answered in 0–2 ms

The process was fast on every path that skipped JWT verification and stalled only on the path that entered jose. That is a saturated outbound connection pool in the long-running process — the direct consequence of one uncached HTTPS round trip per request.

Changes

  • Cache remote key sets in a module-level Map, keyed by URL and timeout. Rotation is still handled by jose through cooldownDuration (30 s) and cacheMaxAge (10 min), so an unknown kid still triggers a refetch.
  • Pass timeoutDuration explicitly rather than relying on the implicit default.
  • Bound both api-key validation fetches with AbortSignal.timeout. An unbounded fetch on the auth path is not a slow request, it is a stuck one — the caller holds its slot until its own deadline and every later request queues behind it.
  • Report the upstream status or the timeout in the thrown error. AppExceptionUnauthorized() with no message gave operators nothing to work with.
  • Expose auth.jwks_timeout_ms and auth.api_key_timeout_ms on HonoApi, both defaulting to 5000 ms.

Tests

test/authenticate-jwks.test.ts generates a real RS256 key pair, serves a real JWKS document, signs a real token, and counts fetches:

  • fetches the JWKS document once across 5 sequential verifications
  • reuses the key set across 10 concurrent verifications
  • keeps separate key sets per JWKS URL
  • gives up on a hanging JWKS endpoint instead of waiting forever

The tests have teeth. Reverting only the reuse line and re-running:

fetches the JWKS document once across many verifications ...        FAILED
reuses the remote key set for the same URL across concurrent ... FAILED
FAILED | 0 passed (2 steps) | 1 failed (2 steps) (5s)

Full suite, deno fmt, deno lint and deno task typecheck are all clean:

ok | 7 passed (121 steps) | 0 failed

Backward compatibility

No breaking change. requestOptions is a new optional trailing parameter, and both timeouts default to 5000 ms — which is what jose already used for JWKS. The api-key calls gain a bound they previously lacked, which is the point.

Follow-up, not in this PR

The library's built-in auth defaults are all public hostnames:

jwks_url:         "https://auth.flowcore.io/realms/flowcore/protocol/openid-connect/certs"
api_key_url:      "https://security-key.api.flowcore.io"
iam_url:          "https://iam.api.flowcore.io"
tenant_store_url: "https://tenant-store.api.flowcore.io"

For in-cluster consumers every one of these hairpins out through the public ingress to reach a peer a namespace away. service-security-organization-api-key-api#18 fixed exactly this for the tenant store and measured the same call at 25 ms in-cluster versus 277 ms public. These defaults are the systemic source of that pattern, but changing them would break out-of-cluster consumers, so it needs its own decision.

🤖 Generated with Claude Code

`authenticate()` called `createRemoteJWKSet()` on every request. That
resolver is stateful — it owns the cached keys, the cooldown window and
the coalescing of concurrent refreshes — so building a new one per
request discarded all of it. Every authenticated request therefore made
its own round trip to the identity provider.

In production this saturated the outbound connection pool of the
long-running process. `GET /api/v1/pathways` on
data-pathways-control-plane returned 401 on 6 of 6 attempts, each at
exactly 5.00 s, with `ERR_JWKS_TIMEOUT` in the logs — while the same
JWKS URL answered in 3 ms from a fresh process in the same pod. The
service sat at 23m CPU against a 1-core limit. It was blocked, not busy.

Changes:

- Cache remote key sets in a module-level Map, keyed by URL and timeout.
  jose keeps handling rotation via `cooldownDuration` (30 s) and
  `cacheMaxAge` (10 min).
- Pass an explicit `timeoutDuration` instead of relying on the implicit
  default.
- Bound both api-key validation fetches with `AbortSignal.timeout`. They
  had no timeout at all, so a stuck upstream held the caller until its
  own deadline and queued every request behind it.
- Include the upstream status, or the timeout, in the thrown error.
  `AppExceptionUnauthorized()` with no message gave the operator nothing.
- Expose `auth.jwks_timeout_ms` and `auth.api_key_timeout_ms` on
  `HonoApi`, both defaulting to 5000 ms.

Tests generate a real RS256 key pair, serve a real JWKS document and
count fetches. Reverting only the reuse line fails two of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jbiskur
jbiskur merged commit 573e9e6 into main Sep 1, 2026
2 checks passed
@jbiskur
jbiskur deleted the fix/jwks-remote-set-reuse-and-timeouts branch September 1, 2026 13:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant