Merged
Size
S
Change Breakdown
Bug Fix100%
#3464fix(webapp): honor RevokedApiKey grace window for public access tokens

Public access tokens honor API key rotation grace periods

Public access tokens will now continue to work during the 24-hour overlap window after an API key rotation, fixing an issue where streams were immediately disconnected.

Previously, when an API key was rotated, public access tokens (PATs) were immediately rejected on real-time stream endpoints, breaking active sessions despite an advertised 24-hour overlap window. Now, these tokens continue to function normally during the rotation period. The system checks the token against the current key first, and if that fails, verifies it against recently revoked keys. This keeps existing user sessions alive while transitioning to new credentials, without slowing down the primary authentication path. The fix lands in the web app's real-time authentication service, completing the grace-period support introduced in a prior rotation update.

View Original GitHub Description

Summary

Follow-up to #3420. PATs (public access tokens) minted before an API key rotation 401'd immediately on the realtime stream endpoints, even though the rotation flow advertises a 24h overlap. This fixes the gap.

Root cause

PATs are JWTs signed with the env's apiKey at mint time. When that secret is rotated, validatePublicJwtKey (apps/webapp/app/services/realtime/jwtAuth.server.ts) only verifies the signature against environment.parentEnvironment?.apiKey ?? environment.apiKey — i.e. the env's current canonical key. Any PAT in the wild signed with the previous key fails signature verification → 401, even within the grace window.

#3420 wired up the grace-window fallback in two places — findEnvironmentByApiKey (raw secret-key auth) and api.v1.auth.jwt.ts (signs new JWTs with the canonical key when minting from an old one) — but the verify path for already-issued PATs was never updated.

In a typical app, POST /api/v1/tasks/.../trigger (Bearer secret) keeps working through rotation because that path has the fallback, but GET /realtime/v1/streams/run_*/... and POST /realtime/v1/streams/run_*/input/... 401 for runs that were already in flight when the rotation happened.

Fix

After the primary validateJWT against the env's current apiKey, fall back to non-expired RevokedApiKey rows for the signing env (parent env when the request is against a child) — but only on the failure path, so the hot success path is unchanged. Uses $replica to match the rest of the auth path.

Symmetrical to the findEnvironmentByApiKey two-step from #3420.

Changes

  • apps/webapp/app/services/realtime/jwtAuth.server.tsvalidateAgainstRevokedApiKeys helper invoked only on !result.ok
  • apps/webapp/app/models/runtimeEnvironment.server.tsfindEnvironmentById also selects parentEnvironment.id so we can scope the revoked-keys lookup to the correct env

Test plan

E2E verified locally via curl against GET /realtime/v1/runs/{runId} (PAT-authenticated):

  • Pre-rotation, PAT signed with K1 → 200 with run body
  • Simulate rotation (insert RevokedApiKey row + flip env apiKey to K2 in a single transaction, mirroring regenerateApiKey)
  • Same PAT (K1) within grace window → 200 with run body — fallback hits
  • Fresh PAT signed with K2 → 200 — current key still works
  • Set RevokedApiKey.expiresAt to past → 401 — fallback finds no live row
  • Bogus signature (no rotation) → 401
  • Cleanup verified: env apiKey restored, RevokedApiKey row deleted
  • pnpm run typecheck --filter webapp passes
© 2026 · via Gitpulse