Skip to content

Firebase session cookies as an alternative to client-driven ID-token cookies

Date: 2026-04-27 Status: Investigation complete — informational reference; not scheduled for implementation Owner: Stefano Susini Contributor: w01fgang (PR review) Originating ticket: TOP-4907 Related: docs/investigations/firebase-token-refresh/2026-04-27-firebase-token-refresh-findings.md


1. Why this document

The findings doc enumerates eight issues with the current Firebase ID-token refresh flow. All eight live downstream of the same architectural choice: the idToken cookie is rotated by the client on a schedule, with the server reading whatever JWT the client most recently posted to /api/set-auth. Per §2.4 of the findings doc, this is the structural reason every failure mode is fundamentally a client-side scheduling problem.

Firebase ships a different primitive — createSessionCookie — that inverts this responsibility: the server mints a long-lived session cookie that the server itself can rotate, validate, and revoke without re-involving the client. This document explores adopting it. It is intentionally exploratory; if the team decides to go ahead, this file should be moved to docs/adr/ and reshaped into ADR format with an explicit decision.


2. The current model (recap)

After sign-in:

  1. Client calls firebase.auth().currentUser.getIdToken(...) to obtain a 1-hour JWT.
  2. Client posts that JWT to pages/api/set-auth.js.
  3. Server validates with admin.auth().verifyIdToken(idToken) and writes it into an HttpOnly cookie named idToken with a 30-day expiry.
  4. Every subsequent request reads the cookie; protected handlers re-run verifyIdToken per request.
  5. Because the JWT inside the cookie expires every hour, the client must re-mint and re-post a fresh token before that happens — via the onIdTokenChanged listener, the setInterval fallback, or the reactive /token-refresh page.

The cookie expiry (30 days) and the JWT expiry (1 hour) are deliberately mismatched, which is why every gap in the client’s refresh schedule produces a stale-cookie failure mode.


3. The proposed model: createSessionCookie

Firebase Authentication exposes admin.auth().createSessionCookie(idToken, { expiresIn }) on the server. It accepts a fresh ID token and returns an opaque session-cookie string whose lifetime can be set up to two weeks (expiresIn in milliseconds, max 14 * 24 * 60 * 60 * 1000). The session cookie is cryptographically distinct from the ID token: it is a Firebase-signed credential bound to the user and project, validated server-side via admin.auth().verifySessionCookie(sessionCookie, checkRevoked?).

After sign-in:

  1. Client calls getIdToken() exactly once and posts it to a server endpoint (e.g. /api/login).
  2. Server validates the ID token, then calls createSessionCookie(idToken, { expiresIn }) to mint the session cookie.
  3. Server writes the session cookie into an HttpOnly cookie. From this point on, the client never touches the cookie.
  4. Every subsequent request reads the cookie; protected handlers validate it with verifySessionCookie. No 1-hour expiry concerns.
  5. When the session cookie itself nears expiry (e.g. on day 13 of a 14-day cookie), the server can either let it expire (forcing re-login) or quietly mint a new one server-side and update the Set-Cookie header on the response of any active request. Caveat: server-side rotation only fires for users who have an active request during the renewal window. A user who is idle from day 10 through day 14 receives no rotation and hits a hard logout at TTL. Mitigations are limited: (a) pick a TTL long enough that “idle for the entire window” is rare for the user population, (b) implement a wake-up mechanism (e.g. a tiny client heartbeat to a /api/ping route, undoing some of the simplification this migration was meant to deliver). Worth surfacing in the open questions before picking a TTL.
  6. Sign-out: client calls a server endpoint (e.g. /api/logout) that clears the cookie, and optionally calls admin.auth().revokeRefreshTokens(uid) to also invalidate the underlying refresh token.

Reference: https://firebase.google.com/docs/auth/admin/manage-cookies.


4. Side-by-side comparison

AspectToday (ID-token cookie)With session cookies
Cookie content1-hour Firebase ID token JWTOpaque Firebase-signed session credential
Cookie TTL30 days (declarative) but real validity = 1 hour (JWT)Up to 14 days, real validity = TTL
Who rotates the cookieClient, by re-posting a fresh ID tokenServer, autonomously
Refresh trigger needed on the clientYes — listener, interval, reactive pageNo
Reactive recovery on stale cookieYes — /token-refresh pageNot needed in normal flow
Server-side validation APIverifyIdTokenverifySessionCookie
RevocationrevokeRefreshTokens + verifyIdToken(token, checkRevoked=true) (codebase never sets checkRevoked, so revocation is currently unenforced)revokeRefreshTokens + verifySessionCookie(cookie, checkRevoked=true); revocation is the intended mechanism
Mobile / backgrounded tabBreaks (Finding 3)Not affected — server validates against its own cookie
Sensitivity to client clockHigh (Finding 1 cause #1)None
Sensitivity to setInterval throttlingHigh (Finding 3)None
Bundle weightToday’sSame — no new client SDK needed
Hijack risk window if a cookie alone leaks1 hour (the JWT TTL). The refresh token is not in any cookie. However, the refresh token does live in JS-readable IndexedDB on the device, so any threat that can run JS in our origin (XSS) extracts it and bypasses this 1-hour bound entirely. See §7.1–§7.3.Up to the configured expiresIn, and renewable indefinitely by the attacker if the server rotates the cookie on each request and revocation is not enforced — a stolen cookie’s use will return a fresh cookie. See §7.4.

The hijack-window row is intentionally complex. The naive framing “today: 1 hour, session cookies: 14 days” is misleading because today’s setup also exposes a long-lived refresh token to XSS. The full per-attack comparison is in §7.3.


5. What changes in this codebase

Approximate scope; numbers are estimates, not commitments.

5.1 Pattern shift: from listener-driven to explicit login/logout

The biggest implementation difference between today and the proposed model is where the cookie work is triggered.

Today, pages/_app.js:369 registers an onIdTokenChanged listener that calls setAuth(false) on every fire. The listener is the de-facto “the cookie may need refreshing” hook. Sign-in is not handled directly in code that knows about authentication — the sign-in screen calls signInWithEmailAndPassword, the listener picks up the new auth state asynchronously and posts the ID token to /api/set-auth. Logout is similarly indirect: signOutAndResetAnalytics calls firebase.auth().signOut() locally, the listener fires with user = null, the page redirects to /sign-in, and the cookie is left to expire on its own.

The Firebase documentation for session cookies recommends a different shape: an explicit /api/login endpoint called immediately after sign-in succeeds, and an explicit /api/logout endpoint called on sign-out. The session-cookie migration should adopt this pattern, for three concrete reasons.

1. onIdTokenChanged conflates two events that need different server actions.

Event the listener fires forRight server action todayRight server action with session cookies
User just signed inMint and write idToken cookieMint a session cookie via /api/login
ID token rotated (background SDK refresh)Write the new idToken cookieNothing — the session cookie is server-managed and independent of ID-token rotation

If the listener-driven exchange is preserved after migration, every silent SDK refresh re-mints the session cookie unnecessarily, recreating the brittleness session cookies were meant to eliminate. Filtering “first sign-in” from “rotation” inside the listener is fragile because the listener does not reliably distinguish them.

2. Logout requires a server endpoint regardless.

Today, signOutAndResetAnalytics (lib/firebase/utils.ts) only signs out the SDK locally. The cookie is not cleared and revokeRefreshTokens is not called. For session cookies this is a security bug: a leaked session cookie remains valid for its full TTL even after the user “signs out”. The migration must add /api/logout, so we are introducing an explicit endpoint for logout already; introducing a matching /api/login is the symmetric and natural shape.

3. Iteration 2 (Persistence.NONE + signOut(), see §7.5) needs explicit login. Calling signOut() immediately after /api/login only makes sense if /api/login is itself called from a known sign-in completion point. With the listener-driven approach, there is no clean “sign-in just finished” moment to anchor the signOut() to. Adopting the explicit pattern in Iteration 1 is what makes Iteration 2 tractable.

Concrete sign-in / sign-out call sites in the codebase

Sign-in (places that need to call /api/login after success):

  • views/SignIn/index.js:203 — main email/password sign-in
  • hooks/auth/useMfaChallenge.js:81 — MFA challenge completion (mfaResolver.resolveSignIn)
  • pages/ops/account/create.js:283 — account creation flow
  • apps/broker-portal/src/components/auth/login-form.tsx:43 — broker portal (uses its own session today; treat separately)

Sign-out (places that need to call /api/logout before clearing local state) — all currently route through signOutAndResetAnalytics:

  • components/MainContainer/ProfileMenu.js:122 — intentional user logout
  • components/Deactivated/index.js:47 — when account is disabled
  • components/pages/Settings/SecurityTab/MfaStatus/EnableButton.js:50
  • components/pages/Settings/SecurityTab/MfaStatus/DisableButton.js:60
  • pages/_app.js:335,517,661,667 — error-driven sign-outs
  • pages/token-refresh.js (deleted in this migration)

Shape of the centralised helpers

// lib/firebase/session-cookie.ts (new)
export async function completeSignInWithSessionCookie(): Promise<void> {
const user = firebase.auth().currentUser;
if (!user) throw new Error('completeSignInWithSessionCookie called with no user');
const idToken = await user.getIdToken();
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ idToken }),
});
if (!res.ok) throw new Error(`/api/login failed: ${res.status}`);
}
// Replaces the existing signOutAndResetAnalytics (or wraps it).
export async function signOutAndClearSession(firebase): Promise<void> {
await fetch('/api/logout', { method: 'POST' }); // clears cookie + revokes refresh tokens server-side
signOutAndResetAnalytics(firebase); // existing local cleanup (Firebase signOut, analytics reset)
}

Each sign-in call site adds one await completeSignInWithSessionCookie() after the successful sign-in promise. Each sign-out call site swaps the existing helper for signOutAndClearSession.

5.2 Replace

  • pages/api/set-auth.jsdeleted. Replaced by two new endpoints:
    • pages/api/login.js — one-shot exchange (ID token in, session cookie out via createSessionCookie).
    • pages/api/logout.js — clears the session cookie via Set-Cookie with past expiry, then calls admin.auth().revokeRefreshTokens(uid).
  • pages/token-refresh.jsdeleted. Reactive recovery page is no longer needed.
  • pages/_app.js’s setAuth method, setAuthInterval, the interval field, and the onIdTokenChanged listener’s call into setAuthremoved. The listener can be kept for Sentry user identification only (Sentry.setUser({ email }) at line 301) and for redirecting unauthenticated users — no cookie work.
  • lib/calculateMinutesToRefresh.jsdeleted.
  • The /CODE_407/ and /Firebase ID token/ regex branches in pages/_app.js:654simplified. A 401 from any endpoint becomes a hard “session expired, redirect to /sign-in” because the cookie is no longer transient by design and there is no /token-refresh page to redirect to.

5.3 Update

Every server-side verifyIdToken call → swapped for verifySessionCookie. The decoded token shape is largely the same (uid, email, custom claims), so most downstream code keeps working. Full enumeration of main-portal sites (13 distinct callers; ~2× the originally-implied scope):

  • server/graphql/helpers/authMiddleware.js:9
  • server/graphql/helpers/auth/isAuthenticated.ts:29
  • server/graphql/resolvers/mutation/updateUserAccount.resolver.js:45
  • server/graphql/resolvers/mutation/accessSensitiveMedia/accessSensitiveMedia.resolver.ts:42
  • pages/api/set-auth.js:23 (deleted in this migration; listed for completeness)
  • pages/api/login-client.js:30
  • pages/api/login-user.js:31
  • pages/api/change-password.js:22
  • pages/api/public/export/companies.js:124
  • pages/api/public/export/companies-v2.js:52
  • pages/api/public/broker/verify-admin.ts:26
  • pages/api/company-signature/utils.ts:267
  • pages/api/upload-company-signature/utils.ts:122

Plus the broker portal’s separate auth path (apps/broker-portal/src/api/routes/auth.ts:56, broker.ts:19), which is out of scope for this migration as it has its own JWT session.

Other updates:

  • lib/getTokens.js → reads a different cookie name (e.g. __session per Firebase Hosting convention, or keep idToken if we do not deploy on Firebase Hosting). During Phase 2 the read order must be __session first, then fall back to idToken, never the reverse — otherwise a stale idToken cookie left over from before the cut-over can shadow a fresh session cookie and the user appears stuck on stale auth.
  • All sign-in call sites listed in §5.1 → add await completeSignInWithSessionCookie() after the successful sign-in promise.
  • All sign-out call sites listed in §5.1 → swap signOutAndResetAnalytics(firebase) for await signOutAndClearSession(firebase). lib/firebase/utils.ts either grows the new helper or is replaced by the new module.

5.4 Stays the same

  • Firebase JS SDK on the client (still used for sign-in flows, Sentry user identification, MFA flows, anything that needs currentUser). Iteration 2 (§7.5) is what would change this.
  • The sign-in UX — users still authenticate with email/password, social providers, etc. The change is internal to the post-sign-in cookie management.
  • All resolver logic that consumes the decoded user.

6. Migration sketch

Phased so we can roll back at any step. The pattern shift described in §5.1 (explicit /api/login and /api/logout instead of listener-driven /api/set-auth) is reflected throughout.

Phase 1 — Add the session-cookie endpoints and helpers in parallel

  • Implement pages/api/login.js minting the session cookie (cookie name TBD per §8). The endpoint validates the supplied ID token with verifyIdToken, then calls createSessionCookie(idToken, { expiresIn }) and writes the result as an HttpOnly cookie.
  • Implement pages/api/logout.js clearing the cookie and calling admin.auth().revokeRefreshTokens(uid).
  • Implement verifySessionCookie helper alongside the existing verifyIdToken callsites. Do not switch any consumers yet.
  • Add the two helpers (completeSignInWithSessionCookie and signOutAndClearSession) in lib/firebase/session-cookie.ts (or extend lib/firebase/utils.ts).
  • No call-site changes yet. idToken cookie + set-auth continue to drive the live flow.

Phase 2 — Migrate sign-in / sign-out call sites and read paths

  • Update each sign-in call site listed in §5.1 to await completeSignInWithSessionCookie() after the successful sign-in promise. Both cookies are written briefly during the migration window — idToken by the existing listener, the session cookie by the new helper.
  • Update each sign-out call site listed in §5.1 to await signOutAndClearSession(firebase) instead of signOutAndResetAnalytics(firebase).
  • Switch the GraphQL context, broker auth, and per-resolver checks to read the session cookie first, falling back to idToken for users mid-session. New sign-ins now produce both cookies; existing sessions continue with idToken until they expire naturally.

Phase 3 — Cut over (must ship with Phase 4)

  • Once the session cookie is present for >99% of active users (track via Sentry / analytics), remove the idToken fallback in the read paths and delete:
    • pages/api/set-auth.js
    • pages/token-refresh.js
    • pages/_app.js’s setAuth, setAuthInterval, interval field, the listener’s call into setAuth, and the /CODE_407/ / /Firebase ID token/ reactive branches at line 654 (simplify to a hard 401 → /sign-in redirect)
    • lib/calculateMinutesToRefresh.js
  • The onIdTokenChanged listener in _app.js is reduced to non-cookie work (Sentry user identification, redirecting unauthenticated users).

Phase 4 — Revocation discipline (must ship with Phase 3)

  • Enable checkRevoked: true on verifySessionCookie for any route where revocation matters (at minimum: mutations, media access, claim-sensitive reads). This adds one Firestore-side lookup per request; pick which routes warrant it.
  • Confirm /api/logout actually calls revokeRefreshTokens(uid) (it does in the helper sketch above; verify in implementation).
  • Add revokeRefreshTokens(uid) calls anywhere a custom claim is changed server-side, so claim updates take effect on the next request rather than at next sign-in.

Phase 4 must ship together with Phase 3, not after it. Without checkRevoked: true and a working logout, the cut-over reduces hijack resistance compared to today’s flow (see §7).


7. Risks and considerations

A clear-eyed comparison of hijack resistance requires knowing exactly which credentials exist on the user’s device and where each is stored. The boundary between “JS-readable” and “HttpOnly cookie” is the most consequential one, and it cuts in both directions.

7.1 Where the secrets actually live

Today’s setup:

SecretStorageJS-readable?Effective TTL
idToken (1-hour Firebase JWT)HttpOnly cookie named idTokenNo30 days as a cookie, but the JWT inside expires every hour
Refresh token, plus a copy of the current ID tokenIndexedDB → firebaseLocalStorageDbfirebaseLocalStorage, key firebase:authUser:{apiKey}:{appName}, in value.stsTokenManagerYesLong-lived, until password change, account disable, or revokeRefreshTokens(uid)

To verify on any signed-in browser, open DevTools → Application → IndexedDB → firebaseLocalStorageDbfirebaseLocalStorage, or programmatically:

const open = indexedDB.open('firebaseLocalStorageDb');
open.onsuccess = (e) => {
e.target.result
.transaction('firebaseLocalStorage', 'readonly')
.objectStore('firebaseLocalStorage')
.getAll().onsuccess = (ev) => console.log(ev.target.result);
};

The IndexedDB API is JavaScript. There is no HttpOnly analogue for it; any code running in the same origin can open the database and read its records. The refresh token is therefore as exposed to XSS as the idToken cookie is not.

With the proposed model, with the same client SDK usage: the cookie value changes (session cookie instead of an ID-token JWT), but the refresh token still lives in IndexedDB on identical terms because the Firebase JS SDK is still being used for sign-in and for currentUser access. The IndexedDB exposure is preserved unless the migration also eliminates client-side Firebase auth state — see §7.5.

7.2 What a stolen refresh token enables

The refresh token is usable directly against the public Firebase secure-token endpoint, no SDK required:

POST https://securetoken.googleapis.com/v1/token?key=[OUR_FIREBASE_API_KEY]
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token=[STOLEN_REFRESH_TOKEN]

The Firebase API key is bundled into the client JavaScript by design — it is not a secret. So an attacker with the refresh token alone can run the exchange from anywhere (their laptop, a VPS, a Lambda) and renew indefinitely. The token is invalidated only by:

  • The user changing their password or email
  • An admin disabling the account
  • Server-side admin.auth().revokeRefreshTokens(uid) being called and the consumer side verifying with checkRevoked: true
  • Explicit sign-out on the device the token was stolen from — note this does not help once the attacker has copied the token off-device

The codebase calls revokeRefreshTokens in the broker portal’s logout (apps/broker-portal/src/api/routes/auth.ts:184) but not in the main portal. So today, signing out of the main portal does not in fact end an attacker’s access if they hold the refresh token.

7.3 Threat model: per-attack comparison

AttackToday’s exposureSession-cookie exposure (with same client SDK usage)
XSS in our appTotal takeover. Refresh token is JS-readable in IndexedDB; attacker exfiltrates it and renews indefinitely against securetoken.googleapis.com.Same total takeover — the session cookie is HttpOnly and not stolen, but the refresh token is still in IndexedDB and still readable.
Malware running as user / infostealerTotal takeover — both the cookie store and IndexedDB live on disk under the user profile, accessible to any process running as the user.Same.
Physical access to unlocked deviceTotal takeover via DevTools — reads IndexedDB and HttpOnly cookies alike.Same.
Network MitMSecure cookie + HTTPS protect cookie traffic. Refresh-token traffic only happens at refresh time, also over HTTPS.Same.
CSRFMitigated by SameSite=Lax (lib/cookies.js:10).Same.
Server log leakageIf idToken cookie surfaces in a log: 1h exposure. Refresh token is not in any server log.If session cookie surfaces in a log: exposure for the configured expiresIn.
Hostile browser extension with cookies + storage permissionsReads cookies and IndexedDB. Total takeover.Same.
Cookie-only exfiltration (extension with cookies permission only, narrow proxy capture, etc.)1h exposure (the leaked JWT self-expires; attacker has no refresh token).Up to TTL.

Key takeaways:

  1. Against XSS — the dominant real-world web threat — today’s setup and the proposed setup are equivalent. Both expose the refresh token to extraction. The “today’s cookie self-expires in 1 hour” framing is not a meaningful defence when the more valuable secret sits next to it in JS-readable storage.
  2. Against malware on the user’s device, the two are equivalent. There is no defence at the cookie layer for code running with the user’s privileges.
  3. Today’s setup is meaningfully safer than session cookies in only one class of scenarios: when an attacker can read cookies but cannot read IndexedDB. That class is narrow — server log leakage and cookie-only browser extensions are the realistic instances.
  4. The session-cookie migration becomes meaningfully safer than today’s setup in only one configuration: when paired with eliminating client-side Firebase auth state (§7.5), which is out of scope for the basic migration.

7.4 Stateless session cookies and the rotation pattern

Independent of the threat-model analysis above, Firebase session cookies have a property worth understanding: they are stateless signed credentials, validated cryptographically. The server has no record of “I issued a newer one, so the older one is now invalid”. Implications for a stolen session cookie:

  • It remains valid for its full configured expiresIn regardless of subsequent activity.
  • If the server rotates the cookie on each request (sliding-window pattern), the rotation does not cap the hijack window: when the attacker uses the stolen cookie, the server validates it (still well-signed, not yet expired) and hands the attacker a fresh cookie. Both the user and the attacker can keep their respective copies “warm” indefinitely. Sliding rotation is therefore not a hijack mitigation, only a UX feature for active users.
  • The cookie cannot be invalidated short of revokeRefreshTokens(uid), and that call only takes effect if verifySessionCookie(cookie, true) is used on the validation side.

Mitigations that actually shorten the hijack window for a stolen session cookie:

  1. Short expiresIn. Picking 1–6 hours brings the worst-case window down at the cost of forcing more frequent re-authentication. A practical compromise (e.g. 12–24 hours) bounds risk while keeping the UX win.
  2. verifySessionCookie(cookie, true) (i.e. checkRevoked: true) on every protected route. Adds a per-request revocation-state lookup against Firebase. This is the only mechanism that lets revokeRefreshTokens actually evict an attacker.
  3. Disciplined revocation triggers. Call revokeRefreshTokens(uid) on logout, password change, custom-claim change, and any anomaly signal (new IP, new user-agent, suspicious activity). Without this, checkRevoked: true is toothless.
  4. Stateful session tracking — abandon createSessionCookie for a session-ID cookie backed by a server-side record, so issuing a new cookie can mark previous ones invalid. This is true session management; it negates the main appeal of stateless session cookies (no per-request DB lookup) and effectively means we are not using Firebase session cookies at all.
  5. Fingerprint binding — couple the cookie to user-agent / IP / device. Practical false-positive rate is high (mobile NAT, IP rotation, network handoff); useful only as a soft signal feeding into anomaly detection.

Cookie-only hijack-window summary:

SetupRealistic window if a cookie alone leaks
Today (1h ID-token JWT in cookie)Up to 1h. Attacker cannot extend without also having the refresh token.
Session cookie, e.g. 7-day TTL, no revocation disciplineUp to 7d, renewable by the attacker — practically unbounded if they keep using it.
Session cookie + checkRevoked: true + revoke on logoutUntil next user-driven trigger (logout / password change), or anomaly detection fires.
Session cookie + 1h TTL + server re-mints per request~1h, similar to today’s flow but with a cleaner revocation API.
Stateful session trackingBounded by rotation generation; needs DB and is no longer Firebase session cookies.

This table is meaningful only against the narrow class of attacks where the cookie leaks but the IndexedDB store does not. Against XSS and against malware, the refresh token in IndexedDB dominates the analysis (§7.3).

The basic migration described in §6 changes the cookie value from a 1-hour ID token to a long-lived session cookie. It does not change where the Firebase JS SDK keeps its own auth state — the refresh token continues to live in IndexedDB and remains JS-readable. Per §7.3, this means the basic migration is roughly security-equivalent to today: it does not reduce hijack resistance, but it does not improve it either. The compelling reason to do it is operational simplicity (Findings 1, 3, 4, 5, 8 become moot).

A second, separate iteration can take the security story from “equivalent” to “materially safer”. The mechanism is the Firebase JS SDK’s setPersistence API:

await firebase.auth().setPersistence(firebase.auth.Auth.Persistence.NONE);
await firebase.auth().signInWithEmailAndPassword(email, password);
const idToken = await firebase.auth().currentUser.getIdToken();
await fetch('/api/login', { method: 'POST', body: JSON.stringify({ idToken }) });
await firebase.auth().signOut(); // also clears the in-memory copy

Persistence.NONE keeps auth state in memory only — no IndexedDB writes at any point. Calling signOut() after the /api/login exchange clears the in-memory copy too. The result: tokens exist in JavaScript reach for the few seconds (or milliseconds) between signInWithEmailAndPassword resolving and signOut resolving, instead of “from sign-in until the user changes their password” as today. This shrinks the XSS exposure window from weeks-or-months to seconds, and closes disk-resident infostealer attacks entirely.

The trade-off — what breaks. After signOut() succeeds, firebase.auth().currentUser is null on every subsequent page load. The Firebase JS SDK is essentially uninitialised from the user’s perspective. Anything in the codebase that today reads currentUser or relies on Firebase SDK auth state has to be refactored to read user data from the session cookie (via a small /api/me endpoint or via SSR props).

Concrete consumers identified in the codebase:

  • pages/_app.js:301 — Sentry user identification (Sentry.setUser({ email }))
  • pages/_app.js:369onIdTokenChanged listener (becomes unnecessary in the session-cookie model anyway)
  • lib/firebase/utils.ts (signOutAndResetAnalytics) — wraps existing sign-out behaviour
  • All firebase.auth().currentUser usages elsewhere — need an audit

The biggest blocker — MFA flows. The codebase has full multi-factor-authentication support under components/Auth/Mfa/ (FactorSelector.js, PhoneChallenge.js, ChallengeFlow.js, etc.). Firebase’s MFA flow depends on a MultiFactorResolver object that holds partial-auth state across multiple SDK calls (initial sign-in throws auth/multi-factor-auth-required with a resolver, the resolver is used to send the SMS code, the user submits it, the resolver completes sign-in). This flow needs at minimum Persistence.SESSION to survive any redirect/navigation during enrollment or challenge — and may not work with Persistence.NONE at all in some configurations. Adopting Persistence.NONE blindly will break MFA enrollment and MFA challenges.

Other affected flows likely to need the SDK’s persistent state:

  • Social login redirect flows (Google, Facebook): the redirect round-trip requires at least SESSION persistence to survive. signInWithPopup is more NONE-friendly than signInWithRedirect, but support varies by provider.
  • Password reset / email verification: Firebase’s applyActionCode and confirmPasswordReset flows can complete without persistent auth state (they take an out-of-band code), but any UX that pre-fills the user’s email from currentUser would break.
  • Re-authentication for sensitive actions (reauthenticateWithCredential): typically needs currentUser and runs immediately before the action, so memory-only persistence is fine within a tab — but if the action involves a redirect, state is lost.

A practical compromise might be a mode-aware persistence policy: SESSION during MFA / social-login flows, then NONE + signOut() once the session cookie is in place. That is more code than a one-line setPersistence(NONE) and warrants careful design.

Why this should be a second iteration, not part of the first.

AspectIteration 1 (basic session-cookie migration)Iteration 2 (Persistence.NONE + signOut())
Security impactEquivalent to today (per §7.3)Materially safer than today against XSS and disk attacks
Operational impactRemoves Findings 1, 3, 4, 5, 8None additional
Code changesReplace verifyIdToken callsites, add /api/login, delete refresh page and code (per §5)Audit and refactor every currentUser consumer; redesign MFA, social login, password reset flows to work without persistent SDK state
RiskLow — well-trodden Firebase patternMedium — touches MFA enrollment / challenge, which is high-stakes UX
ReversibilityPhase-by-phase rollback (§6)Re-enabling persistence is one line, but the consumer refactors stay

Iteration 2 is a meaningful security upgrade but a real engineering project, especially around MFA. Iteration 1 is the operational fix and stands on its own. Sequencing them lets the team ship the immediate refresh-flow improvements without being blocked on the MFA redesign.

If the team wants Iteration 2, it should land in its own ADR with explicit decisions on:

  • The persistence policy during MFA enrollment, MFA challenge, and social-login redirect flows
  • Whether to migrate MFA to Firebase’s newer SMS / TOTP flows that are more amenable to memory-only persistence, or build server-mediated MFA on top of the Admin SDK
  • The shape of the /api/me endpoint and which consumers migrate to it

7.6 Other considerations

Conclusion on hijack resistance. The session-cookie migration is roughly equivalent to today’s setup against XSS and malware (the dominant threats), better in some narrow scenarios (claim revocation, anomaly-driven sign-out), and worse in others (cookie-only exfiltration, log leakage) unless checkRevoked: true and disciplined revocation triggers are wired in. Without those, it is a regression in the cookie-only-exfiltration class. With them, it is a wash with today against the major threats and a modest improvement against the minor ones. The compelling reason to migrate is the simplification of the refresh flow, not a security upgrade — the security story should be presented as “no worse, with mitigations” rather than “better”.

Single point of validation, single point of failure. Today, even if set-auth is broken, the listener-driven path keeps things alive in browser memory. With session cookies, every protected request runs verifySessionCookie against the Admin SDK. Verification is local (the cookie is signed; signature check uses Firebase’s public keys, which the SDK fetches and caches). It is not a network dependency per request — but the public-key cache still needs to refresh periodically, same as today’s verifyIdToken callsites.

Hosting platform alignment. Firebase Hosting standardises on a __session cookie name (see manage-cookies doc); other platforms have no such convention. If we ever deploy under Firebase Hosting (not the case today), the conventional name avoids friction. Otherwise the cookie name is our choice.

Custom claims propagation — material product-behaviour regression without checkRevoked: true. When a user’s role / org / permission claims change, today’s flow propagates the change within an hour because the client mints a new ID token within the hour. With a 7-day session cookie and no checkRevoked: true, claim changes are not visible to the server until the cookie is re-minted or the user signs out and back in — the user can keep using stale-claim authority for the full TTL. For a multi-tenant rental product where role and permission changes are core operational behaviour (granting / revoking access, deactivating users, switching tenants), this is a material regression. checkRevoked: true on every protected route, paired with revokeRefreshTokens(uid) on every server-side claim change, is non-optional for any path that gates on claims — the cost (one Firestore-side lookup per request) is well worth the avoided regression. The broker portal already uses a similar pattern for org-status changes (apps/broker-portal/src/api/middleware/broker-auth.ts:91 re-issues the cookie when orgStatus/role changes in DB); the equivalent discipline must extend to the main portal.

CSRF surface. Both today’s idToken cookie and a future session cookie are HttpOnly, sent automatically by the browser on cross-site requests, and so subject to the same CSRF threat model. The current setup mitigates with SameSite=Lax (lib/cookies.js:10), which is sufficient for the GET/idempotent surface and for top-level navigation but does not prevent state-changing POSTs from same-origin pages that have been compromised. Migrating to session cookies does not change this exposure, but it also does not improve it — a long-lived session cookie is just as attackable via CSRF as today’s 1-hour ID-token cookie. For Iteration 1 the practical mitigation is unchanged: keep SameSite=Lax, ensure all state-changing requests go through GraphQL mutations (which require an Authorization or other custom header that CORS prevents from being forged cross-site), and audit any plain-form POST endpoints that might rely on cookie auth alone. A double-submit token would be the standard reinforcement if the audit surfaces gaps; flag it in §8.

Firebase v8 SDK constraint. The Admin SDK methods involved (createSessionCookie, verifySessionCookie, revokeRefreshTokens) are stable in firebase-admin: ^9.11.0. The client-side SDK version (Finding 7 of the findings doc — currently firebase: 8.10.1, EOL) is largely independent of this proposal because the client side gets simpler, not more complex. Note on scope: the v8→v9+ migration is bigger than “separate workstream” implies — the codebase has roughly 70 v8 namespace usages (firebase.auth(), firebase.app(), etc.) across pages, components, hooks, and lib modules. That’s a multi-week project on its own and would be its own ADR; mentioning here only so neither this migration nor that one is sized as if it could ride along with the other.

Testing. Each migration phase should ship with end-to-end tests that exercise: fresh sign-in writes the right cookie, request with valid session cookie succeeds, request with expired cookie redirects to /sign-in, logout clears cookie and revokes tokens, custom-claim change becomes effective on next request, and (for Phase 2 specifically) a stale idToken cookie does not shadow a freshly-minted session cookie.


8. Open questions

  • Target expiresIn: needs to be chosen jointly with the revocation strategy and the idle-user trade-off. Options: ~1 hour (security parity with today, modest UX gain), 12–24 hours (balanced), 7 days (UX win, requires checkRevoked: true + logout-driven revocation to be safe). A 14-day TTL has no realistic safe configuration without anomaly-driven revocation. Note that any TTL forces a hard logout for users idle through the entire window (§3 step 5 caveat); pick a TTL where “idle for the full window” is rare for the actual user population.
  • Where to enable checkRevoked: true. Per the strengthened claim-propagation note in §7.6, the practical answer is everywhere for a multi-tenant product — slower-revocation read paths produce stale-claim regressions. Confirm whether any high-volume read endpoint cannot afford the per-request lookup cost; if not, default to enabling everywhere.
  • CSRF audit: enumerate every state-changing endpoint that uses cookie auth alone (i.e. not behind a GraphQL mutation with a custom header). If any exist, decide whether SameSite=Lax plus the existing CORS configuration is sufficient or whether a double-submit token is needed.
  • Do we want to roll the same cookie out across the broker portal in parallel (it has its own JWT-based session — see apps/broker-portal/src/lib/auth/broker-session.ts), or keep them separate?
  • Do we want to run the migration in a single commit window, or feature-flag it per tenant?
  • Should the cookie name be __session (Firebase convention) or kept as idToken for continuity? Either is technically fine.

9. Summary

The migration is best treated as two independent iterations:

Iteration 1 — basic session-cookie migration. createSessionCookie is the Firebase-native answer to the client-side-scheduling problem the findings doc characterises. Adopting it makes Findings 1, 3, 4, 5, and 8 structurally moot and simplifies Finding 2. The compelling reason is operational simplicity. On security, this iteration is equivalent to today: both setups expose the long-lived refresh token to JavaScript via IndexedDB, so against XSS and against malware they are roughly the same. With checkRevoked: true plus disciplined revocation triggers (Phase 4), it is a wash against major threats and a modest improvement against narrow ones. It does not reduce current security level — the only thing that changes from a security standpoint is that the cookie value is opaque instead of a JWT, and the revocation API becomes usable. Cleared as a low-risk operational improvement; can ship on its own.

Iteration 2 — Persistence.NONE + signOut() after cookie exchange. This is what makes session cookies a material security upgrade. Setting persistence to NONE and calling signOut() after /api/login reduces the XSS exposure window from “weeks or months” to “the seconds between sign-in and sign-out”, and closes disk-resident infostealer attacks entirely. The cost is real engineering: every currentUser consumer has to migrate to a session-cookie-backed /api/me endpoint, and — critically — the codebase’s MFA flows (components/Auth/Mfa/) depend on Firebase JS SDK persistent state and would break under naive Persistence.NONE. Iteration 2 needs its own ADR covering the persistence policy during MFA / social-login flows. Recommended to defer until Iteration 1 has shipped.

Iteration 1 is feasible in four phases with rollback at each step, on the condition that Phase 4 (revocation discipline) ships together with Phase 3 (cut-over). A separate decision is required on the target expiresIn, which should be chosen alongside the revocation strategy rather than independently.

If the team wants to proceed with Iteration 1, this document should be promoted to docs/adr/ and rewritten into ADR format with an explicit decision. Iteration 2 should be drafted as a follow-up ADR once Iteration 1 is in production.