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:
- Client calls
firebase.auth().currentUser.getIdToken(...)to obtain a 1-hour JWT. - Client posts that JWT to
pages/api/set-auth.js. - Server validates with
admin.auth().verifyIdToken(idToken)and writes it into anHttpOnlycookie namedidTokenwith a 30-day expiry. - Every subsequent request reads the cookie; protected handlers re-run
verifyIdTokenper request. - 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
onIdTokenChangedlistener, thesetIntervalfallback, or the reactive/token-refreshpage.
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:
- Client calls
getIdToken()exactly once and posts it to a server endpoint (e.g./api/login). - Server validates the ID token, then calls
createSessionCookie(idToken, { expiresIn })to mint the session cookie. - Server writes the session cookie into an
HttpOnlycookie. From this point on, the client never touches the cookie. - Every subsequent request reads the cookie; protected handlers validate it with
verifySessionCookie. No 1-hour expiry concerns. - 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-Cookieheader 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/pingroute, undoing some of the simplification this migration was meant to deliver). Worth surfacing in the open questions before picking a TTL. - Sign-out: client calls a server endpoint (e.g.
/api/logout) that clears the cookie, and optionally callsadmin.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
| Aspect | Today (ID-token cookie) | With session cookies |
|---|---|---|
| Cookie content | 1-hour Firebase ID token JWT | Opaque Firebase-signed session credential |
| Cookie TTL | 30 days (declarative) but real validity = 1 hour (JWT) | Up to 14 days, real validity = TTL |
| Who rotates the cookie | Client, by re-posting a fresh ID token | Server, autonomously |
| Refresh trigger needed on the client | Yes — listener, interval, reactive page | No |
| Reactive recovery on stale cookie | Yes — /token-refresh page | Not needed in normal flow |
| Server-side validation API | verifyIdToken | verifySessionCookie |
| Revocation | revokeRefreshTokens + 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 tab | Breaks (Finding 3) | Not affected — server validates against its own cookie |
| Sensitivity to client clock | High (Finding 1 cause #1) | None |
Sensitivity to setInterval throttling | High (Finding 3) | None |
| Bundle weight | Today’s | Same — no new client SDK needed |
| Hijack risk window if a cookie alone leaks | 1 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 for | Right server action today | Right server action with session cookies |
|---|---|---|
| User just signed in | Mint and write idToken cookie | Mint a session cookie via /api/login |
| ID token rotated (background SDK refresh) | Write the new idToken cookie | Nothing — 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-inhooks/auth/useMfaChallenge.js:81— MFA challenge completion (mfaResolver.resolveSignIn)pages/ops/account/create.js:283— account creation flowapps/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 logoutcomponents/Deactivated/index.js:47— when account is disabledcomponents/pages/Settings/SecurityTab/MfaStatus/EnableButton.js:50components/pages/Settings/SecurityTab/MfaStatus/DisableButton.js:60pages/_app.js:335,517,661,667— error-driven sign-outspages/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.js→ deleted. Replaced by two new endpoints:pages/api/login.js— one-shot exchange (ID token in, session cookie out viacreateSessionCookie).pages/api/logout.js— clears the session cookie viaSet-Cookiewith past expiry, then callsadmin.auth().revokeRefreshTokens(uid).
pages/token-refresh.js→ deleted. Reactive recovery page is no longer needed.pages/_app.js’ssetAuthmethod,setAuthInterval, theintervalfield, and theonIdTokenChangedlistener’s call intosetAuth→ removed. 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.js→ deleted.- The
/CODE_407/and/Firebase ID token/regex branches inpages/_app.js:654→ simplified. 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-refreshpage 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:9server/graphql/helpers/auth/isAuthenticated.ts:29server/graphql/resolvers/mutation/updateUserAccount.resolver.js:45server/graphql/resolvers/mutation/accessSensitiveMedia/accessSensitiveMedia.resolver.ts:42pages/api/set-auth.js:23(deleted in this migration; listed for completeness)pages/api/login-client.js:30pages/api/login-user.js:31pages/api/change-password.js:22pages/api/public/export/companies.js:124pages/api/public/export/companies-v2.js:52pages/api/public/broker/verify-admin.ts:26pages/api/company-signature/utils.ts:267pages/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.__sessionper Firebase Hosting convention, or keepidTokenif we do not deploy on Firebase Hosting). During Phase 2 the read order must be__sessionfirst, then fall back toidToken, never the reverse — otherwise a staleidTokencookie 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)forawait signOutAndClearSession(firebase).lib/firebase/utils.tseither 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.jsminting the session cookie (cookie name TBD per §8). The endpoint validates the supplied ID token withverifyIdToken, then callscreateSessionCookie(idToken, { expiresIn })and writes the result as anHttpOnlycookie. - Implement
pages/api/logout.jsclearing the cookie and callingadmin.auth().revokeRefreshTokens(uid). - Implement
verifySessionCookiehelper alongside the existingverifyIdTokencallsites. Do not switch any consumers yet. - Add the two helpers (
completeSignInWithSessionCookieandsignOutAndClearSession) inlib/firebase/session-cookie.ts(or extendlib/firebase/utils.ts). - No call-site changes yet.
idTokencookie +set-authcontinue 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 —idTokenby 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 ofsignOutAndResetAnalytics(firebase). - Switch the GraphQL context, broker auth, and per-resolver checks to read the session cookie first, falling back to
idTokenfor users mid-session. New sign-ins now produce both cookies; existing sessions continue withidTokenuntil 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
idTokenfallback in the read paths and delete:pages/api/set-auth.jspages/token-refresh.jspages/_app.js’ssetAuth,setAuthInterval,intervalfield, the listener’s call intosetAuth, and the/CODE_407///Firebase ID token/reactive branches at line 654 (simplify to a hard 401 →/sign-inredirect)lib/calculateMinutesToRefresh.js
- The
onIdTokenChangedlistener in_app.jsis reduced to non-cookie work (Sentry user identification, redirecting unauthenticated users).
Phase 4 — Revocation discipline (must ship with Phase 3)
- Enable
checkRevoked: trueonverifySessionCookiefor 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/logoutactually callsrevokeRefreshTokens(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:
| Secret | Storage | JS-readable? | Effective TTL |
|---|---|---|---|
idToken (1-hour Firebase JWT) | HttpOnly cookie named idToken | No | 30 days as a cookie, but the JWT inside expires every hour |
| Refresh token, plus a copy of the current ID token | IndexedDB → firebaseLocalStorageDb → firebaseLocalStorage, key firebase:authUser:{apiKey}:{appName}, in value.stsTokenManager | Yes | Long-lived, until password change, account disable, or revokeRefreshTokens(uid) |
To verify on any signed-in browser, open DevTools → Application → IndexedDB → firebaseLocalStorageDb → firebaseLocalStorage, 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 withcheckRevoked: 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
| Attack | Today’s exposure | Session-cookie exposure (with same client SDK usage) |
|---|---|---|
| XSS in our app | Total 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 / infostealer | Total 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 device | Total takeover via DevTools — reads IndexedDB and HttpOnly cookies alike. | Same. |
| Network MitM | Secure cookie + HTTPS protect cookie traffic. Refresh-token traffic only happens at refresh time, also over HTTPS. | Same. |
| CSRF | Mitigated by SameSite=Lax (lib/cookies.js:10). | Same. |
| Server log leakage | If 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 permissions | Reads 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:
- 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.
- 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.
- 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.
- 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
expiresInregardless 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 ifverifySessionCookie(cookie, true)is used on the validation side.
Mitigations that actually shorten the hijack window for a stolen session cookie:
- 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. 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 letsrevokeRefreshTokensactually evict an attacker.- 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: trueis toothless. - Stateful session tracking — abandon
createSessionCookiefor 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. - 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:
| Setup | Realistic 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 discipline | Up to 7d, renewable by the attacker — practically unbounded if they keep using it. |
Session cookie + checkRevoked: true + revoke on logout | Until 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 tracking | Bounded 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).
7.5 Eliminating client-side Firebase auth state (recommended as a second iteration)
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 copyPersistence.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:369—onIdTokenChangedlistener (becomes unnecessary in the session-cookie model anyway)lib/firebase/utils.ts(signOutAndResetAnalytics) — wraps existing sign-out behaviour- All
firebase.auth().currentUserusages 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
SESSIONpersistence to survive.signInWithPopupis moreNONE-friendly thansignInWithRedirect, but support varies by provider. - Password reset / email verification: Firebase’s
applyActionCodeandconfirmPasswordResetflows can complete without persistent auth state (they take an out-of-band code), but any UX that pre-fills the user’s email fromcurrentUserwould break. - Re-authentication for sensitive actions (
reauthenticateWithCredential): typically needscurrentUserand 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.
| Aspect | Iteration 1 (basic session-cookie migration) | Iteration 2 (Persistence.NONE + signOut()) |
|---|---|---|
| Security impact | Equivalent to today (per §7.3) | Materially safer than today against XSS and disk attacks |
| Operational impact | Removes Findings 1, 3, 4, 5, 8 | None additional |
| Code changes | Replace 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 |
| Risk | Low — well-trodden Firebase pattern | Medium — touches MFA enrollment / challenge, which is high-stakes UX |
| Reversibility | Phase-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/meendpoint 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, requirescheckRevoked: 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=Laxplus 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 asidTokenfor 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.