Skip to content

Firebase Token Refresh — Findings & Action Plan

Date: 2026-04-27 Status: Investigation complete — fixes accepted by the team for follow-up implementation Owner: Stefano Susini Contributor: w01fgang (PR review) Originating ticket: TOP-4907 Scope: Main portal Firebase ID token refresh flow (pages/_app.js, pages/token-refresh.js, pages/api/set-auth.js, lib/calculateMinutesToRefresh.js)


1. Problem statement

The CTO reported that the Firebase token refresh procedure does not work as expected. A code-based audit was performed to identify whether the procedure exists, whether it is correctly wired, and what concrete issues could explain the observed behaviour.

The procedure does exist and is implemented end-to-end. However, the audit surfaced nine distinct issues that, individually or combined, can produce symptoms ranging from silent logouts to expired-token errors after device idle to silent 401s on REST endpoints. None of the failure modes are mutually exclusive, and at least three of them route the user to /sign-in instead of /token-refresh, which is consistent with a CTO-level perception that “refresh doesn’t work”.

The findings fall into three distinct root-cause categories, which is worth naming early since the right fix shape depends on the category:

  • Client-side scheduling — Findings 3, 4, 5, 8. The browser fails to refresh on time (backgrounded tab, swallowed errors, race conditions, rate-limit misclassification). Fix: better client-side scheduling, retries, and visibility events.
  • Server response policy — Finding 1. The server makes a destructive choice in response to its own transient failures (clears the cookie). Fix: change what the server does in the catch path; not a scheduling problem.
  • Coverage gap — Finding 2. The reactive trigger is only wired to one of several code paths that can surface an expired-token error. Fix: cross-cutting interception or unified validation.
  • Cross-cutting concerns — Findings 6, 7, 9. Less directly causal but contribute to fragility (URL parsing, EOL SDK, lack of stable error taxonomy).

§2.4 elaborates on the first two categories as operational invariants of the system.


2. How refresh is supposed to work

2.1 Firebase’s documented model (generic reference)

Per the official Firebase Authentication documentation, every signed-in user holds two distinct tokens:

  • ID token — a short-lived JWT used to authenticate requests. Firebase ID tokens are short lived and last for an hour.
  • Refresh token — a long-lived credential used to obtain new ID tokens. Refresh tokens expire only when one of the following occurs: a major account change is detected for the user (e.g. password or email change, account disable, or explicit revocation via the Admin SDK).

The Firebase Web SDK stores the refresh token in IndexedDB and exposes two main APIs that interact with it:

  • User.getIdToken(forceRefresh) — when forceRefresh is omitted or false, returns the cached unexpired token, or refreshes it if the current one is expired. When forceRefresh is true, it always exchanges the refresh token for a brand-new ID token.
  • firebase.auth().onIdTokenChanged(...)identical to onAuthStateChanged but it also fires when the user’s ID token is refreshed. This is the canonical hook for keeping a server-side cookie in sync with the latest ID token.

A subtle but important caveat from the same documentation: Firebase does not automatically refresh the ID token on a fixed schedule. It only does so opportunistically while it is maintaining an active connection to Firestore or Realtime Database. If the app uses neither, the application is responsible for proactively refreshing tokens before they expire.

Under the hood, refresh is performed against the secure token endpoint: https://securetoken.googleapis.com/v1/token?key=[API_KEY] with body grant_type=refresh_token&refresh_token=[REFRESH_TOKEN]. The response includes a fresh id_token, an updated refresh_token, and expires_in (typically 3600 seconds).

Server-side, the Admin SDK’s admin.auth().verifyIdToken(idToken) validates the ID token’s signature and expiry. When the ID token is expired it rejects with auth/id-token-expired and an error message containing the literal string Firebase ID token has expired. Per the documentation, the recommended client/server pattern is:

  1. Send the latest ID token to the server on each request (cookie or Authorization: Bearer).
  2. Server verifies it with verifyIdToken. On auth/id-token-expired, return a 401 with a stable error code.
  3. Client catches the 401, calls getIdToken(true) to mint a fresh token, sends it back, and retries.

2.2 As implemented in this codebase

The codebase implements the documented client/server pattern with two reinforcing layers of defence — proactive (refresh before the token expires) and reactive (recover if it slips through anyway) — joined by a dedicated refresh page that handles full-page recovery.

The proactive layer is itself doubled. A Firebase listener (onIdTokenChanged) is registered on app mount to catch any refresh that the SDK performs internally; in parallel, a manual setInterval is armed to force a refresh roughly five minutes before the current token’s expiry. The interval exists specifically because this app does not maintain a live Firestore or Realtime Database connection, so — per section 2.1 — the SDK will not auto-refresh on its own. Every successful refresh, by either path, ends with a POST to /api/set-auth, which verifies the new ID token with the Admin SDK and writes it into an HttpOnly cookie that all server-side request handlers read on subsequent requests.

The reactive layer is intentionally narrower. When a Relay query fails, the top-level error handler in pages/_app.js inspects the error message and, if it matches the well-known shapes for an expired Firebase token (/CODE_407/im or /Firebase ID token/), redirects the browser to /token-refresh. That page mints a fresh ID token client-side (getIdToken(true)), syncs it through /api/set-auth, and then sends the user back to the URL they came from.

Concretely, this is wired up across five files:

  1. Listener (proactive)pages/_app.js:369 registers firebase.auth().onIdTokenChanged(). Whenever Firebase refreshes the token internally, the listener calls setAuth(false) which posts the new token to /api/set-auth and updates the cookie.
  2. Interval (proactive)pages/_app.js:357 sets a setInterval that fires roughly five minutes before the current token expires (computed by lib/calculateMinutesToRefresh.js). It calls setAuth(true) to force a refresh. This compensates for the fact that the codebase does not rely on Firestore/RTDB and so cannot assume Firebase will auto-refresh on its own.
  3. Reactive recoverypages/_app.js:654 inspects errors thrown inside the Relay QueryRenderer. When the error message matches /CODE_407/im or /Firebase ID token/, it redirects to /token-refresh. The second regex aligns with the literal Admin SDK error string for an expired ID token.
  4. Refresh pagepages/token-refresh.js calls firebase.auth().currentUser.getIdToken(true), posts the new token to /api/set-auth, then redirects back to the original URL.
  5. Server cookie writepages/api/set-auth.js verifies the supplied ID token via Firebase Admin SDK and, on success, writes a 30-day idToken cookie containing the freshly minted JWT.

Development documentation: docs/user-guides/dev-manual/token-refresh.md.

2.3 References

The Admin SDK’s documentation page lists error codes; the source files are the authoritative reference for the exact error message strings and for the conditions under which each code is thrown (notably: revocation is only checked when verifyIdToken is called with checkRevoked=true, which is never the case in this codebase).

2.4 Operational invariants

Two facts about how the system actually behaves are crucial for understanding the findings below; both come up repeatedly in the analysis.

Cookie rotation is mostly client-driven, but the server still owns its own response policy. pages/api/set-auth.js is a write-only endpoint fed by the client immediately after a successful getIdToken(...) call. The server alone cannot rotate the cookie — it has no access to the refresh token (intentional: refresh tokens are long-lived and live only in the browser’s IndexedDB, by Firebase JS SDK design). However, the server does fully own how it responds to its own failures (cookie state on transient errors, status codes returned, retry hints) — and that response policy is the locus of Finding 1.

So most failure modes in the proactive layer are client-side scheduling problems (Findings 3, 4, 5, 8), but Finding 1 is a server response-policy problem and Finding 2 is a coverage-gap problem. They need different shapes of fix; conflating them under “client-side scheduling” obscures that. Note also that verifyIdToken inside set-auth almost never legitimately rejects the token — the token it receives was just minted by the JS SDK milliseconds earlier — which is what makes the catch-path response policy so consequential when it does fire.

Two distinct error-code namespaces are involved. The codebase touches both Firebase Admin (server) and Firebase JS (client) SDKs. Their error codes overlap in shape but mean different things:

SourceWhere it runsExamplesMeaning
Admin SDK verifyIdTokenServer-side: pages/api/set-auth.js:23, authMiddleware.js:9, isAuthenticated.ts:29auth/id-token-expired, auth/id-token-revoked, auth/invalid-id-tokenThe token in the cookie is bad — recoverable by client refresh
JS SDK getIdTokenClient-side: pages/_app.js:330-338, pages/token-refresh.js:89-97auth/user-token-expired, auth/user-disabled, auth/user-not-found, auth/invalid-user-token, auth/too-many-requestsThe user’s refresh token / account is unusable — must sign out

Critically, auth/id-token-expired is a server-side signal that should trigger a refresh, not a sign-out. The JS SDK’s getIdToken automatically refreshes when the cached ID token is past expiry, so it never throws auth/id-token-expired itself — it only throws when the refresh token itself cannot be exchanged. The codebase’s sign-out branch at pages/_app.js:330-338 correctly enumerates the client-side codes; the recovery policy in Findings 1 and 4 must respect this distinction and never escalate a server-side signal (or a generic 5xx) to a logout.


3. Findings

Each finding includes a description, the file/line evidence, severity, suggested solution, and the rationale for that solution.

Severity: Medium Evidence: pages/api/set-auth.js:49-58

} catch (err) {
console.error(err);
Sentry.captureException(err);
res.setHeader('Set-Cookie', [
serialize('idToken', '', { ...DEFAULT_COOKIE_OPTIONS, expires: new Date(0) }),
]);
res.status(500).json({ ... });
}

Per §2.4, the endpoint is fed exclusively by the client immediately after a fresh getIdToken(...). So the catch path almost never fires for “the supplied token is genuinely invalid” — that path is effectively unreachable under normal conditions. The realistic causes are:

  1. Client clock skew — laptops returning from sleep or badly-synced phones cause the JS SDK to stamp a token whose iat/exp look wrong to the Admin SDK. Most common cause in production at scale.
  2. getAdmin() cold-start failure — serverless cold starts can fail to initialize the Admin SDK (transient credential resolution, slow IAM).
  3. JWT signing key rotation race — Firebase rotates RS256 signing keys on a schedule. If the Admin SDK’s key cache misses and the refetch hiccups, verification fails transiently.
  4. googleapis.com blip — same family as #3.

All four are rare per request but inevitable at fleet scale. The bug isn’t that the catch fires often; it is that the response (clearing the HttpOnly cookie) is disproportionate to the trigger. After the cookie is cleared, the next GraphQL request hits createResolversContext.js:40, throws Missing auth token or token is not valid., matches /Missing auth token/im at pages/_app.js:666, and signs the user out — for a transient infrastructure event that would have self-resolved within seconds.

Suggested solution: Stop clearing the cookie in the catch path entirely. Return a typed 5xx so the client can tell this apart from validation failures, but leave the existing cookie in place. Pair with the unified retry policy in Finding 4 so the client force-refreshes and retries on any 5xx from /api/set-auth.

Rationale: Per §2.4, the only authoritative signal that the user’s session is genuinely over is a getIdToken rejection with one of the five client-side auth/* codes already enumerated at pages/_app.js:330-338. A 5xx from /api/set-auth is not one of those signals. Treating it as one converts every transient infra blip into a forced logout, even though the user’s refresh token is still valid in IndexedDB and the next refresh cycle would have succeeded on its own.


Finding 2 — Reactive trigger has no coverage for Postgres-disabled tenants on most GraphQL queries

Severity: Medium Evidence: server/graphql/helpers/createResolversContext.js:80-101 is the only branch in the GraphQL context creator that verifies the Firebase ID token, and it only runs when postgresIsEnabled && idToken && companyId. For Postgres-disabled tenants, the GraphQL context never calls verifyIdToken and never throws Firebase ID token has expired. The reactive recovery regex at pages/_app.js:654 therefore never matches on those tenants for ordinary queries.

This is a long-standing gap, not a recent regression. The non-Postgres path has effectively never verified idToken at the GraphQL context level. (A short-lived block that did so was added in commit 6cbda16 on 2026-04-20 and removed in 94492db on 2026-04-22 — about 48 hours in master, not enough to count as the historical baseline.)

The trigger does still fire incidentally on:

  • Postgres-enabled tenants — every GraphQL request via authMiddleware.
  • Resolvers that verify idToken inline — server/graphql/resolvers/mutation/updateUserAccount.resolver.js:45, server/graphql/resolvers/mutation/accessSensitiveMedia/accessSensitiveMedia.resolver.ts:42.
  • API routes that gate themselves with isAuthenticated (server/graphql/helpers/auth/isAuthenticated.ts:29).

But ordinary read queries on Postgres-disabled tenants succeed silently with a stale idToken cookie. Refresh only triggers when the user happens to hit one of those rare verify-inline call sites, or when the rest of the auth state breaks for an unrelated reason. Other endpoints that verify (pages/api/upload-company-signature/utils.ts, pages/api/company-signature/utils.ts, several broker routes) throw outside the Relay error pipeline, so their failures never reach the reactive trigger either.

Suggested solution: Make refresh-on-401 a cross-cutting concern instead of a Relay-only one. Two options, not mutually exclusive:

  1. Add a global fetch-level interceptor (or a custom Apollo/Relay network layer) that catches any response containing Firebase ID token or a 401 status code from any endpoint and triggers the same /token-refresh redirect.
  2. Add a single verifyIdToken check at a shared boundary (Next.js middleware or a small wrapper used by every protected handler) so all routes — GraphQL, API routes, broker routes — share one authority for ID-token validity, and one error shape that the reactive trigger can match against.

Rationale: The reactive trigger has always been implicitly tied to “the GraphQL context happens to verify the ID token”. On Postgres-disabled tenants that has never been true for ordinary queries, so the proactive interval is the only line of defence — and Finding 3 shows that line is brittle in backgrounded tabs. Centralising token validation gives the reactive layer a reliable signal to react to.


Finding 3 — Background tabs lose the proactive refresh interval; no recovery on focus

Severity: High Evidence: pages/_app.js:357-359

this.interval = setInterval(() => {
this.setAuth(true);
}, 1000 * 60 * minutesToRefresh);

Mobile Safari pauses setInterval when the tab is backgrounded; Chrome heavily throttles it. After ~1 hour of inactivity the in-memory token is stale and the cookie is stale too. There is no visibilitychange/focus listener that triggers a fresh setAuth(true) when the tab becomes visible again, so recovery only happens lazily on the next failed query.

Suggested solution: Add a document.addEventListener('visibilitychange', ...) and window.addEventListener('focus', ...) handler in componentDidMount that calls setAuth(true) when the tab becomes visible after being hidden for more than 30 seconds (debounced).

Rationale: Tab-throttling is a deliberate browser behaviour and cannot be worked around with intervals alone. Visibility events are the canonical signal that a stale tab is becoming active again.


Finding 4 — setAuth swallows non-auth errors with no retry, no UX, and no recovery policy

Severity: High Evidence: pages/_app.js:326-344

The catch branch in setAuth reports to Sentry and otherwise does nothing for any error that is not in the small set of client-side auth/* codes (see §2.4). The idToken cookie has a 30-day expiry (pages/api/set-auth.js:34), but the JWT inside it expires in one hour. If setAuth cannot reach /api/set-auth for any non-auth reason, the cookie keeps a stale JWT for up to 30 days while the user assumes everything is fine.

Suggested solution: Implement a single, unified recovery policy across both pages/_app.js’s setAuth and pages/token-refresh.js’s refreshToken:

attempt 1: getIdToken(false) -> POST /api/set-auth
on 5xx (network or HTTP):
attempt 2 (immediate): getIdToken(true) -> POST /api/set-auth
on 5xx (immediate retry failed):
backoff retry (2s, 4s, 8s) up to 3 times, calling getIdToken(true) each time
on persistent failure:
proactive path: leave existing cookie, surface a non-blocking UI signal
reactive path: show retry / sign-in UI (existing behaviour at pages/token-refresh.js:151+)
on getIdToken auth/* error (the five codes enumerated at pages/_app.js:330-338):
signOut + redirect to /sign-in

The immediate force-refresh on the second attempt covers both the “transient infra” case (force-refresh wastes one cheap round-trip and self-heals once infra recovers) and the “the original token was somehow malformed” case (force-refresh produces a clean one). A single unified path is justified because the client cannot reliably distinguish the two server-side, and force-refresh is cheap (~200-400 ms, single HTTPS call to securetoken.googleapis.com) and harmless in either case.

Visibility:

  • The proactive path (onIdTokenChanged listener and setInterval in _app.js) runs in the background; retries here are invisible to the user. Even a 14-second backoff window is unnoticed unless the user happens to fire a query in that window that needs a fresh cookie. To reduce that risk, keep the proactive backoff window short and let the reactive path take over if it expires.
  • The reactive path (/token-refresh page) is intrinsically blocking — the page is shown with a loader. Each retry attempt extends the loader’s duration. After exhausted backoff the existing UI at pages/token-refresh.js:151+ shows a retry button and a sign-in escape, which is the correct behaviour.

Rationale: Per §2.4, the right shape of recovery is a single path that treats all non-auth/* failures as transient and worth retrying with a fresh token. Distinguishing infrastructure-transient from token-malformed adds protocol complexity that the client cannot act on differently anyway. Sentry-only telemetry is for engineers, not users.


Severity: Medium Evidence: pages/_app.js:312-321

const expiresIn = await user.getIdTokenResult().then((r) => r.expirationTime);
this.setAuthInterval(expiresIn);
const resp = await fetch('/api/set-auth', { ... });
if (!resp.ok) throw new Error(...);

The interval is scheduled based on the new token’s expiry even when /api/set-auth then fails. The local in-memory token will be valid, but the cookie won’t, so the timer ticks on data that doesn’t reflect the cookie’s real state.

Suggested solution: Move setAuthInterval(expiresIn) to after the successful fetch response, inside the if (resp.ok) branch.

Rationale: The interval represents “the cookie is good until X”. It should only be armed once X is actually true on the server.


Finding 6 — validateReturnUrl rejects relative paths

Severity: Low Evidence: pages/token-refresh.js:23-39

new URL(returnUrl) throws on relative paths like /dashboard. The catch silently falls back to /, so a user redirected to /token-refresh from /dashboard may be sent home rather than back to where they were.

Suggested solution: Treat string starting with a single / (and not //) as a safe relative path and return it as-is. Only run the URL-origin check for absolute URLs.

Rationale: The current implementation is overly strict. The security goal (block open redirects) is preserved by the same-origin rule for absolute URLs and by rejecting protocol-relative // URLs.


Finding 7 — Firebase JS SDK is end-of-life

Severity: Medium Evidence: package.json:137"firebase": "8.10.1"

Firebase JS SDK v8.10.1 was released in September 2021. Firebase deprecated the v8 namespace API in 2024. v8 has known auth-state edge cases that were fixed in the modular v9+ API.

Suggested solution: Plan a migration to firebase v10/v11 modular SDK as a separate workstream. Pair the migration with revisiting persistence configuration (browserLocalPersistence vs indexedDBLocalPersistence).

Rationale: Continuing on an EOL SDK accumulates risk: no security patches, no compatibility guarantees with newer browsers, and any new auth bug found upstream will not be fixed for v8.


Finding 8 — auth/too-many-requests is treated as a sign-out trigger, but it is rate-limiting

Severity: Low Evidence: pages/_app.js:333-334 and pages/token-refresh.js:93

Both call sites enumerate auth/too-many-requests alongside truly terminal codes like auth/user-disabled and auth/user-not-found, and respond by signing the user out. But auth/too-many-requests is Firebase rate-limiting, not a session-end signal. The user is not at fault (the device or the upstream call rate is) and the underlying refresh token is still valid. Signing them out is a destructive overreaction.

Suggested solution: Remove auth/too-many-requests from the sign-out branch. Treat it the same as a 5xx in the unified recovery policy from Finding 4 — backoff and retry. If the rate limit persists past the backoff window, surface a non-blocking message (“Too many sign-in attempts — please try again in a moment”) rather than logging the user out.

Rationale: Rate limits self-clear. The destructive recovery (sign-out) outlasts the transient cause (rate-limit window). The unified retry policy is exactly the right shape for this case.


Finding 9 — No stable server-side error taxonomy for the reactive trigger to match against

Severity: Medium Evidence: pages/_app.js:654 and pages/_app.js:660-668

The reactive recovery in _app.js matches errors by regex on the message string rather than by a stable code:

if ((/CODE_407/im.test(error.message) || /Firebase ID token/.test(error.message)) && ...) {
window.location.href = getTokenRefreshRoute(window.location.href);
}
if (/unauthorized/im.test(error.message) || error.message.includes('crypt') || /Missing tenantId/.test(error.message)) {
signOutAndResetAnalytics(firebase);
...
}
if (/Missing auth token/im.test(error.message)) {
signOutAndResetAnalytics(firebase);
}

This is brittle for several independent reasons:

  1. The matched substring Firebase ID token is a literal from the Firebase Admin SDK’s expired-token message template (token-verifier.ts). If Firebase changes the template — even just rephrasing it on a minor SDK upgrade — the reactive trigger silently stops firing. There is no test that would catch the regression.
  2. The matched code CODE_407 is the application’s own forbidden code (lib/errorCodes/index.js:13), but it is reused for several distinct conditions (missing auth token, missing companyId, Postgres-required-token-missing, etc.). Matching on the code alone cannot tell “refresh and retry” apart from “sign out”.
  3. The categorisation between refresh-path and sign-out-path is order-sensitive — the regex chains check refresh first, then sign-out — and adding a new error message anywhere in the codebase that happens to contain “unauthorized” silently routes users to sign-out instead of refresh.
  4. Out-of-band errors do not match anything. Errors from non-Relay endpoints (REST API routes that throw an auth/id-token-expired-shaped error to the fetch layer) never reach the regex chain at all — Finding 2’s coverage-gap problem.

Suggested solution: Introduce a small server-side error taxonomy with stable codes the reactive layer can match on:

  • TOKEN_EXPIRED — refresh and retry
  • TOKEN_MISSING — sign in (no session at all)
  • TOKEN_INVALID — sign in (token is malformed or signed for the wrong project)
  • TOKEN_REVOKED — sign in (revokeRefreshTokens was called server-side)

Every server-side rejection of the idToken cookie maps to one of those four codes (in the GraphQL response, in REST 401 bodies, anywhere). The reactive layer in _app.js matches error.code === 'TOKEN_EXPIRED', not a regex. This is orthogonal to the cookie format — it works equally well today’s idToken cookies and would carry over to a hypothetical session-cookie migration without change.

Rationale: A regex on a vendor’s message string is a fragile contract. A stable code defined in our codebase is not. The shape of the fix is small (a helper that wraps thrown errors and a few error.code checks in _app.js) and it removes a class of silent regressions that would otherwise be invisible until users started complaining.


Bonus — CLAUDE.md banned-pattern violations on the refresh path

These are not root causes of the refresh failure, but should be cleaned up while the area is being touched:

  • pages/_app.js:378 — fire-and-forget this.setAuth(false) (no await, no error handler).
  • pages/api/set-auth.js:9-60 — native try/catch; project standard is @power-rent/try-catch/nextjs.
  • lib/graphql/yogaHandler.ts:34// TODO: mask non validation errors (banned TODO comment).
  • pages/token-refresh.js:33-36 — empty-ish catch on URL parse with bare console.error and no Sentry breadcrumb.

Each of these should land as its own follow-up ticket so the cleanup is tracked rather than absorbed silently into the implementation PRs.


4. Confirmation steps before implementing fixes

These are needed to rank the findings against the symptom the CTO is actually seeing:

  1. Sentry comparison. Last 7 days, count: Failed to set auth token events, Firebase ID token has expired events, /token-refresh page views, and unintended logouts. A high Failed to set auth token count with low /token-refresh views points at Findings 1 and 4. A high Firebase ID token count with low /token-refresh views points at Finding 2.
  2. Cookie-clear repro. In a logged-in browser, delete only the idToken cookie via DevTools, then navigate to a protected page. Expected: redirect to /token-refresh, then back to the page. If instead the user is sent to /sign-in, Finding 1 is confirmed.
  3. Mobile-idle repro. Background the iOS Safari tab for 90 minutes, reopen, and tap any UI element that issues a query. Expected: a brief flicker through /token-refresh. If the user sees an error or a logout, Finding 3 is confirmed.
  4. Non-Relay endpoint repro. With an expired ID token, hit pages/api/upload-company-signature directly. If it returns 401 without triggering refresh, Finding 2 is confirmed.

5. Suggested order of work

Sequenced so that the unified recovery policy lands first, since several other findings depend on it:

  1. Finding 4 (unified retry + UX) — defines the recovery policy that Findings 1, 5, 6, and 8 plug into. Land this first; subsequent PRs become single-line changes.
  2. Finding 9 (stable server-side error taxonomy) — should land alongside Finding 4 because the unified retry policy is most useful when it can match on a stable code rather than a regex. Land before Finding 1 / 2 so they can use the new codes.
  3. Finding 1 (stop clearing the cookie in set-auth) — small diff once Finding 4 + 9 are in place; together they fix the primary forced-logout failure mode.
  4. Finding 3 (visibility/focus listeners) — small diff, fixes mobile/idle case.
  5. Finding 2 (cross-cutting refresh on 401) — medium diff, requires a design choice between fetch interceptor and shared middleware. Cheaper once Finding 9’s taxonomy is in place. Lower urgency than 1/3/4 because most production traffic is Postgres-enabled today; revisit if Postgres-disabled tenants remain.
  6. Finding 5 (interval-after-cookie) — trivial fix, ride along with Finding 4.
  7. Finding 6 (relative-path return URL) — trivial fix, ride along with Finding 4.
  8. Finding 8 (rate-limit not a sign-out trigger) — trivial fix, ride along with Finding 4.
  9. Finding 7 (Firebase v8 → v9+ migration) — separate workstream, plan independently.
  10. Bonus (banned-pattern cleanup) — fold into each PR that touches the relevant file. Each item also gets its own follow-up ticket so the cleanup is tracked.

Each item should ship as its own PR with the corresponding repro from Section 4 turned into an automated test where feasible.