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.ts—validateAgainstRevokedApiKeyshelper invoked only on!result.okapps/webapp/app/models/runtimeEnvironment.server.ts—findEnvironmentByIdalso selectsparentEnvironment.idso 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
RevokedApiKeyrow + flip envapiKeyto K2 in a single transaction, mirroringregenerateApiKey) - Same PAT (K1) within grace window → 200 with run body — fallback hits
- Fresh PAT signed with K2 → 200 — current key still works
- Set
RevokedApiKey.expiresAtto past → 401 — fallback finds no live row - Bogus signature (no rotation) → 401
- Cleanup verified: env
apiKeyrestored,RevokedApiKeyrow deleted -
pnpm run typecheck --filter webapppasses