diff --git a/README.md b/README.md
index 6f950c3db..1d46311eb 100644
--- a/README.md
+++ b/README.md
@@ -395,6 +395,61 @@ const ui = initializeUI({
});
```
+#### `legacyFetchSignInWithEmail`
+
+The `legacyFetchSignInWithEmail` behavior augments OAuth `auth/account-exists-with-different-credential` flows by calling `fetchSignInMethodsForEmail(auth, email)` and storing the returned methods on the UI instance. In the packaged React and Angular screen components, this recovery state can be rendered as a modal on `SignInAuthScreen` and `OAuthScreen` via the `showLegacySignInRecovery` prop/input, which defaults to `false`. Registering the behavior alone does not show any UI; opt in explicitly (`showLegacySignInRecovery={true}` / `[showLegacySignInRecovery]="true"`) on the screens where you want the built-in recovery modal.
+
+The original pending credential is still preserved, so after the user signs in with the correct method, Firebase UI can continue the existing linking flow.
+
+> **⚠️ Security note:** This behavior has important limitations and security trade-offs. The `fetchSignInMethodsForEmail()` API only works for Firebase projects that have [Email Enumeration Protection disabled](https://cloud.google.com/identity-platform/docs/admin/email-enumeration-protection). Projects created after September 15, 2023 have this protection enabled by default; on those projects, `fetchSignInMethodsForEmail()` returns an empty array and this behavior becomes a no-op. Additionally, when enabled, this behavior will call `fetchSignInMethodsForEmail()` not only for OAuth conflicts (`auth/account-exists-with-different-credential`), but also for plain password sign-in failures (`auth/wrong-password`, `auth/invalid-credential`, `auth/invalid-login-credentials`). This means enabling this behavior causes the app to actively call an enumeration-capable API and surface which sign-in methods exist for an email address on every failed password attempt—directly opposing the enumeration protection that Firebase's generic error codes are otherwise designed to provide. Enable this behavior only if you explicitly understand and accept this UX-vs-security trade-off.
+
+During this recovery flow, a pending OAuth credential is temporarily stored in **plaintext** in the browser's `sessionStorage` so it can be reapplied after the user signs in with the correct method. It is consumed and removed immediately once sign-in succeeds. This is a deliberate, same-origin-scoped, pre-existing trade-off, not an oversight.
+
+```ts
+import { legacyFetchSignInWithEmail } from '@firebase-oss/ui-core';
+
+const ui = initializeUI({
+ app,
+ behaviors: [legacyFetchSignInWithEmail()],
+});
+```
+
+If you want full control over the UI, hide the built-in recovery component on the screen and read the recovery state directly with `useLegacySignInRecovery()`:
+
+```tsx
+import { GitHubSignInButton, GoogleSignInButton, SignInAuthScreen, useLegacySignInRecovery } from '@firebase-oss/ui-react';
+
+function WrongProviderRecovery() {
+ const { recovery, clearRecovery } = useLegacySignInRecovery();
+
+ if (!recovery) {
+ return null;
+ }
+
+ return (
+
+
You have previously signed in with a different method for {recovery.email}.
+ );
+}
+
+export function CustomSignInScreen() {
+ return (
+
+
+
+ );
+}
+```
+
+Angular apps can hide the built-in recovery UI with `showLegacySignInRecovery="false"` and read the same state with `injectLegacySignInRecovery()` / `injectClearLegacySignInRecovery()`.
+
#### `oneTapSignIn`
The `oneTapSignIn` behavior triggers the [Google One Tap](https://developers.google.com/identity/gsi/web/guides/features) experience to render.
@@ -1061,6 +1116,7 @@ By default, any missing translations will fallback to English if not specified.
| onSignIn | `(user: User) => void?` | Callback when sign-in succeeds |
| onForgotPasswordClick | `() => void?` | Callback when forgot password link is clicked |
| onSignUpClick | `() => void?` | Callback when sign-up link is clicked |
+| showLegacySignInRecovery | `boolean?` | Whether to show the built-in legacy sign-in recovery UI (defaults to `false`) |
**`SignUpAuthScreen`**
@@ -1113,6 +1169,7 @@ By default, any missing translations will fallback to English if not specified.
|------|:----:|-------------|
| onSignIn | `(user: User) => void?` | Callback when sign-in succeeds |
| children | `React.ReactNode?` | Child components |
+| showLegacySignInRecovery | `boolean?` | Whether to show the built-in legacy sign-in recovery UI (defaults to `false`) |
**`OAuthButton`**
@@ -1189,6 +1246,10 @@ By default, any missing translations will fallback to English if not specified.
| asChild | `boolean?` | Render as child component using Slot |
| ...props | `ComponentProps<"button">` | Standard button HTML attributes |
+ **`LegacySignInRecovery`**
+
+ Default component for displaying suggested previous sign-in methods from `legacyFetchSignInWithEmail`.
+
**`Card`**
Card container component.
@@ -1251,6 +1312,12 @@ By default, any missing translations will fallback to English if not specified.
Returns `string | undefined`.
+ **`useLegacySignInRecovery`**
+
+ Gets the legacy sign-in recovery state populated by `legacyFetchSignInWithEmail`.
+
+ Returns `{ recovery: LegacySignInRecovery | undefined; clearRecovery: () => void }`.
+
**`useSignInAuthFormSchema`**
Creates a Zod schema for sign-in form validation.
@@ -1700,6 +1767,10 @@ By default, any missing translations will fallback to English if not specified.
Screen component for email/password sign-in.
+ | Input | Type | Description |
+ |-------|:----:|-------------|
+ | showLegacySignInRecovery | `boolean` | Whether to show the built-in legacy sign-in recovery UI (defaults to `false`) |
+
| Output | Type | Description |
|--------|:----:|-------------|
| signIn | `EventEmitter` | Emitted when sign-in succeeds |
@@ -1754,6 +1825,10 @@ By default, any missing translations will fallback to English if not specified.
Screen component for OAuth provider sign-in.
+ | Input | Type | Description |
+ |-------|:----:|-------------|
+ | showLegacySignInRecovery | `boolean` | Whether to show the built-in legacy sign-in recovery UI (defaults to `false`) |
+
| Output | Type | Description |
|--------|:----:|-------------|
| onSignIn | `EventEmitter` | Emitted when OAuth sign-in succeeds |
@@ -1904,6 +1979,12 @@ By default, any missing translations will fallback to English if not specified.
Component that displays redirect errors from Firebase UI authentication flow.
+ **`LegacySignInRecoveryComponent`**
+
+ Selector: `fui-legacy-sign-in-recovery`
+
+ Default component for displaying suggested previous sign-in methods from `legacyFetchSignInWithEmail`.
+
**`ContentComponent`**
Selector: `fui-content`
@@ -1922,6 +2003,18 @@ By default, any missing translations will fallback to English if not specified.
Returns `Signal`.
+ **`injectLegacySignInRecovery`**
+
+ Injects the legacy sign-in recovery state from the UI store as a signal.
+
+ Returns `Signal`.
+
+ **`injectClearLegacySignInRecovery`**
+
+ Injects a callback that clears the current legacy sign-in recovery state.
+
+ Returns `() => void`.
+
**`injectTranslation`**
Injects a translated string for a given category and key.
diff --git a/e2e/tests/legacy-sign-in-recovery.spec.ts b/e2e/tests/legacy-sign-in-recovery.spec.ts
new file mode 100644
index 000000000..ae536e1c2
--- /dev/null
+++ b/e2e/tests/legacy-sign-in-recovery.spec.ts
@@ -0,0 +1,213 @@
+/**
+ * Copyright 2026 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 type { APIRequestContext, Locator, Page } from "@playwright/test";
+import { enUs } from "@firebase-oss/ui-translations";
+import { expect, test } from "../fixtures/test-harness";
+
+const AUTH_EMULATOR_BASE_URL = "http://127.0.0.1:9099";
+const FIREBASE_PROJECT_ID = "demo-test";
+const RECOVERY_PATH = "/screens/legacy-recovery-demo";
+
+const projectsUnderTest = ["react", "angular-example"] as const;
+
+type EmulatorUser = {
+ localId: string;
+ providerUserInfo?: Array<{ providerId: string }>;
+};
+
+function uniqueEmail(projectName: string, label: string): string {
+ return `${projectName}-${label}-${crypto.randomUUID()}@example.test`;
+}
+
+async function clearEmulatorUsers(request: APIRequestContext): Promise {
+ const response = await request.delete(
+ `${AUTH_EMULATOR_BASE_URL}/emulator/v1/projects/${FIREBASE_PROJECT_ID}/accounts`
+ );
+ expect(response.ok(), await response.text()).toBe(true);
+}
+
+async function createGoogleAccount(request: APIRequestContext, email: string): Promise {
+ const claims = {
+ sub: crypto.randomUUID(),
+ name: "Playwright Recovery User",
+ email,
+ email_verified: true,
+ };
+ const requestUri =
+ `${AUTH_EMULATOR_BASE_URL}/emulator/auth/handler?providerId=google.com&id_token=` +
+ encodeURIComponent(JSON.stringify(claims));
+ const response = await request.post(
+ `${AUTH_EMULATOR_BASE_URL}/identitytoolkit.googleapis.com/v1/accounts:signInWithIdp?key=fake-api-key`,
+ {
+ data: {
+ requestUri,
+ sessionId: "ValueNotUsedByAuthEmulator",
+ returnSecureToken: true,
+ returnIdpCredential: true,
+ },
+ }
+ );
+
+ expect(response.ok(), await response.text()).toBe(true);
+}
+
+/**
+ * The Auth emulator widget marks every OAuth email as verified. Its backend then silently merges
+ * providers with matching emails instead of returning account-exists-with-different-credential.
+ * Production providers can return an unverified email, so rewrite only the attempted provider's
+ * emulator assertion to exercise Firebase Auth's real conflict response and credential payload.
+ */
+async function forceUnverifiedProviderEmail(page: Page, providerId: string): Promise {
+ await page.route("**/accounts:signInWithIdp?*", async (route) => {
+ const request = route.request();
+ const body = request.postDataJSON() as { requestUri: string };
+ const requestUri = new URL(body.requestUri);
+
+ if (requestUri.searchParams.get("providerId") === providerId) {
+ const idToken = requestUri.searchParams.get("id_token");
+ if (idToken) {
+ const claims = JSON.parse(idToken) as { email_verified?: boolean };
+ claims.email_verified = false;
+ requestUri.searchParams.set("id_token", JSON.stringify(claims));
+ body.requestUri = requestUri.toString();
+ }
+ }
+
+ await route.continue({ postData: JSON.stringify(body) });
+ });
+}
+
+async function completeNewProviderSignIn(
+ page: Page,
+ buttonName: string,
+ emulatorProviderName: string,
+ email: string
+): Promise {
+ const popupPromise = page.waitForEvent("popup");
+ await page.getByRole("button", { name: buttonName, exact: true }).click();
+ const popup = await popupPromise;
+
+ const addAccountButton = popup.getByRole("button", { name: "Add new account" });
+ await expect(addAccountButton).toBeVisible();
+ const emailInput = popup.locator("#email-input");
+ await expect(async () => {
+ await addAccountButton.click();
+ await expect(emailInput).toBeVisible({ timeout: 1_000 });
+ }).toPass({ timeout: 10_000 });
+
+ await emailInput.fill(email);
+ await popup.locator("#display-name-input").fill("Playwright Recovery User");
+ await Promise.all([
+ popup.waitForEvent("close"),
+ popup.getByRole("button", { name: new RegExp(`Sign in with ${emulatorProviderName}`, "i") }).click(),
+ ]);
+}
+
+async function completeExistingGoogleSignIn(page: Page, dialog: Locator, email: string): Promise {
+ const popupPromise = page.waitForEvent("popup");
+ await dialog.getByRole("button", { name: enUs.translations.labels.signInWithGoogle }).click();
+ const popup = await popupPromise;
+
+ const existingAccount = popup.getByText(email, { exact: true });
+ await expect(existingAccount).toBeVisible();
+ await Promise.all([popup.waitForEvent("close"), existingAccount.click()]);
+}
+
+async function getUsersByEmail(request: APIRequestContext, email: string): Promise {
+ const response = await request.post(
+ `${AUTH_EMULATOR_BASE_URL}/identitytoolkit.googleapis.com/v1/projects/${FIREBASE_PROJECT_ID}/accounts:lookup`,
+ {
+ headers: { authorization: "Bearer owner" },
+ data: { email: [email] },
+ }
+ );
+ expect(response.ok(), await response.text()).toBe(true);
+
+ const body = (await response.json()) as { users?: EmulatorUser[] };
+ return body.users ?? [];
+}
+
+for (const projectName of projectsUnderTest) {
+ test.describe(`legacy sign-in recovery (${projectName})`, () => {
+ test.describe.configure({ timeout: 90_000 });
+
+ test.beforeEach(async ({ request }, testInfo) => {
+ test.skip(testInfo.project.name !== projectName, `runs only on the ${projectName} project`);
+ await clearEmulatorUsers(request);
+ });
+
+ test("shows previous methods and links the pending OAuth credential", async ({ page, request }) => {
+ const email = uniqueEmail(projectName, "default");
+ await createGoogleAccount(request, email);
+ await forceUnverifiedProviderEmail(page, "github.com");
+
+ await page.goto(RECOVERY_PATH);
+ await completeNewProviderSignIn(page, enUs.translations.labels.signInWithGitHub, "GitHub.com", email);
+
+ const dialog = page.getByRole("dialog", {
+ name: enUs.translations.messages.legacySignInRecoverySelectMethod,
+ });
+ await expect(dialog).toBeVisible();
+ await expect(dialog).toContainText(email);
+ await expect(dialog.getByRole("button", { name: enUs.translations.labels.signInWithGoogle })).toBeVisible();
+ await expect(page.getByText(enUs.translations.errors.accountExistsWithDifferentCredential)).toBeVisible();
+ // "pendingCred" mirrors PENDING_CREDENTIAL_STORAGE_KEY in packages/core/src/behaviors/legacy-fetch-sign-in-with-email.ts.
+ // Left as a literal here since page.evaluate runs in the browser context and can't import from the package.
+ expect(await page.evaluate(() => window.sessionStorage.getItem("pendingCred"))).not.toBeNull();
+
+ await completeExistingGoogleSignIn(page, dialog, email);
+
+ await expect(dialog).toHaveCount(0);
+ await expect.poll(() => page.evaluate(() => window.sessionStorage.getItem("pendingCred"))).toBeNull();
+
+ await expect
+ .poll(async () => {
+ const users = await getUsersByEmail(request, email);
+ return {
+ count: users.length,
+ providers: users[0]?.providerUserInfo?.map(({ providerId }) => providerId).sort(),
+ };
+ })
+ .toEqual({ count: 1, providers: ["github.com", "google.com"] });
+ });
+
+ test("supports custom recovery UI and clears it when dismissed", async ({ page, request }) => {
+ const email = uniqueEmail(projectName, "handled");
+ await createGoogleAccount(request, email);
+ await forceUnverifiedProviderEmail(page, "github.com");
+
+ await page.goto(`${RECOVERY_PATH}?legacyRecovery=handled`);
+ await completeNewProviderSignIn(page, enUs.translations.labels.signInWithGitHub, "GitHub.com", email);
+
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ const customRecovery = page.getByTestId("custom-legacy-recovery");
+ await expect(customRecovery).toBeVisible();
+ await expect(page.getByTestId("custom-legacy-recovery-email")).toHaveText(email);
+ await expect(page.getByTestId("custom-legacy-recovery-methods")).toContainText("google.com");
+ // "pendingCred" mirrors PENDING_CREDENTIAL_STORAGE_KEY in packages/core/src/behaviors/legacy-fetch-sign-in-with-email.ts.
+ // Left as a literal here since page.evaluate runs in the browser context and can't import from the package.
+ expect(await page.evaluate(() => window.sessionStorage.getItem("pendingCred"))).not.toBeNull();
+
+ await page.getByRole("button", { name: "Custom dismiss" }).click();
+ await expect(customRecovery).toHaveCount(0);
+ // clearLegacySignInRecovery() removes the pending credential synchronously, so no polling
+ // is needed here (contrast with the completeExistingGoogleSignIn case above, which clears
+ // it via an async sign-in flow).
+ expect(await page.evaluate(() => window.sessionStorage.getItem("pendingCred"))).toBeNull();
+ });
+ });
+}
diff --git a/examples/angular/src/app/app.config.ts b/examples/angular/src/app/app.config.ts
index 69676139b..05d698804 100644
--- a/examples/angular/src/app/app.config.ts
+++ b/examples/angular/src/app/app.config.ts
@@ -23,7 +23,7 @@ import { provideClientHydration, withEventReplay } from "@angular/platform-brows
import { provideFirebaseApp, initializeApp } from "@angular/fire/app";
import { provideAuth, getAuth, connectAuthEmulator } from "@angular/fire/auth";
import { provideFirebaseUI, provideFirebaseUIPolicies } from "@firebase-oss/ui-angular";
-import { initializeUI } from "@firebase-oss/ui-core";
+import { initializeUI, legacyFetchSignInWithEmail } from "@firebase-oss/ui-core";
const firebaseConfig = {
apiKey: "AIzaSyCvMftIUCD9lUQ3BzIrimfSfBbCUQYZf-I",
@@ -48,7 +48,7 @@ export const appConfig: ApplicationConfig = {
}
return auth;
}),
- provideFirebaseUI((apps) => initializeUI({ app: apps[0] })),
+ provideFirebaseUI((apps) => initializeUI({ app: apps[0], behaviors: [legacyFetchSignInWithEmail()] })),
provideFirebaseUIPolicies(() => ({
termsOfServiceUrl: "https://www.google.com",
privacyPolicyUrl: "https://www.google.com",
diff --git a/examples/angular/src/app/routes.ts b/examples/angular/src/app/routes.ts
index fcafffd4a..e06a22b98 100644
--- a/examples/angular/src/app/routes.ts
+++ b/examples/angular/src/app/routes.ts
@@ -44,6 +44,13 @@ export const routes: RouteConfig[] = [
loadComponent: () =>
import("./screens/sign-in-auth-screen-w-oauth").then((m) => m.SignInAuthScreenWithOAuthComponent),
},
+ {
+ name: "Legacy Recovery Demo",
+ description:
+ "Use this screen to test wrong-provider recovery for email/password and OAuth attempts in a project that has email enumeration protection disabled.",
+ path: "/screens/legacy-recovery-demo",
+ loadComponent: () => import("./screens/legacy-recovery-demo").then((m) => m.LegacyRecoveryDemoComponent),
+ },
{
name: "Sign Up Screen",
description: "A sign up screen with email and password.",
diff --git a/examples/angular/src/app/screens/legacy-recovery-demo.ts b/examples/angular/src/app/screens/legacy-recovery-demo.ts
new file mode 100644
index 000000000..df56b5c2a
--- /dev/null
+++ b/examples/angular/src/app/screens/legacy-recovery-demo.ts
@@ -0,0 +1,111 @@
+/**
+ * Copyright 2026 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 { Component, inject } from "@angular/core";
+import { CommonModule } from "@angular/common";
+import {
+ SignInAuthScreenComponent,
+ ContentComponent,
+ GoogleSignInButtonComponent,
+ FacebookSignInButtonComponent,
+ AppleSignInButtonComponent,
+ GitHubSignInButtonComponent,
+ MicrosoftSignInButtonComponent,
+ TwitterSignInButtonComponent,
+ YahooSignInButtonComponent,
+ injectLegacySignInRecovery,
+ injectClearLegacySignInRecovery,
+} from "@firebase-oss/ui-angular";
+import { ActivatedRoute, Router } from "@angular/router";
+
+@Component({
+ selector: "app-legacy-recovery-demo",
+ standalone: true,
+ imports: [
+ CommonModule,
+ SignInAuthScreenComponent,
+ ContentComponent,
+ GoogleSignInButtonComponent,
+ FacebookSignInButtonComponent,
+ AppleSignInButtonComponent,
+ GitHubSignInButtonComponent,
+ MicrosoftSignInButtonComponent,
+ TwitterSignInButtonComponent,
+ YahooSignInButtonComponent,
+ ],
+ template: `
+
+
Legacy recovery demo
+
+ Use this screen to test wrong-provider recovery with both email/password and OAuth attempts, in a project that
+ has email enumeration protection disabled.
+
+
+ Suggested flow: create an account with Google first, sign out, then come back here and try the same email with
+ email/password or another provider like GitHub.
+
+ }
+ `,
+ styles: [],
+})
+/**
+ * Demo screen combining email/password and OAuth sign-in, used to exercise the
+ * `legacyFetchSignInWithEmail` recovery flow in e2e tests.
+ */
+export class LegacyRecoveryDemoComponent {
+ private router = inject(Router);
+ private route = inject(ActivatedRoute);
+
+ recovery = injectLegacySignInRecovery();
+ private clearLegacyRecovery = injectClearLegacySignInRecovery();
+
+ // e2e scenario switch: "handled" suppresses the default recovery modal in favor of the custom
+ // UI above, proving apps can opt out of the built-in flow.
+ handled = this.route.snapshot.queryParamMap.get("legacyRecovery") === "handled";
+
+ onSignIn() {
+ this.router.navigate(["/"]);
+ }
+
+ clearRecovery() {
+ this.clearLegacyRecovery();
+ }
+}
diff --git a/examples/react/src/firebase/firebase.ts b/examples/react/src/firebase/firebase.ts
index e0132cda3..ac1996822 100644
--- a/examples/react/src/firebase/firebase.ts
+++ b/examples/react/src/firebase/firebase.ts
@@ -16,7 +16,7 @@
"use client";
-import { countryCodes, initializeUI, oneTapSignIn } from "@firebase-oss/ui-core";
+import { countryCodes, initializeUI, legacyFetchSignInWithEmail, oneTapSignIn } from "@firebase-oss/ui-core";
import { getApps, initializeApp } from "firebase/app";
import { connectAuthEmulator, getAuth } from "firebase/auth";
@@ -30,6 +30,7 @@ export const ui = initializeUI({
app: firebaseApp,
behaviors: [
// autoAnonymousLogin(),
+ legacyFetchSignInWithEmail(),
oneTapSignIn({
clientId: "616577669988-led6l3rqek9ckn9t1unj4l8l67070fhp.apps.googleusercontent.com",
}),
diff --git a/examples/react/src/routes.ts b/examples/react/src/routes.ts
index f46f72903..5e92925fc 100644
--- a/examples/react/src/routes.ts
+++ b/examples/react/src/routes.ts
@@ -1,6 +1,7 @@
import SignInAuthScreenPage from "./screens/sign-in-auth-screen";
import SignInAuthScreenWithHandlersPage from "./screens/sign-in-auth-screen-w-handlers";
import SignInAuthScreenWithOAuthPage from "./screens/sign-in-auth-screen-w-oauth";
+import LegacyRecoveryDemoPage from "./screens/legacy-recovery-demo";
import SignUpAuthScreenPage from "./screens/sign-up-auth-screen";
import SignUpAuthScreenWithHandlersPage from "./screens/sign-up-auth-screen-w-handlers";
import SignUpAuthScreenWithOAuthPage from "./screens/sign-up-auth-screen-w-oauth";
@@ -32,6 +33,13 @@ export const routes = [
path: "/screens/sign-in-auth-screen-w-oauth",
component: SignInAuthScreenWithOAuthPage,
},
+ {
+ name: "Legacy Recovery Demo",
+ description:
+ "Use this screen to test wrong-provider recovery for email/password and OAuth attempts in a project that has email enumeration protection disabled.",
+ path: "/screens/legacy-recovery-demo",
+ component: LegacyRecoveryDemoPage,
+ },
{
name: "Sign Up Screen",
description: "A sign up screen with email and password.",
diff --git a/examples/react/src/screens/legacy-recovery-demo.tsx b/examples/react/src/screens/legacy-recovery-demo.tsx
new file mode 100644
index 000000000..1c224aafb
--- /dev/null
+++ b/examples/react/src/screens/legacy-recovery-demo.tsx
@@ -0,0 +1,97 @@
+/**
+ * Copyright 2026 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 {
+ AppleSignInButton,
+ FacebookSignInButton,
+ GitHubSignInButton,
+ GoogleSignInButton,
+ MicrosoftSignInButton,
+ SignInAuthScreen,
+ TwitterSignInButton,
+ YahooSignInButton,
+ useLegacySignInRecovery,
+} from "@firebase-oss/ui-react";
+import { useNavigate, useSearchParams } from "react-router";
+
+/**
+ * A minimal custom recovery UI, used by the `?legacyRecovery=handled` e2e scenario to prove that
+ * apps can suppress the default `` modal (via `showLegacySignInRecovery={false}`)
+ * and build their own UI on top of `useLegacySignInRecovery()`.
+ */
+function CustomLegacyRecovery() {
+ const { recovery, clearRecovery } = useLegacySignInRecovery();
+
+ if (!recovery) {
+ return null;
+ }
+
+ return (
+
+ );
+}
+
+export default function LegacyRecoveryDemoPage() {
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ // e2e scenario switch: "handled" suppresses the default recovery modal in favor of the custom
+ // UI above, proving apps can opt out of the built-in flow.
+ const handled = searchParams.get("legacyRecovery") === "handled";
+
+ return (
+
+
+
Legacy recovery demo
+
+ Use this screen to test wrong-provider recovery with both email/password and OAuth attempts, in a project that
+ has email enumeration protection disabled.
+
+
+ Suggested flow: create an account with Google first, sign out, then come back here and try the same email with
+ email/password or another provider like GitHub.
+