Repository: firebase/firebase-js-sdk
Pull request #8839: Auth cookie persistence
Author: login: jamesdaniels
Created: 2025-03-10T04:41:55Z | Merged: 2025-03-27T21:53:59Z

[go/firebase-auth-cookie-persistence](https://goto2.corp.google.com/firebase-auth-cookie-persistence), pair with [this NextJS middleware](https://github.com/FirebaseExtended/reactfire/pull/640/files). 

[Demo here](https://nextjs-ssr-auth-demo-ts--web-frameworks-e2e.us-central1.hosted.app/)—login restricted to `@google.com` Google accounts.

[go/firebase-cookie-auth-bug-bash](http://goto.google.com/firebase-cookie-auth-bug-bash) for bug bash instructions / feedback.

Principles of operation:

1. New persistence option `COOKIE`
1. When persistence is cookie, sign-in and token refresh requests are proxied to `/__cookie__`, which is handled by the NextJS middleware. The middleware this redacts the refreshToken and stores it in an HTTP-only cookie, the idToken is stored in an JS-readable cookie
1. `getCurrentUser()` internally can accept an idToken and will initiated a fetch request to get the user-details
1. The NextJS middleware can itself freshen the idToken, if it's expired on a full-page load
1. During logout the client will set the JS-readable cookie to `""` and will make a best-effort attempt to hit `/__cookie__`, the middleware treats the blank string as a logout and will delete the refreshToken cookie if seen

There's a lot to clean up here, some high level things that need to be addressed

- [x] Types
- [x] Fix tests
- [x] The cookie persistence class depends on cookieStore, we need to fall back to navigator.cookie
- [x] Need to test with other sign in methods
- [x] Sanitize the cookie names
- [x] Use sentinel value for logout
- [x] Confirm functionality in other UAs
- [x] Double check this functions correctly with diff tenants


Diff:
diff --git a/.changeset/orange-turtles-taste.md b/.changeset/orange-turtles-taste.md
new file mode 100644
index 0000000000..2787a28031
--- /dev/null
+++ b/.changeset/orange-turtles-taste.md
@@ -0,0 +1,8 @@
+---
+'firebase': minor
+'@firebase/auth': minor
+---
+
+Adding `Persistence.COOKIE` a new persistence method backed by cookies. The
+`browserCookiePersistence` implementation is designed to be used in conjunction with middleware that
+ensures both your front and backend authentication state remains synchronized.
diff --git a/common/api-review/auth.api.md b/common/api-review/auth.api.md
index 7ec0db38bd..0c9625a90e 100644
--- a/common/api-review/auth.api.md
+++ b/common/api-review/auth.api.md
@@ -258,6 +258,9 @@ export interface AuthSettings {
 // @public
 export function beforeAuthStateChanged(auth: Auth, callback: (user: User | null) => void | Promise<void>, onAbort?: () => void): Unsubscribe;
 
+// @beta
+export const browserCookiePersistence: Persistence;
+
 // @public
 export const browserLocalPersistence: Persistence;
 
@@ -596,7 +599,7 @@ export interface PasswordValidationStatus {
 
 // @public
 export interface Persistence {
-    readonly type: 'SESSION' | 'LOCAL' | 'NONE';
+    readonly type: 'SESSION' | 'LOCAL' | 'NONE' | 'COOKIE';
 }
 
 // @public
diff --git a/docs-devsite/auth.md b/docs-devsite/auth.md
index 82f8a3dc19..1b3938ef4e 100644
--- a/docs-devsite/auth.md
+++ b/docs-devsite/auth.md
@@ -150,6 +150,7 @@ Firebase Authentication
 |  --- | --- |
 |  [ActionCodeOperation](./auth.md#actioncodeoperation) | An enumeration of the possible email action types. |
 |  [AuthErrorCodes](./auth.md#autherrorcodes) | A map of potential <code>Auth</code> error codes, for easier comparison with errors thrown by the SDK. |
+|  [browserCookiePersistence](./auth.md#browsercookiepersistence) | <b><i>(Public Preview)</i></b> An implementation of [Persistence](./auth.persistence.md#persistence_interface) of type <code>COOKIE</code>, for use on the client side in applications leveraging hybrid rendering and middleware. |
 |  [browserLocalPersistence](./auth.md#browserlocalpersistence) | An implementation of [Persistence](./auth.persistence.md#persistence_interface) of type <code>LOCAL</code> using <code>localStorage</code> for the underlying storage. |
 |  [browserPopupRedirectResolver](./auth.md#browserpopupredirectresolver) | An implementation of [PopupRedirectResolver](./auth.popupredirectresolver.md#popupredirectresolver_interface) suitable for browser based applications. |
 |  [browserSessionPersistence](./auth.md#browsersessionpersistence) | An implementation of [Persistence](./auth.persistence.md#persistence_interface) of <code>SESSION</code> using <code>sessionStorage</code> for the underlying storage. |
@@ -1960,6 +1961,21 @@ AUTH_ERROR_CODES_MAP_DO_NOT_USE_INTERNALLY: {
 }
 ```
 
+## browserCookiePersistence
+
+> This API is provided as a preview for developers and may change based on feedback that we receive. Do not use this API in a production environment.
+> 
+
+An implementation of [Persistence](./auth.persistence.md#persistence_interface) of type `COOKIE`<!-- -->, for use on the client side in applications leveraging hybrid rendering and middleware.
+
+This persistence method requires companion middleware to function, such as that provided by [ReactFire](https://firebaseopensource.com/projects/firebaseextended/reactfire/) for NextJS.
+
+<b>Signature:</b>
+
+```typescript
+browserCookiePersistence: Persistence
+```
+
 ## browserLocalPersistence
 
 An implementation of [Persistence](./auth.persistence.md#persistence_interface) of type `LOCAL` using `localStorage` for the underlying storage.
diff --git a/docs-devsite/auth.persistence.md b/docs-devsite/auth.persistence.md
index b3f9ecb11e..8e0a5c3523 100644
--- a/docs-devsite/auth.persistence.md
+++ b/docs-devsite/auth.persistence.md
@@ -22,14 +22,14 @@ export interface Persistence
 
 |  Property | Type | Description |
 |  --- | --- | --- |
-|  [type](./auth.persistence.md#persistencetype) | 'SESSION' \| 'LOCAL' \| 'NONE' | Type of Persistence. - 'SESSION' is used for temporary persistence such as <code>sessionStorage</code>. - 'LOCAL' is used for long term persistence such as <code>localStorage</code> or <code>IndexedDB</code>. - 'NONE' is used for in-memory, or no persistence. |
+|  [type](./auth.persistence.md#persistencetype) | 'SESSION' \| 'LOCAL' \| 'NONE' \| 'COOKIE' | Type of Persistence. - 'SESSION' is used for temporary persistence such as <code>sessionStorage</code>. - 'LOCAL' is used for long term persistence such as <code>localStorage</code> or <code>IndexedDB</code>. - 'NONE' is used for in-memory, or no persistence. - 'COOKIE' is used for cookie persistence, useful for server-side rendering. |
 
 ## Persistence.type
 
-Type of Persistence. - 'SESSION' is used for temporary persistence such as `sessionStorage`<!-- -->. - 'LOCAL' is used for long term persistence such as `localStorage` or `IndexedDB`<!-- -->. - 'NONE' is used for in-memory, or no persistence.
+Type of Persistence. - 'SESSION' is used for temporary persistence such as `sessionStorage`<!-- -->. - 'LOCAL' is used for long term persistence such as `localStorage` or `IndexedDB`<!-- -->. - 'NONE' is used for in-memory, or no persistence. - 'COOKIE' is used for cookie persistence, useful for server-side rendering.
 
 <b>Signature:</b>
 
 ```typescript
-readonly type: 'SESSION' | 'LOCAL' | 'NONE';
+readonly type: 'SESSION' | 'LOCAL' | 'NONE' | 'COOKIE';
 ```
diff --git a/packages/auth-compat/src/auth.test.ts b/packages/auth-compat/src/auth.test.ts
index 4dee1e4f29..c2e73ea5df 100644
--- a/packages/auth-compat/src/auth.test.ts
+++ b/packages/auth-compat/src/auth.test.ts
@@ -65,7 +65,7 @@ describe('auth compat', () => {
     it('saves the persistence into session storage if available', async () => {
       if (typeof self !== 'undefined') {
         underlyingAuth._initializationPromise = Promise.resolve();
-        sinon.stub(underlyingAuth, '_getPersistence').returns('TEST');
+        sinon.stub(underlyingAuth, '_getPersistenceType').returns('TEST');
         sinon
           .stub(underlyingAuth, '_initializationPromise')
           .value(Promise.resolve());
@@ -97,7 +97,7 @@ describe('auth compat', () => {
           }
         } as unknown as Window);
         const setItemSpy = sinon.spy(sessionStorage, 'setItem');
-        sinon.stub(underlyingAuth, '_getPersistence').returns('TEST');
+        sinon.stub(underlyingAuth, '_getPersistenceType').returns('TEST');
         sinon
           .stub(underlyingAuth, '_initializationPromise')
           .value(Promise.resolve());
diff --git a/packages/auth-compat/src/persistence.ts b/packages/auth-compat/src/persistence.ts
index c3f046828d..3c839823a7 100644
--- a/packages/auth-compat/src/persistence.ts
+++ b/packages/auth-compat/src/persistence.ts
@@ -91,7 +91,7 @@ export async function _savePersistenceForRedirect(
     auth.name
   );
   if (session) {
-    session.setItem(key, auth._getPersistence());
+    session.setItem(key, auth._getPersistenceType());
   }
 }
 
diff --git a/packages/auth/index.ts b/packages/auth/index.ts
index df67fd1616..95e2f453f1 100644
--- a/packages/auth/index.ts
+++ b/packages/auth/index.ts
@@ -43,6 +43,7 @@ export * from './src';
 
 // persistence
 import { browserLocalPersistence } from './src/platform_browser/persistence/local_storage';
+import { browserCookiePersistence } from './src/platform_browser/persistence/cookie_storage';
 import { browserSessionPersistence } from './src/platform_browser/persistence/session_storage';
 import { indexedDBLocalPersistence } from './src/platform_browser/persistence/indexed_db';
 
@@ -83,6 +84,7 @@ import { getAuth } from './src/platform_browser';
 
 export {
   browserLocalPersistence,
+  browserCookiePersistence,
   browserSessionPersistence,
   indexedDBLocalPersistence,
   PhoneAuthProvider,
diff --git a/packages/auth/package.json b/packages/auth/package.json
index cfa5c0d5f4..a593797386 100644
--- a/packages/auth/package.json
+++ b/packages/auth/package.json
@@ -136,6 +136,7 @@
     "@rollup/plugin-strip": "2.1.0",
     "@types/express": "4.17.21",
     "chromedriver": "119.0.1",
+    "cookie-store": "4.0.0-next.4",
     "rollup": "2.79.2",
     "rollup-plugin-sourcemaps": "0.6.3",
     "rollup-plugin-typescript2": "0.36.0",
diff --git a/packages/auth/src/api/authentication/token.ts b/packages/auth/src/api/authentication/token.ts
index 06342c4c63..6646321fbe 100644
--- a/packages/auth/src/api/authentication/token.ts
+++ b/packages/auth/src/api/authentication/token.ts
@@ -74,7 +74,7 @@ export async function requestStsToken(
           'refresh_token': refreshToken
         }).slice(1);
         const { tokenApiHost, apiKey } = auth.config;
-        const url = _getFinalTarget(
+        const url = await _getFinalTarget(
           auth,
           tokenApiHost,
           Endpoint.TOKEN,
diff --git a/packages/auth/src/api/index.test.ts b/packages/auth/src/api/index.test.ts
index 11070509d7..ea11af59d0 100644
--- a/packages/auth/src/api/index.test.ts
+++ b/packages/auth/src/api/index.test.ts
@@ -509,17 +509,17 @@ describe('api/_performApiRequest', () => {
   });
 
   context('_getFinalTarget', () => {
-    it('works properly with a non-emulated environment', () => {
-      expect(_getFinalTarget(auth, 'host', '/path', 'query=test')).to.eq(
+    it('works properly with a non-emulated environment', async () => {
+      expect(await _getFinalTarget(auth, 'host', '/path', 'query=test')).to.eq(
         'mock://host/path?query=test'
       );
     });
 
-    it('works properly with an emulated environment', () => {
+    it('works properly with an emulated environment', async () => {
       (auth.config as ConfigInternal).emulator = {
         url: 'http://localhost:5000/'
       };
-      expect(_getFinalTarget(auth, 'host', '/path', 'query=test')).to.eq(
+      expect(await _getFinalTarget(auth, 'host', '/path', 'query=test')).to.eq(
         'http://localhost:5000/host/path?query=test'
       );
     });
diff --git a/packages/auth/src/api/index.ts b/packages/auth/src/api/index.ts
index 4813ace950..769a1b6acc 100644
--- a/packages/auth/src/api/index.ts
+++ b/packages/auth/src/api/index.ts
@@ -31,6 +31,8 @@ import { AuthInternal, ConfigInternal } from '../model/auth';
 import { IdTokenResponse, TaggedWithTokenResponse } from '../model/id_token';
 import { IdTokenMfaResponse } from './authentication/mfa';
 import { SERVER_ERROR_MAP, ServerError, ServerErrorMap } from './errors';
+import { PersistenceType } from '../core/persistence';
+import { CookiePersistence } from '../platform_browser/persistence/cookie_storage';
 
 export const enum HttpMethod {
   POST = 'POST',
@@ -73,6 +75,15 @@ export const enum Endpoint {
   REVOKE_TOKEN = '/v2/accounts:revokeToken'
 }
 
+const CookieAuthProxiedEndpoints: string[] = [
+  Endpoint.SIGN_IN_WITH_CUSTOM_TOKEN,
+  Endpoint.SIGN_IN_WITH_EMAIL_LINK,
+  Endpoint.SIGN_IN_WITH_IDP,
+  Endpoint.SIGN_IN_WITH_PASSWORD,
+  Endpoint.SIGN_IN_WITH_PHONE_NUMBER,
+  Endpoint.TOKEN
+];
+
 export const enum RecaptchaClientType {
   WEB = 'CLIENT_TYPE_WEB',
   ANDROID = 'CLIENT_TYPE_ANDROID',
@@ -167,7 +178,7 @@ export async function _performApiRequest<T, V>(
     }
 
     return FetchProvider.fetch()(
-      _getFinalTarget(auth, auth.config.apiHost, path, query),
+      await _getFinalTarget(auth, auth.config.apiHost, path, query),
       fetchArgs
     );
   });
@@ -257,19 +268,34 @@ export async function _performSignInRequest<T, V extends IdTokenResponse>(
   return serverResponse as V;
 }
 
-export function _getFinalTarget(
+export async function _getFinalTarget(
   auth: Auth,
   host: string,
   path: string,
   query: string
-): string {
+): Promise<string> {
   const base = `${host}${path}?${query}`;
 
-  if (!(auth as AuthInternal).config.emulator) {
-    return `${auth.config.apiScheme}://${base}`;
+  const authInternal = auth as AuthInternal;
+  const finalTarget = authInternal.config.emulator
+    ? _emulatorUrl(auth.config as ConfigInternal, base)
+    : `${auth.config.apiScheme}://${base}`;
+
+  // Cookie auth works by MiTMing the signIn and token endpoints from the developer's backend,
+  // saving the idToken and refreshToken into cookies, and then redacting the refreshToken
+  // from the response
+  if (CookieAuthProxiedEndpoints.includes(path)) {
+    // Persistence manager is async, we need to await it. We can't just wait for auth initialized
+    // here since auth initialization calls this function.
+    await authInternal._persistenceManagerAvailable;
+    if (authInternal._getPersistenceType() === PersistenceType.COOKIE) {
+      const cookiePersistence =
+        authInternal._getPersistence() as CookiePersistence;
+      return cookiePersistence._getFinalTarget(finalTarget).toString();
+    }
   }
 
-  return _emulatorUrl(auth.config as ConfigInternal, base);
+  return finalTarget;
 }
 
 export function _parseEnforcementState(
diff --git a/packages/auth/src/core/auth/auth_impl.ts b/packages/auth/src/core/auth/auth_impl.ts
index 45a2c99ea0..4a71870211 100644
--- a/packages/auth/src/core/auth/auth_impl.ts
+++ b/packages/auth/src/core/auth/auth_impl.ts
@@ -120,6 +120,10 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
   _tenantRecaptchaConfigs: Record<string, RecaptchaConfig> = {};
   _projectPasswordPolicy: PasswordPolicyInternal | null = null;
   _tenantPasswordPolicies: Record<string, PasswordPolicyInternal> = {};
+  _resolvePersistenceManagerAvailable:
+    | ((value: void | PromiseLike<void>) => void)
+    | undefined = undefined;
+  _persistenceManagerAvailable: Promise<void>;
   readonly name: string;
 
   // Tracks the last notified UID for state change listeners to prevent
@@ -139,6 +143,11 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
   ) {
     this.name = app.name;
     this.clientVersion = config.sdkClientVersion;
+    // TODO(jamesdaniels) explore less hacky way to do this, cookie authentication needs
+    // persistenceMananger to be available. see _getFinalTarget for more context
+    this._persistenceManagerAvailable = new Promise<void>(
+      resolve => (this._resolvePersistenceManagerAvailable = resolve)
+    );
   }
 
   _initializeWithPersistence(
@@ -160,6 +169,7 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
         this,
         persistenceHierarchy
       );
+      this._resolvePersistenceManagerAvailable?.();
 
       if (this._deleted) {
         return;
@@ -524,10 +534,14 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
     }
   }
 
-  _getPersistence(): string {
+  _getPersistenceType(): string {
     return this.assertedPersistence.persistence.type;
   }
 
+  _getPersistence(): PersistenceInternal {
+    return this.assertedPersistence.persistence;
+  }
+
   _updateErrorMap(errorMap: AuthErrorMap): void {
     this._errorFactory = new ErrorFactory<AuthErrorCode, AuthErrorParams>(
       'auth',
diff --git a/packages/auth/src/core/auth/initialize.test.ts b/packages/auth/src/core/auth/initialize.test.ts
index 5ca5fa6eb5..f2d4d24c88 100644
--- a/packages/auth/src/core/auth/initialize.test.ts
+++ b/packages/auth/src/core/auth/initialize.test.ts
@@ -170,7 +170,7 @@ describe('core/auth/initialize', () => {
         sdkClientVersion: expectedSdkClientVersion,
         tokenApiHost: 'securetoken.googleapis.com'
       });
-      expect(auth._getPersistence()).to.eq('NONE');
+      expect(auth._getPersistenceType()).to.eq('NONE');
     });
 
     it('should set persistence', async () => {
@@ -179,7 +179,7 @@ describe('core/auth/initialize', () => {
       }) as AuthInternal;
       await auth._initializationPromise;
 
-      expect(auth._getPersistence()).to.eq('SESSION');
+      expect(auth._getPersistenceType()).to.eq('SESSION');
     });
 
     it('should set persistence with fallback', async () => {
@@ -188,7 +188,7 @@ describe('core/auth/initialize', () => {
       }) as AuthInternal;
       await auth._initializationPromise;
 
-      expect(auth._getPersistence()).to.eq('SESSION');
+      expect(auth._getPersistenceType()).to.eq('SESSION');
     });
 
     it('should set resolver', async () => {
diff --git a/packages/auth/src/core/persistence/index.ts b/packages/auth/src/core/persistence/index.ts
index 5f3db8f705..5d66584422 100644
--- a/packages/auth/src/core/persistence/index.ts
+++ b/packages/auth/src/core/persistence/index.ts
@@ -19,7 +19,8 @@ import { Persistence } from '../../model/public_types';
 export const enum PersistenceType {
   SESSION = 'SESSION',
   LOCAL = 'LOCAL',
-  NONE = 'NONE'
+  NONE = 'NONE',
+  COOKIE = 'COOKIE'
 }
 
 export type PersistedBlob = Record<string, unknown>;
diff --git a/packages/auth/src/core/persistence/persistence_user_manager.ts b/packages/auth/src/core/persistence/persistence_user_manager.ts
index 7aa651c511..580aaad3b2 100644
--- a/packages/auth/src/core/persistence/persistence_user_manager.ts
+++ b/packages/auth/src/core/persistence/persistence_user_manager.ts
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 
+import { getAccountInfo } from '../../api/account_management/account';
 import { ApiKey, AppName, AuthInternal } from '../../model/auth';
 import { UserInternal } from '../../model/user';
 import { PersistedBlob, PersistenceInternal } from '../persistence';
@@ -66,8 +67,22 @@ export class PersistenceUserManager {
   }
 
   async getCurrentUser(): Promise<UserInternal | null> {
-    const blob = await this.persistence._get<PersistedBlob>(this.fullUserKey);
-    return blob ? UserImpl._fromJSON(this.auth, blob) : null;
+    const blob = await this.persistence._get<PersistedBlob | string>(
+      this.fullUserKey
+    );
+    if (!blob) {
+      return null;
+    }
+    if (typeof blob === 'string') {
+      const response = await getAccountInfo(this.auth, { idToken: blob }).catch(
+        () => undefined
+      );
+      if (!response) {
+        return null;
+      }
+      return UserImpl._fromGetAccountInfoResponse(this.auth, response, blob);
+    }
+    return UserImpl._fromJSON(this.auth, blob);
   }
 
   removeCurrentUser(): Promise<void> {
@@ -140,9 +155,24 @@ export class PersistenceUserManager {
     // persistence, we will (but only if that persistence supports migration).
     for (const persistence of persistenceHierarchy) {
       try {
-        const blob = await persistence._get<PersistedBlob>(key);
+        const blob = await persistence._get<PersistedBlob | string>(key);
         if (blob) {
-          const user = UserImpl._fromJSON(auth, blob); // throws for unparsable blob (wrong format)
+          let user: UserInternal;
+          if (typeof blob === 'string') {
+            const response = await getAccountInfo(auth, {
+              idToken: blob
+            }).catch(() => undefined);
+            if (!response) {
+              break;
+            }
+            user = await UserImpl._fromGetAccountInfoResponse(
+              auth,
+              response,
+              blob
+            );
+          } else {
+            user = UserImpl._fromJSON(auth, blob); // throws for unparsable blob (wrong format)
+          }
           if (persistence !== selectedPersistence) {
             userToMigrate = user;
           }
diff --git a/packages/auth/src/model/auth.ts b/packages/auth/src/model/auth.ts
index a456b25578..a88430fd5d 100644
--- a/packages/auth/src/model/auth.ts
+++ b/packages/auth/src/model/auth.ts
@@ -33,6 +33,7 @@ import { UserInternal } from './user';
 import { ClientPlatform } from '../core/util/version';
 import { RecaptchaConfig } from '../platform_browser/recaptcha/recaptcha';
 import { PasswordPolicyInternal } from './password_policy';
+import { PersistenceInternal } from '../core/persistence';
 
 export type AppName = string;
 export type ApiKey = string;
@@ -71,6 +72,7 @@ export interface AuthInternal extends Auth {
   _canInitEmulator: boolean;
   _isInitialized: boolean;
   _initializationPromise: Promise<void> | null;
+  _persistenceManagerAvailable: Promise<void>;
   _updateCurrentUser(user: UserInternal | null): Promise<void>;
 
   _onStorageEvent(): void;
@@ -86,7 +88,8 @@ export interface AuthInternal extends Auth {
   _key(): string;
   _startProactiveRefresh(): void;
   _stopProactiveRefresh(): void;
-  _getPersistence(): string;
+  _getPersistenceType(): string;
+  _getPersistence(): PersistenceInternal;
   _getRecaptchaConfig(): RecaptchaConfig | null;
   _getPasswordPolicyInternal(): PasswordPolicyInternal | null;
   _updatePasswordPolicy(): Promise<void>;
diff --git a/packages/auth/src/model/public_types.ts b/packages/auth/src/model/public_types.ts
index ac1face6b6..43942b93d9 100644
--- a/packages/auth/src/model/public_types.ts
+++ b/packages/auth/src/model/public_types.ts
@@ -341,8 +341,9 @@ export interface Persistence {
    * - 'SESSION' is used for temporary persistence such as `sessionStorage`.
    * - 'LOCAL' is used for long term persistence such as `localStorage` or `IndexedDB`.
    * - 'NONE' is used for in-memory, or no persistence.
+   * - 'COOKIE' is used for cookie persistence, useful for server-side rendering.
    */
-  readonly type: 'SESSION' | 'LOCAL' | 'NONE';
+  readonly type: 'SESSION' | 'LOCAL' | 'NONE' | 'COOKIE';
 }
 
 /**
diff --git a/packages/auth/src/platform_browser/persistence/cookie_storage.ts b/packages/auth/src/platform_browser/persistence/cookie_storage.ts
new file mode 100644
index 0000000000..9b4570c825
--- /dev/null
+++ b/packages/auth/src/platform_browser/persistence/cookie_storage.ts
@@ -0,0 +1,166 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Persistence } from '../../model/public_types';
+import type { CookieChangeEvent } from 'cookie-store';
+
+const POLLING_INTERVAL_MS = 1_000;
+
+import {
+  PersistenceInternal,
+  PersistenceType,
+  PersistenceValue,
+  StorageEventListener
+} from '../../core/persistence';
+
+// Pull a cookie value from document.cookie
+function getDocumentCookie(name: string): string | null {
+  const escapedName = name.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
+  const matcher = RegExp(`${escapedName}=([^;]+)`);
+  return document.cookie.match(matcher)?.[1] ?? null;
+}
+
+// Produce a sanitized cookie name from the persistence key
+function getCookieName(key: string): string {
+  // __HOST- doesn't work in localhost https://issues.chromium.org/issues/40196122 but it has
+  // desirable security properties, so lets use a different cookie name while in dev-mode.
+  // Already checked isSecureContext in _isAvailable, so if it's http we're hitting local.
+  const isDevMode = window.location.protocol === 'http:';
+  return `${isDevMode ? '__dev_' : '__HOST-'}FIREBASE_${key.split(':')[3]}`;
+}
+
+export class CookiePersistence implements PersistenceInternal {
+  static type: 'COOKIE' = 'COOKIE';
+  readonly type = PersistenceType.COOKIE;
+  listenerUnsubscribes: Map<StorageEventListener, () => void> = new Map();
+
+  // used to get the URL to the backend to proxy to
+  _getFinalTarget(originalUrl: string): URL | string {
+    if (typeof window === undefined) {
+      return originalUrl;
+    }
+    const url = new URL(`${window.location.origin}/__cookies__`);
+    url.searchParams.set('finalTarget', originalUrl);
+    return url;
+  }
+
+  // To be a usable persistence method in a chain browserCookiePersistence ensures that
+  // prerequisites have been met, namely that we're in a secureContext, navigator and document are
+  // available and cookies are enabled. Not all UAs support these method, so fallback accordingly.
+  async _isAvailable(): Promise<boolean> {
+    if (typeof isSecureContext === 'boolean' && !isSecureContext) {
+      return false;
+    }
+    if (typeof navigator === 'undefined' || typeof document === 'undefined') {
+      return false;
+    }
+    return navigator.cookieEnabled ?? true;
+  }
+
+  // Set should be a noop as we expect middleware to handle this
+  async _set(_key: string, _value: PersistenceValue): Promise<void> {
+    return;
+  }
+
+  // Attempt to get the cookie from cookieStore, fallback to document.cookie
+  async _get<T extends PersistenceValue>(key: string): Promise<T | null> {
+    if (!this._isAvailable()) {
+      return null;
+    }
+    const name = getCookieName(key);
+    if (window.cookieStore) {
+      const cookie = await window.cookieStore.get(name);
+      return cookie?.value as T;
+    }
+    return getDocumentCookie(name) as T;
+  }
+
+  // Log out by overriding the idToken with a sentinel value of ""
+  async _remove(key: string): Promise<void> {
+    if (!this._isAvailable()) {
+      return;
+    }
+    // To make sure we don't hit signout over and over again, only do this operation if we need to
+    // with the logout sentinel value of "" this can cause race conditions. Unnecessary set-cookie
+    // headers will reduce CDN hit rates too.
+    const existingValue = await this._get(key);
+    if (!existingValue) {
+      return;
+    }
+    const name = getCookieName(key);
+    document.cookie = `${name}=;Max-Age=34560000;Partitioned;Secure;SameSite=Strict;Path=/;Priority=High`;
+    await fetch(`/__cookies__`, { method: 'DELETE' }).catch(() => undefined);
+  }
+
+  // Listen for cookie changes, both cookieStore and fallback to polling document.cookie
+  _addListener(key: string, listener: StorageEventListener): void {
+    if (!this._isAvailable()) {
+      return;
+    }
+    const name = getCookieName(key);
+    if (window.cookieStore) {
+      const cb = ((event: CookieChangeEvent): void => {
+        const changedCookie = event.changed.find(
+          change => change.name === name
+        );
+        if (changedCookie) {
+          listener(changedCookie.value as PersistenceValue);
+        }
+        const deletedCookie = event.deleted.find(
+          change => change.name === name
+        );
+        if (deletedCookie) {
+          listener(null);
+        }
+      }) as EventListener;
+      const unsubscribe = (): void =>
+        window.cookieStore.removeEventListener('change', cb);
+      this.listenerUnsubscribes.set(listener, unsubscribe);
+      return window.cookieStore.addEventListener('change', cb as EventListener);
+    }
+    let lastValue = getDocumentCookie(name);
+    const interval = setInterval(() => {
+      const currentValue = getDocumentCookie(name);
+      if (currentValue !== lastValue) {
+        listener(currentValue as PersistenceValue | null);
+        lastValue = currentValue;
+      }
+    }, POLLING_INTERVAL_MS);
+    const unsubscribe = (): void => clearInterval(interval);
+    this.listenerUnsubscribes.set(listener, unsubscribe);
+  }
+
+  _removeListener(_key: string, listener: StorageEventListener): void {
+    const unsubscribe = this.listenerUnsubscribes.get(listener);
+    if (!unsubscribe) {
+      return;
+    }
+    unsubscribe();
+    this.listenerUnsubscribes.delete(listener);
+  }
+}
+
+/**
+ * An implementation of {@link Persistence} of type `COOKIE`, for use on the client side in
+ * applications leveraging hybrid rendering and middleware.
+ *
+ * @remarks This persistence method requires companion middleware to function, such as that provided
+ * by {@link https://firebaseopensource.com/projects/firebaseextended/reactfire/ | ReactFire} for
+ * NextJS.
+ * @beta
+ */
+export const browserCookiePersistence: Persistence = CookiePersistence;
diff --git a/packages/auth/src/platform_node/index.ts b/packages/auth/src/platform_node/index.ts
index 67618b5b77..00d3f67b75 100644
--- a/packages/auth/src/platform_node/index.ts
+++ b/packages/auth/src/platform_node/index.ts
@@ -81,6 +81,7 @@ class FailClass {
 
 export const browserLocalPersistence = inMemoryPersistence;
 export const browserSessionPersistence = inMemoryPersistence;
+export const browserCookiePersistence = inMemoryPersistence;
 export const indexedDBLocalPersistence = inMemoryPersistence;
 export const browserPopupRedirectResolver = NOT_AVAILABLE_ERROR;
 export const PhoneAuthProvider = FailClass;
diff --git a/yarn.lock b/yarn.lock
index 5e5947884f..758bf3f505 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5875,6 +5875,11 @@ cookie-signature@1.0.6:
   resolved "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
   integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==
 
+cookie-store@4.0.0-next.4:
+  version "4.0.0-next.4"
+  resolved "https://registry.npmjs.org/cookie-store/-/cookie-store-4.0.0-next.4.tgz#8b13981bfd93e10e30694e9816928f8c478a326b"
+  integrity sha512-RVcIK13cCiAa+rsxAbFhrIThn1eBcgt9WTyLq539zMafDnhdGb6u/O5JdMTC3/pcJVqqHJmctiWxAYPpwT/fxw==
+
 cookie@0.6.0:
   version "0.6.0"
   resolved "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051"


Reviews:
user: login: jamesdaniels
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T14:43:55Z
user: login: jamesdaniels
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T14:44:17Z
user: login: jamesdaniels
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T14:44:33Z
user: login: DellaBitta
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T15:08:12Z
user: login: jamesdaniels
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T15:08:42Z
user: login: jamesdaniels
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T15:09:16Z
user: login: jamesdaniels
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T15:10:48Z
user: login: jamesdaniels
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T15:11:17Z
user: login: jamesdaniels
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T15:11:50Z
user: login: jamesdaniels
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T17:28:34Z
user: login: hsubox76
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T19:07:26Z
user: login: jamesdaniels
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T19:14:59Z
user: login: hsubox76
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T19:21:35Z
user: login: jamesdaniels
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T22:12:38Z
user: login: jamesdaniels
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T15:10:19Z
user: login: jamesdaniels
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T19:48:01Z
user: login: sam-gc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T19:59:40Z
user: login: jamesdaniels
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T20:53:33Z
user: login: jamesdaniels
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T15:20:55Z
user: login: jamesdaniels
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T15:21:10Z
user: login: DellaBitta
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T22:49:16Z
user: login: jamesdaniels
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T12:23:36Z
user: login: jamesdaniels
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T19:02:57Z
user: login: egilmorez
body: LG, just a tiny nit!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T20:13:43Z
user: login: hsubox76
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T20:17:55Z
user: login: DellaBitta
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T21:51:38Z

Review comments:
diff_hunk: @@ -241,6 +242,9 @@ export async function _performSignInRequest<T, V extends IdTokenResponse>(
   request?: T,
   customErrorMap: Partial<ServerErrorMap<ServerError>> = {}
 ): Promise<V> {
+  // TODO(jamedaniels) if auth persistence is cookie, proxy through the server endpoint
path: packages/auth/src/api/index.ts
user: login: jamesdaniels
body: done.
created_at: 2025-03-10T14:43:55Z
author_association: MEMBER
diff_hunk: @@ -44,6 +44,8 @@ export class UserCredentialImpl
     this.operationType = params.operationType;
   }
 
+  // TODO(jamesdaniels) fetch the user credential from the cookie and response returned from the
path: packages/auth/src/core/user/user_credential_impl.ts
user: login: jamesdaniels
body: Done.
created_at: 2025-03-10T14:44:17Z
author_association: MEMBER
diff_hunk: @@ -62,6 +62,8 @@ export interface UserInternal extends User {
 
   auth: AuthInternal;
   providerId: ProviderId.FIREBASE;
+  // TODO(jamesdaniels): refreshToken should either be optional or a sentinel value for COOKIE
path: packages/auth/src/model/user.ts
user: login: jamesdaniels
body: Done.
created_at: 2025-03-10T14:44:33Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,82 @@
+/**
+ * @license
+ * Copyright 2019 Google LLC
path: packages/auth/src/platform_browser/persistence/cookie_storage.ts
user: login: DellaBitta
body: 2025
created_at: 2025-03-10T14:56:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,82 @@
+/**
+ * @license
+ * Copyright 2019 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Persistence } from '../../model/public_types';
+
+import {
+  PersistenceInternal,
+  PersistenceType,
+  PersistenceValue,
+  StorageEventListener
+} from '../../core/persistence';
+
+export class CookiePersistence implements PersistenceInternal {
+  static type: 'COOKIE' = 'COOKIE';
+  readonly type = PersistenceType.COOKIE;
+  listeners: Map<StorageEventListener, (e: any) => void> = new Map();
path: packages/auth/src/platform_browser/persistence/cookie_storage.ts
user: login: DellaBitta
body: I think our linter frowns up on the use of `any`.
created_at: 2025-03-10T14:57:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,82 @@
+/**
+ * @license
+ * Copyright 2019 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Persistence } from '../../model/public_types';
+
+import {
+  PersistenceInternal,
+  PersistenceType,
+  PersistenceValue,
+  StorageEventListener
+} from '../../core/persistence';
+
+export class CookiePersistence implements PersistenceInternal {
+  static type: 'COOKIE' = 'COOKIE';
+  readonly type = PersistenceType.COOKIE;
+  listeners: Map<StorageEventListener, (e: any) => void> = new Map();
+
+  async _isAvailable(): Promise<boolean> {
path: packages/auth/src/platform_browser/persistence/cookie_storage.ts
user: login: DellaBitta
body: Could you add class and method comments?
created_at: 2025-03-10T14:57:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,82 @@
+/**
+ * @license
+ * Copyright 2019 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Persistence } from '../../model/public_types';
+
+import {
+  PersistenceInternal,
+  PersistenceType,
+  PersistenceValue,
+  StorageEventListener
+} from '../../core/persistence';
+
+export class CookiePersistence implements PersistenceInternal {
+  static type: 'COOKIE' = 'COOKIE';
+  readonly type = PersistenceType.COOKIE;
+  listeners: Map<StorageEventListener, (e: any) => void> = new Map();
+
+  async _isAvailable(): Promise<boolean> {
+    return navigator.hasOwnProperty('cookieEnabled') ?
+      navigator.cookieEnabled :
+      true;
+  }
+
+  async _set(_key: string, _value: PersistenceValue): Promise<void> {
+    return;
+  }
+
+  async _get<T extends PersistenceValue>(key: string): Promise<T | null> {
+    const cookie = await (window as any).cookieStore.get(key);
+    return cookie?.value;
+  }
+
+  async _remove(key: string): Promise<void> {
+    const cookie = await (window as any).cookieStore.get(key);
+    if (!cookie) {
+      return;
+    }
+    await (window as any).cookieStore.set({ ...cookie, value: "" });
+    await fetch(`/__cookies__`, { method: 'DELETE' }).catch(() => undefined);
+  }
+
+  _addListener(_key: string, _listener: StorageEventListener): void {
+    // TODO fallback to polling if cookieStore is not available
+    const cb = (event: any) => {
+      const cookie = event.changed.find((change: any) => change.name === _key);
+      if (cookie) {
+        _listener(cookie.value);
+      }
+    };
+    this.listeners.set(_listener, cb);
+    (window as any).cookieStore.addEventListener('change', cb);
+  }
+
+  _removeListener(_key: string, _listener: StorageEventListener): void {
+    const cb = this.listeners.get(_listener);
+    if (!cb) {
+      return;
+    }
+    (window as any).cookieStore.removeEventListener('change', cb);
+  }
+}
+
+/**
+ * An implementation of {@link Persistence} of type 'NONE'.
path: packages/auth/src/platform_browser/persistence/cookie_storage.ts
user: login: DellaBitta
body: Isn't this of type `COOKIE`?
created_at: 2025-03-10T14:58:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,5 +1,5 @@
 {
     "extends": "../../config/api-extractor.json",
     // Point it to your entry point d.ts file. 
-    "mainEntryPointFilePath": "<projectFolder>/dist/rules-unit-testing/index.d.ts"
+    "mainEntryPointFilePath": "<projectFolder>/dist/index.d.ts"
path: packages/rules-unit-testing/api-extractor.json
user: login: DellaBitta
body: Why is this change in here?
created_at: 2025-03-10T14:59:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -241,6 +242,9 @@ export async function _performSignInRequest<T, V extends IdTokenResponse>(
   request?: T,
   customErrorMap: Partial<ServerErrorMap<ServerError>> = {}
 ): Promise<V> {
+  // TODO(jamedaniels) if auth persistence is cookie, proxy through the server endpoint
path: packages/auth/src/api/index.ts
user: login: DellaBitta
body: Could you remove the completed `TODO` comments from the source if they're actually done. It would help with the review.
created_at: 2025-03-10T14:59:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -66,8 +67,21 @@ export class PersistenceUserManager {
   }
 
   async getCurrentUser(): Promise<UserInternal | null> {
-    const blob = await this.persistence._get<PersistedBlob>(this.fullUserKey);
-    return blob ? UserImpl._fromJSON(this.auth, blob) : null;
+    const blob = await this.persistence._get<PersistedBlob | string>(
+      this.fullUserKey
+    );
+    if (!blob) {
+      return null;
+    }
+    if (typeof blob === 'string') {
+      const response = await getAccountInfo(this.auth, { idToken: blob });
path: packages/auth/src/core/persistence/persistence_user_manager.ts
user: login: DellaBitta
body: Should we check if the token is still valid and return null if it's not? We already have `validateTokenTTL` in `app/src/firebaseServerApp.ts` which could be brought into our utils package.
created_at: 2025-03-10T15:05:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -66,8 +67,21 @@ export class PersistenceUserManager {
   }
 
   async getCurrentUser(): Promise<UserInternal | null> {
-    const blob = await this.persistence._get<PersistedBlob>(this.fullUserKey);
-    return blob ? UserImpl._fromJSON(this.auth, blob) : null;
+    const blob = await this.persistence._get<PersistedBlob | string>(
+      this.fullUserKey
+    );
+    if (!blob) {
+      return null;
+    }
+    if (typeof blob === 'string') {
+      const response = await getAccountInfo(this.auth, { idToken: blob });
path: packages/auth/src/core/persistence/persistence_user_manager.ts
user: login: DellaBitta
body: Have you tested this with an invalid token to see how it behaves?
created_at: 2025-03-10T15:06:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -140,9 +154,19 @@ export class PersistenceUserManager {
     // persistence, we will (but only if that persistence supports migration).
     for (const persistence of persistenceHierarchy) {
       try {
-        const blob = await persistence._get<PersistedBlob>(key);
+        const blob = await persistence._get<PersistedBlob | string>(key);
         if (blob) {
-          const user = UserImpl._fromJSON(auth, blob); // throws for unparsable blob (wrong format)
+          let user: UserInternal;
path: packages/auth/src/core/persistence/persistence_user_manager.ts
user: login: DellaBitta
body: Same here, what if the token isn't valid or has expired?
created_at: 2025-03-10T15:07:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -241,6 +242,9 @@ export async function _performSignInRequest<T, V extends IdTokenResponse>(
   request?: T,
   customErrorMap: Partial<ServerErrorMap<ServerError>> = {}
 ): Promise<V> {
+  // TODO(jamedaniels) if auth persistence is cookie, proxy through the server endpoint
path: packages/auth/src/api/index.ts
user: login: jamesdaniels
body: will do
created_at: 2025-03-10T15:08:42Z
author_association: MEMBER
diff_hunk: @@ -66,8 +67,21 @@ export class PersistenceUserManager {
   }
 
   async getCurrentUser(): Promise<UserInternal | null> {
-    const blob = await this.persistence._get<PersistedBlob>(this.fullUserKey);
-    return blob ? UserImpl._fromJSON(this.auth, blob) : null;
+    const blob = await this.persistence._get<PersistedBlob | string>(
+      this.fullUserKey
+    );
+    if (!blob) {
+      return null;
+    }
+    if (typeof blob === 'string') {
+      const response = await getAccountInfo(this.auth, { idToken: blob });
path: packages/auth/src/core/persistence/persistence_user_manager.ts
user: login: jamesdaniels
body: Good call on both
created_at: 2025-03-10T15:09:16Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,82 @@
+/**
+ * @license
+ * Copyright 2019 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Persistence } from '../../model/public_types';
+
+import {
+  PersistenceInternal,
+  PersistenceType,
+  PersistenceValue,
+  StorageEventListener
+} from '../../core/persistence';
+
+export class CookiePersistence implements PersistenceInternal {
+  static type: 'COOKIE' = 'COOKIE';
+  readonly type = PersistenceType.COOKIE;
+  listeners: Map<StorageEventListener, (e: any) => void> = new Map();
+
+  async _isAvailable(): Promise<boolean> {
+    return navigator.hasOwnProperty('cookieEnabled') ?
+      navigator.cookieEnabled :
+      true;
+  }
+
+  async _set(_key: string, _value: PersistenceValue): Promise<void> {
+    return;
+  }
+
+  async _get<T extends PersistenceValue>(key: string): Promise<T | null> {
+    const cookie = await (window as any).cookieStore.get(key);
+    return cookie?.value;
+  }
+
+  async _remove(key: string): Promise<void> {
+    const cookie = await (window as any).cookieStore.get(key);
+    if (!cookie) {
+      return;
+    }
+    await (window as any).cookieStore.set({ ...cookie, value: "" });
+    await fetch(`/__cookies__`, { method: 'DELETE' }).catch(() => undefined);
+  }
+
+  _addListener(_key: string, _listener: StorageEventListener): void {
+    // TODO fallback to polling if cookieStore is not available
+    const cb = (event: any) => {
+      const cookie = event.changed.find((change: any) => change.name === _key);
+      if (cookie) {
+        _listener(cookie.value);
+      }
+    };
+    this.listeners.set(_listener, cb);
+    (window as any).cookieStore.addEventListener('change', cb);
+  }
+
+  _removeListener(_key: string, _listener: StorageEventListener): void {
+    const cb = this.listeners.get(_listener);
+    if (!cb) {
+      return;
+    }
+    (window as any).cookieStore.removeEventListener('change', cb);
+  }
+}
+
+/**
+ * An implementation of {@link Persistence} of type 'NONE'.
path: packages/auth/src/platform_browser/persistence/cookie_storage.ts
user: login: jamesdaniels
body: heh, yeah copy-pasta
created_at: 2025-03-10T15:10:48Z
author_association: MEMBER
diff_hunk: @@ -1,5 +1,5 @@
 {
     "extends": "../../config/api-extractor.json",
     // Point it to your entry point d.ts file. 
-    "mainEntryPointFilePath": "<projectFolder>/dist/rules-unit-testing/index.d.ts"
+    "mainEntryPointFilePath": "<projectFolder>/dist/index.d.ts"
path: packages/rules-unit-testing/api-extractor.json
user: login: jamesdaniels
body: No idea, I should have left a comment this was to fix the build
created_at: 2025-03-10T15:11:17Z
author_association: MEMBER
diff_hunk: @@ -845,6 +845,7 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
   }
 
   async _getAppCheckToken(): Promise<string | undefined> {
+    // @ts-ignore
path: packages/auth/src/core/auth/auth_impl.ts
user: login: jamesdaniels
body: Should not be needed, my local build was broken without it.
created_at: 2025-03-10T15:11:49Z
author_association: MEMBER
diff_hunk: @@ -66,8 +67,21 @@ export class PersistenceUserManager {
   }
 
   async getCurrentUser(): Promise<UserInternal | null> {
-    const blob = await this.persistence._get<PersistedBlob>(this.fullUserKey);
-    return blob ? UserImpl._fromJSON(this.auth, blob) : null;
+    const blob = await this.persistence._get<PersistedBlob | string>(
+      this.fullUserKey
+    );
+    if (!blob) {
+      return null;
+    }
+    if (typeof blob === 'string') {
+      const response = await getAccountInfo(this.auth, { idToken: blob });
path: packages/auth/src/core/persistence/persistence_user_manager.ts
user: login: jamesdaniels
body: Thinking about this, in normal operation this shouldn't happen, as the server-side route should be covered by middleware which will return set-cookie—but if the developer made a mistake and didn't cover the route with middleware then this could expire—so we should handle & log a sensible error.
created_at: 2025-03-10T17:28:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,82 @@
+/**
+ * @license
+ * Copyright 2019 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Persistence } from '../../model/public_types';
+
+import {
+  PersistenceInternal,
+  PersistenceType,
+  PersistenceValue,
+  StorageEventListener
+} from '../../core/persistence';
+
+export class CookiePersistence implements PersistenceInternal {
+  static type: 'COOKIE' = 'COOKIE';
+  readonly type = PersistenceType.COOKIE;
+  listeners: Map<StorageEventListener, (e: any) => void> = new Map();
+
+  async _isAvailable(): Promise<boolean> {
+    return navigator.hasOwnProperty('cookieEnabled') ?
path: packages/auth/src/platform_browser/persistence/cookie_storage.ts
user: login: hsubox76
body: Probably need a check on `typeof navigator !== 'undefined'` just in case. IndexedDB persistence has a check on `if (!indexedDB)` inside a try/catch which I think also works.
created_at: 2025-03-12T19:07:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,82 @@
+/**
+ * @license
+ * Copyright 2019 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Persistence } from '../../model/public_types';
+
+import {
+  PersistenceInternal,
+  PersistenceType,
+  PersistenceValue,
+  StorageEventListener
+} from '../../core/persistence';
+
+export class CookiePersistence implements PersistenceInternal {
+  static type: 'COOKIE' = 'COOKIE';
+  readonly type = PersistenceType.COOKIE;
+  listeners: Map<StorageEventListener, (e: any) => void> = new Map();
+
+  async _isAvailable(): Promise<boolean> {
+    return navigator.hasOwnProperty('cookieEnabled') ?
path: packages/auth/src/platform_browser/persistence/cookie_storage.ts
user: login: jamesdaniels
body: good call, will do
created_at: 2025-03-12T19:14:58Z
author_association: MEMBER
diff_hunk: @@ -265,11 +269,21 @@ export function _getFinalTarget(
 ): string {
   const base = `${host}${path}?${query}`;
 
-  if (!(auth as AuthInternal).config.emulator) {
-    return `${auth.config.apiScheme}://${base}`;
+  const finalTarget = (auth as AuthInternal).config.emulator
+    ? _emulatorUrl(auth.config as ConfigInternal, base)
+    : `${auth.config.apiScheme}://${base}`;
+
+  // TODO get the exchange URL from the persistence method
+  //      don't use startsWith v1/accounts...
+  if (
+    (auth as AuthInternal)._getPersistence() === PersistenceType.COOKIE &&
+    (path.startsWith('/v1/accounts:signIn') || path === Endpoint.TOKEN)
+  ) {
+    const params = new URLSearchParams({ finalTarget });
+    return `${window.location.origin}/__cookies__?${params.toString()}`;
path: packages/auth/src/api/index.ts
user: login: hsubox76
body: I think this function can be called in Node so we may have to make sure `window` doesn't throw. I'm not sure if the `=== PersistenceType.COOKIE` is a guarantee that we're in browser, maybe safer to explicitly check for window. What's the "don't use startsWith v1/accounts" comment about?
created_at: 2025-03-12T19:21:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -265,11 +269,21 @@ export function _getFinalTarget(
 ): string {
   const base = `${host}${path}?${query}`;
 
-  if (!(auth as AuthInternal).config.emulator) {
-    return `${auth.config.apiScheme}://${base}`;
+  const finalTarget = (auth as AuthInternal).config.emulator
+    ? _emulatorUrl(auth.config as ConfigInternal, base)
+    : `${auth.config.apiScheme}://${base}`;
+
+  // TODO get the exchange URL from the persistence method
+  //      don't use startsWith v1/accounts...
+  if (
+    (auth as AuthInternal)._getPersistence() === PersistenceType.COOKIE &&
+    (path.startsWith('/v1/accounts:signIn') || path === Endpoint.TOKEN)
+  ) {
+    const params = new URLSearchParams({ finalTarget });
+    return `${window.location.origin}/__cookies__?${params.toString()}`;
path: packages/auth/src/api/index.ts
user: login: jamesdaniels
body: SG. My thought with "don't use startsWith v1/accounts" is to test for enum inclusion rather than the string, e.g, `SignInEndpoints.includes(path)`
created_at: 2025-03-12T22:12:38Z
author_association: MEMBER
diff_hunk: @@ -136,6 +136,7 @@
     "@rollup/plugin-strip": "2.1.0",
     "@types/express": "4.17.21",
     "chromedriver": "119.0.1",
+    "cookie-store": "4.0.0-next.4",
path: packages/auth/package.json
user: login: jamesdaniels
body: FYI using this dev-dep for types, somewhere else I tried this ponyfill and found it unsuitable for prod as it can't be webpacked.
created_at: 2025-03-13T15:10:18Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,143 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Persistence } from '../../model/public_types';
+import type { CookieChangeEvent } from 'cookie-store';
+
+const POLLING_INTERVAL_MS = 1_000;
+
+import {
+  PersistenceInternal,
+  PersistenceType,
+  PersistenceValue,
+  StorageEventListener
+} from '../../core/persistence';
+
+const getDocumentCookie = (name: string): string | null => {
+  const escapedName = name.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
+  const matcher = RegExp(`${escapedName}=([^;]+)`);
+  return document.cookie.match(matcher)?.[1] ?? null;
+};
+
+export class CookiePersistence implements PersistenceInternal {
+  static type: 'COOKIE' = 'COOKIE';
+  readonly type = PersistenceType.COOKIE;
+  cookieStoreListeners: Map<
+    StorageEventListener,
+    (event: CookieChangeEvent) => void
+  > = new Map();
+  cookiePollingIntervals: Map<StorageEventListener, NodeJS.Timeout> = new Map();
+
+  async _isAvailable(): Promise<boolean> {
+    if (typeof navigator === 'undefined' || typeof document === 'undefined') {
+      return false;
+    }
+    return navigator.cookieEnabled ?? true;
+  }
+
+  async _set(_key: string, _value: PersistenceValue): Promise<void> {
+    return;
+  }
+
+  async _get<T extends PersistenceValue>(key: string): Promise<T | null> {
+    if (!this._isAvailable()) {
+      return null;
+    }
+    if (window.cookieStore) {
+      const cookie = await window.cookieStore.get(key);
+      return cookie?.value as T;
+    } else {
+      return getDocumentCookie(key) as T;
+    }
+  }
+
+  async _remove(key: string): Promise<void> {
path: packages/auth/src/platform_browser/persistence/cookie_storage.ts
user: login: jamesdaniels
body: Discussed with @avolkovi during the bug bash, server should set a third cookie for logout—client can either delete or set to sentinel value
created_at: 2025-03-25T19:48:00Z
author_association: MEMBER
diff_hunk: @@ -265,11 +266,21 @@ export function _getFinalTarget(
 ): string {
   const base = `${host}${path}?${query}`;
 
-  if (!(auth as AuthInternal).config.emulator) {
-    return `${auth.config.apiScheme}://${base}`;
+  const finalTarget = (auth as AuthInternal).config.emulator
path: packages/auth/src/api/index.ts
user: login: sam-gc
body: Small nit: can we create a `const authInternal = auth as AuthInternal` so that we only have to do that `as` cast once?
created_at: 2025-03-25T19:55:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,133 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Persistence } from '../../model/public_types';
+import type { CookieChangeEvent } from 'cookie-store';
+
+const POLLING_INTERVAL_MS = 1_000;
+
+import {
+  PersistenceInternal,
+  PersistenceType,
+  PersistenceValue,
+  StorageEventListener
+} from '../../core/persistence';
+
+const getDocumentCookie = (name: string): string | null => {
path: packages/auth/src/platform_browser/persistence/cookie_storage.ts
user: login: sam-gc
body: Let's just make these functions; I don't think the closure is needed to capture any `this` context right?

go/tsjs-style#function-declarations
created_at: 2025-03-25T19:58:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -296,6 +296,9 @@ export function connectAuthEmulator(auth: Auth, url: string, options?: {
     disableWarnings: boolean;
 }): void;
 
+// @public
path: common/api-review/auth.api.md
user: login: jamesdaniels
body: `@beta`
created_at: 2025-03-25T20:53:32Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,82 @@
+/**
+ * @license
+ * Copyright 2019 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Persistence } from '../../model/public_types';
+
+import {
+  PersistenceInternal,
+  PersistenceType,
+  PersistenceValue,
+  StorageEventListener
+} from '../../core/persistence';
+
+export class CookiePersistence implements PersistenceInternal {
+  static type: 'COOKIE' = 'COOKIE';
+  readonly type = PersistenceType.COOKIE;
+  listeners: Map<StorageEventListener, (e: any) => void> = new Map();
+
+  async _isAvailable(): Promise<boolean> {
path: packages/auth/src/platform_browser/persistence/cookie_storage.ts
user: login: jamesdaniels
body: Will add comments in next change set
created_at: 2025-03-26T15:20:55Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,143 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Persistence } from '../../model/public_types';
+import type { CookieChangeEvent } from 'cookie-store';
+
+const POLLING_INTERVAL_MS = 1_000;
+
+import {
+  PersistenceInternal,
+  PersistenceType,
+  PersistenceValue,
+  StorageEventListener
+} from '../../core/persistence';
+
+const getDocumentCookie = (name: string): string | null => {
+  const escapedName = name.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
+  const matcher = RegExp(`${escapedName}=([^;]+)`);
+  return document.cookie.match(matcher)?.[1] ?? null;
+};
+
+export class CookiePersistence implements PersistenceInternal {
+  static type: 'COOKIE' = 'COOKIE';
+  readonly type = PersistenceType.COOKIE;
+  cookieStoreListeners: Map<
+    StorageEventListener,
+    (event: CookieChangeEvent) => void
+  > = new Map();
+  cookiePollingIntervals: Map<StorageEventListener, NodeJS.Timeout> = new Map();
+
+  async _isAvailable(): Promise<boolean> {
+    if (typeof navigator === 'undefined' || typeof document === 'undefined') {
+      return false;
+    }
+    return navigator.cookieEnabled ?? true;
+  }
+
+  async _set(_key: string, _value: PersistenceValue): Promise<void> {
+    return;
+  }
+
+  async _get<T extends PersistenceValue>(key: string): Promise<T | null> {
+    if (!this._isAvailable()) {
+      return null;
+    }
+    if (window.cookieStore) {
+      const cookie = await window.cookieStore.get(key);
+      return cookie?.value as T;
+    } else {
+      return getDocumentCookie(key) as T;
+    }
+  }
+
+  async _remove(key: string): Promise<void> {
path: packages/auth/src/platform_browser/persistence/cookie_storage.ts
user: login: jamesdaniels
body: Will address in next change-set
created_at: 2025-03-26T15:21:10Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,6 @@
+---
+'firebase': minor
+'@firebase/auth': minor
+---
+
+Adding Persistence.COOKIE for use in frameworks that utilize hybrid rendering
path: .changeset/orange-turtles-taste.md
user: login: DellaBitta
body: Could you punch-up this changeset description? It becomes our public release note.
created_at: 2025-03-26T22:44:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -341,8 +341,9 @@ export interface Persistence {
    * - 'SESSION' is used for temporary persistence such as `sessionStorage`.
    * - 'LOCAL' is used for long term persistence such as `localStorage` or `IndexedDB`.
    * - 'NONE' is used for in-memory, or no persistence.
+   * - 'COOKIE' is used for Cookie persistence, useful for server-side rendering.
path: packages/auth/src/model/public_types.ts
user: login: DellaBitta
body: I don't think `Cookie` should be capitalized.
created_at: 2025-03-26T22:45:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -140,9 +155,24 @@ export class PersistenceUserManager {
     // persistence, we will (but only if that persistence supports migration).
     for (const persistence of persistenceHierarchy) {
       try {
-        const blob = await persistence._get<PersistedBlob>(key);
+        const blob = await persistence._get<PersistedBlob | string>(key);
         if (blob) {
-          const user = UserImpl._fromJSON(auth, blob); // throws for unparsable blob (wrong format)
+          let user: UserInternal;
+          if (typeof blob === 'string') {
+            const response = await getAccountInfo(auth, {
+              idToken: blob
+            }).catch(() => undefined);
+            if (!response) {
+              break;
+            }
+            user = await UserImpl._fromGetAccountInfoResponse(
+              auth,
+              response,
+              blob
+            );
+          } else {
+            user = UserImpl._fromJSON(auth, blob); // throws for unparsable blob (wrong format)
path: packages/auth/src/core/persistence/persistence_user_manager.ts
user: login: DellaBitta
body: Is the thrown error descriptive?
created_at: 2025-03-26T22:48:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -140,9 +155,24 @@ export class PersistenceUserManager {
     // persistence, we will (but only if that persistence supports migration).
     for (const persistence of persistenceHierarchy) {
       try {
-        const blob = await persistence._get<PersistedBlob>(key);
+        const blob = await persistence._get<PersistedBlob | string>(key);
         if (blob) {
-          const user = UserImpl._fromJSON(auth, blob); // throws for unparsable blob (wrong format)
+          let user: UserInternal;
+          if (typeof blob === 'string') {
+            const response = await getAccountInfo(auth, {
+              idToken: blob
+            }).catch(() => undefined);
+            if (!response) {
+              break;
+            }
+            user = await UserImpl._fromGetAccountInfoResponse(
+              auth,
+              response,
+              blob
+            );
+          } else {
+            user = UserImpl._fromJSON(auth, blob); // throws for unparsable blob (wrong format)
path: packages/auth/src/core/persistence/persistence_user_manager.ts
user: login: jamesdaniels
body: Not sure, this line was just whitespace changes
created_at: 2025-03-27T12:23:36Z
author_association: MEMBER
diff_hunk: @@ -139,6 +143,11 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
   ) {
     this.name = app.name;
     this.clientVersion = config.sdkClientVersion;
+    // TODO(jamesdaniels) explore less hacky way to do this, cookie authentication needs
+    // persistenceMananger to be available. see _getFinalTarget for more context
+    this._persistenceManagerAvailable = new Promise<void>(
+      resolve => (this._resolvePersistenceManagerAvailable = resolve)
+    );
path: packages/auth/src/core/auth/auth_impl.ts
user: login: jamesdaniels
body: Can i haz `Promise.withResolvers` yet? 🤣 
created_at: 2025-03-27T19:02:57Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,166 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Persistence } from '../../model/public_types';
+import type { CookieChangeEvent } from 'cookie-store';
+
+const POLLING_INTERVAL_MS = 1_000;
+
+import {
+  PersistenceInternal,
+  PersistenceType,
+  PersistenceValue,
+  StorageEventListener
+} from '../../core/persistence';
+
+// Pull a cookie value from document.cookie
+function getDocumentCookie(name: string): string | null {
+  const escapedName = name.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
+  const matcher = RegExp(`${escapedName}=([^;]+)`);
+  return document.cookie.match(matcher)?.[1] ?? null;
+}
+
+// Produce a sanitized cookie name from the persistence key
+function getCookieName(key: string): string {
+  // __HOST- doesn't work in localhost https://issues.chromium.org/issues/40196122 but it has
+  // desirable security properties, so lets use a different cookie name while in dev-mode.
+  // Already checked isSecureContext in _isAvailable, so if it's http we're hitting local.
+  const isDevMode = window.location.protocol === 'http:';
+  return `${isDevMode ? '__dev_' : '__HOST-'}FIREBASE_${key.split(':')[3]}`;
+}
+
+export class CookiePersistence implements PersistenceInternal {
+  static type: 'COOKIE' = 'COOKIE';
+  readonly type = PersistenceType.COOKIE;
+  listenerUnsubscribes: Map<StorageEventListener, () => void> = new Map();
+
+  // used to get the URL to the backend to proxy to
+  _getFinalTarget(originalUrl: string): URL | string {
+    if (typeof window === undefined) {
+      return originalUrl;
+    }
+    const url = new URL(`${window.location.origin}/__cookies__`);
+    url.searchParams.set('finalTarget', originalUrl);
+    return url;
+  }
+
+  // To be a usable persistence method in a chain browserCookiePersistence ensures that
+  // prerequisites have been met, namely that we're in a secureContext, navigator and document are
+  // available and cookies are enabled. Not all UAs support these method, so fallback accordingly.
+  async _isAvailable(): Promise<boolean> {
+    if (typeof isSecureContext === 'boolean' && !isSecureContext) {
+      return false;
+    }
+    if (typeof navigator === 'undefined' || typeof document === 'undefined') {
+      return false;
+    }
+    return navigator.cookieEnabled ?? true;
+  }
+
+  // Set should be a noop as we expect middleware to handle this
+  async _set(_key: string, _value: PersistenceValue): Promise<void> {
+    return;
+  }
+
+  // Attempt to get the cookie from cookieStore, fallback to document.cookie
+  async _get<T extends PersistenceValue>(key: string): Promise<T | null> {
+    if (!this._isAvailable()) {
+      return null;
+    }
+    const name = getCookieName(key);
+    if (window.cookieStore) {
+      const cookie = await window.cookieStore.get(name);
+      return cookie?.value as T;
+    }
+    return getDocumentCookie(name) as T;
+  }
+
+  // Log out by overriding the idToken with a sentinel value of ""
+  async _remove(key: string): Promise<void> {
+    if (!this._isAvailable()) {
+      return;
+    }
+    // To make sure we don't hit signout over and over again, only do this operation if we need to
+    // with the logout sentinel value of "" this can cause race conditions. Unnecessary set-cookie
+    // headers will reduce CDN hit rates too.
+    const existingValue = await this._get(key);
+    if (!existingValue) {
+      return;
+    }
+    const name = getCookieName(key);
+    document.cookie = `${name}=;Max-Age=34560000;Partitioned;Secure;SameSite=Strict;Path=/;Priority=High`;
+    await fetch(`/__cookies__`, { method: 'DELETE' }).catch(() => undefined);
+  }
+
+  // Listen for cookie changes, both cookieStore and fallback to polling document.cookie
+  _addListener(key: string, listener: StorageEventListener): void {
+    if (!this._isAvailable()) {
+      return;
+    }
+    const name = getCookieName(key);
+    if (window.cookieStore) {
+      const cb = ((event: CookieChangeEvent): void => {
+        const changedCookie = event.changed.find(
+          change => change.name === name
+        );
+        if (changedCookie) {
+          listener(changedCookie.value as PersistenceValue);
+        }
+        const deletedCookie = event.deleted.find(
+          change => change.name === name
+        );
+        if (deletedCookie) {
+          listener(null);
+        }
+      }) as EventListener;
+      const unsubscribe = (): void =>
+        window.cookieStore.removeEventListener('change', cb);
+      this.listenerUnsubscribes.set(listener, unsubscribe);
+      return window.cookieStore.addEventListener('change', cb as EventListener);
+    }
+    let lastValue = getDocumentCookie(name);
+    const interval = setInterval(() => {
+      const currentValue = getDocumentCookie(name);
+      if (currentValue !== lastValue) {
+        listener(currentValue as PersistenceValue | null);
+        lastValue = currentValue;
+      }
+    }, POLLING_INTERVAL_MS);
+    const unsubscribe = (): void => clearInterval(interval);
+    this.listenerUnsubscribes.set(listener, unsubscribe);
+  }
+
+  _removeListener(_key: string, listener: StorageEventListener): void {
+    const unsubscribe = this.listenerUnsubscribes.get(listener);
+    if (!unsubscribe) {
+      return;
+    }
+    unsubscribe();
+    this.listenerUnsubscribes.delete(listener);
+  }
+}
+
+/**
+ * An implementation of {@link Persistence} of type `COOKIE`, for use on the client-side in
path: packages/auth/src/platform_browser/persistence/cookie_storage.ts
user: login: egilmorez
body: No need for hyphen here; just "client side" works.
created_at: 2025-03-27T20:12:07Z
author_association: CONTRIBUTOR

PR comments:
user: login: changeset-bot[bot]
created_at: 2025-03-10T04:41:58Z
body: ###  🦋  Changeset detected

Latest commit: 1e50af687bb2e2f15080190e55dab34d89b9c1db

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 3 packages</summary>

  | Name                  | Type  |
| --------------------- | ----- |
| firebase              | Minor |
| @firebase/auth        | Minor |
| @firebase/auth-compat | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/firebase/firebase-js-sdk/new/jamesdaniels_authCookiePersistence?filename=.changeset/young-cats-divide.md&value=---%0A%22%40firebase%2Fauth-compat%22%3A%20patch%0A%22%40firebase%2Fauth%22%3A%20patch%0A---%0A%0AAuth%20cookie%20persistence%0A)


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-25T20:53:57Z
body: ### Changeset File Check :warning:
- Warning: This PR modifies files in the following packages but they have not been included in the changeset file:%0A  - @firebase/auth-compat%0A%0A  Make sure this was intentional.
author_association: CONTRIBUTOR
user: login: google-oss-bot
created_at: 2025-03-25T21:11:14Z
body: <!DOCTYPE html>
<html><body><h1>Size Report <sup><a href="#yzioDUGmst">1</a></sup></h1><h3>Affected Products</h3><ul><li><h4><code>@firebase/auth</code></h4><table><thead><tr><th align="center">Type</th><th align="center">Base (dcc62c0)</th><th align="center">Merge (01c5d15)</th><th align="center">Diff</th></tr></thead><tbody><tr><td align="left">browser</td><td align="center">189 kB</td><td align="center">193 kB</td><td align="center">+3.96 kB (+2.1%)</td></tr><tr><td align="left">cordova</td><td align="center">164 kB</td><td align="center">166 kB</td><td align="center">+1.29 kB (+0.8%)</td></tr><tr><td align="left">main</td><td align="center">146 kB</td><td align="center">147 kB</td><td align="center">+1.52 kB (+1.0%)</td></tr><tr><td align="left">module</td><td align="center">189 kB</td><td align="center">193 kB</td><td align="center">+3.96 kB (+2.1%)</td></tr><tr><td align="left">react-native</td><td align="center">164 kB</td><td align="center">165 kB</td><td align="center">+1.29 kB (+0.8%)</td></tr></tbody></table></li><li><h4><code>@firebase/auth-compat</code></h4><table><thead><tr><th align="center">Type</th><th align="center">Base (dcc62c0)</th><th align="center">Merge (01c5d15)</th><th align="center">Diff</th></tr></thead><tbody><tr><td align="left">browser</td><td align="center">20.2 kB</td><td align="center">20.2 kB</td><td align="center">+4 B (+0.0%)</td></tr><tr><td align="left">main</td><td align="center">22.4 kB</td><td align="center">22.4 kB</td><td align="center">+4 B (+0.0%)</td></tr><tr><td align="left">module</td><td align="center">20.2 kB</td><td align="center">20.2 kB</td><td align="center">+4 B (+0.0%)</td></tr></tbody></table></li><li><h4><code>@firebase/auth-cordova</code></h4><table><thead><tr><th align="center">Type</th><th align="center">Base (dcc62c0)</th><th align="center">Merge (01c5d15)</th><th align="center">Diff</th></tr></thead><tbody><tr><td align="left">browser</td><td align="center">164 kB</td><td align="center">166 kB</td><td align="center">+1.29 kB (+0.8%)</td></tr><tr><td align="left">module</td><td align="center">164 kB</td><td align="center">166 kB</td><td align="center">+1.29 kB (+0.8%)</td></tr></tbody></table></li><li><h4><code>@firebase/auth-web-extension</code></h4><table><thead><tr><th align="center">Type</th><th align="center">Base (dcc62c0)</th><th align="center">Merge (01c5d15)</th><th align="center">Diff</th></tr></thead><tbody><tr><td align="left">browser</td><td align="center">141 kB</td><td align="center">142 kB</td><td align="center">+1.29 kB (+0.9%)</td></tr><tr><td align="left">main</td><td align="center">158 kB</td><td align="center">159 kB</td><td align="center">+1.29 kB (+0.8%)</td></tr><tr><td align="left">module</td><td align="center">141 kB</td><td align="center">142 kB</td><td align="center">+1.29 kB (+0.9%)</td></tr></tbody></table></li><li><h4><code>@firebase/auth/internal</code></h4><table><thead><tr><th align="center">Type</th><th align="center">Base (dcc62c0)</th><th align="center">Merge (01c5d15)</th><th align="center">Diff</th></tr></thead><tbody><tr><td align="left">browser</td><td align="center">200 kB</td><td align="center">204 kB</td><td align="center">+3.96 kB (+2.0%)</td></tr><tr><td align="left">main</td><td align="center">172 kB</td><td align="center">173 kB</td><td align="center">+1.53 kB (+0.9%)</td></tr><tr><td align="left">module</td><td align="center">200 kB</td><td align="center">204 kB</td><td align="center">+3.96 kB (+2.0%)</td></tr></tbody></table></li><li><h4><code>bundle</code></h4><table><thead><tr><th align="center">Type</th><th align="center">Base (dcc62c0)</th><th align="center">Merge (01c5d15)</th><th align="center">Diff</th></tr></thead><tbody><tr><td align="left">auth (Anonymous)</td><td align="center">76.7 kB</td><td align="center">77.7 kB</td><td align="center">+997 B (+1.3%)</td></tr><tr><td align="left">auth (EmailAndPassword)</td><td align="center">86.8 kB</td><td align="center">87.8 kB</td><td align="center">+997 B (+1.1%)</td></tr><tr><td align="left">auth (GoogleFBTwitterGitHubPopup)</td><td align="center">104 kB</td><td align="center">105 kB</td><td align="center">+997 B (+1.0%)</td></tr><tr><td align="left">auth (GooglePopup)</td><td align="center">101 kB</td><td align="center">102 kB</td><td align="center">+997 B (+1.0%)</td></tr><tr><td align="left">auth (GoogleRedirect)</td><td align="center">101 kB</td><td align="center">102 kB</td><td align="center">+997 B (+1.0%)</td></tr><tr><td align="left">auth (Phone)</td><td align="center">94.2 kB</td><td align="center">95.2 kB</td><td align="center">+997 B (+1.1%)</td></tr></tbody></table></li><li><h4><code>firebase</code></h4><table><thead><tr><th align="center">Type</th><th align="center">Base (dcc62c0)</th><th align="center">Merge (01c5d15)</th><th align="center">Diff</th></tr></thead><tbody><tr><td align="left">firebase-auth-compat.js</td><td align="center">140 kB</td><td align="center">141 kB</td><td align="center">+893 B (+0.6%)</td></tr><tr><td align="left">firebase-auth-cordova.js</td><td align="center">137 kB</td><td align="center">138 kB</td><td align="center">+973 B (+0.7%)</td></tr><tr><td align="left">firebase-auth-web-extension.js</td><td align="center">119 kB</td><td align="center">120 kB</td><td align="center">+973 B (+0.8%)</td></tr><tr><td align="left">firebase-auth.js</td><td align="center">155 kB</td><td align="center">158 kB</td><td align="center">+2.96 kB (+1.9%)</td></tr><tr><td align="left">firebase-compat.js</td><td align="center">793 kB</td><td align="center">794 kB</td><td align="center">+895 B (+0.1%)</td></tr></tbody></table></li></ul><h3>Test Logs</h3><ul><li>Base (dcc62c0): <a href="https://github.com/firebase/firebase-js-sdk/actions/runs/14113941248">https://github.com/firebase/firebase-js-sdk/actions/runs/14113941248</a></li><li>Merge (01c5d15): <a href="https://github.com/firebase/firebase-js-sdk/actions/runs/14116807432">https://github.com/firebase/firebase-js-sdk/actions/runs/14116807432</a></li></ul><h1></h1><footer><ol><li id="yzioDUGmst"><a href="https://storage.googleapis.com/firebase-sdk-metric-reports/yzioDUGmst.html">https://storage.googleapis.com/firebase-sdk-metric-reports/yzioDUGmst.html</a></li></ol></footer></body></html>
author_association: COLLABORATOR
user: login: google-oss-bot
created_at: 2025-03-25T21:23:21Z
body: <!DOCTYPE html>
<html><body><h1>Size Analysis Report <sup><a href="#lye9reBjpy">1</a></sup></h1>This report is too large (139,584 characters) to be displayed here in a GitHub comment. Please use the below link to see the full report on Google Cloud Storage.<h3>Test Logs</h3><ul><li>Base (dcc62c0): <a href="https://github.com/firebase/firebase-js-sdk/actions/runs/14113941248">https://github.com/firebase/firebase-js-sdk/actions/runs/14113941248</a></li><li>Merge (01c5d15): <a href="https://github.com/firebase/firebase-js-sdk/actions/runs/14116807432">https://github.com/firebase/firebase-js-sdk/actions/runs/14116807432</a></li></ul><h1></h1><footer><ol><li id="lye9reBjpy"><a href="https://storage.googleapis.com/firebase-sdk-metric-reports/lye9reBjpy.html">https://storage.googleapis.com/firebase-sdk-metric-reports/lye9reBjpy.html</a></li></ol></footer></body></html>
author_association: COLLABORATOR
user: login: jamesdaniels
created_at: 2025-03-26T15:24:40Z
body: @sam-gc @DellaBitta @hsubox76 PTAL at the latest iteration, cleaned up a lot. I'll be adding comments, fixing the test I broke with the function rename, and addressing my last few TODOs in the next change-set.
author_association: MEMBER