Testing Firebase Auth emails: emulator links vs a real inbox

Updated October 17, 2026 · Zyntra

The Firebase Auth emulator never sends an email: it keeps each verification, reset and sign-in link and lists them at a REST endpoint, so local tests read the link from there. That proves your flow calls Firebase correctly, but not that the real email arrives with a working link, so add a few tests against a staging project that send to a real inbox.

The emulator keeps the links instead of sending them

When your app calls sendEmailVerification, sendPasswordResetEmail or sendSignInLinkToEmail against the Auth emulator, the emulator prints the link to its terminal and stores it. A test fetches the stored links from GET /emulator/v1/projects/{projectId}/oobCodes; each entry has email, oobCode, oobLink and requestType (VERIFY_EMAIL, PASSWORD_RESET, EMAIL_SIGNIN or VERIFY_AND_CHANGE_EMAIL).

signup.emulator.spec.ts
import { test, expect } from "@playwright/test";

const EMULATOR = "http://127.0.0.1:9099";
const PROJECT_ID = process.env.FIREBASE_PROJECT_ID!;

type OobCode = { email: string; oobLink: string; requestType: string };

async function oobLink(email: string, requestType: string) {
  const res = await fetch(`${EMULATOR}/emulator/v1/projects/${PROJECT_ID}/oobCodes`);
  const { oobCodes } = (await res.json()) as { oobCodes: OobCode[] };
  const mine = oobCodes.filter((c) => c.email === email && c.requestType === requestType);
  if (mine.length === 0) throw new Error(`No ${requestType} link for ${email}`);
  return mine[mine.length - 1]!.oobLink;
}

test("sign-up sends a verification link", async ({ page }) => {
  const email = `user-${Date.now()}@example.com`;
  await page.goto("/signup");
  await page.getByLabel("Email").fill(email);
  await page.getByLabel("Password").fill("a-long-test-password");
  await page.getByRole("button", { name: "Sign up" }).click();
  await expect(page.getByText("Check your inbox")).toBeVisible();

  // Opening the emulator's link applies the code, as clicking it would.
  const res = await fetch(await oobLink(email, "VERIFY_EMAIL"));
  expect(res.ok).toBe(true);
});

Addresses here can be anything, because nothing is delivered. Clear the emulator between runs with DELETE /emulator/v1/projects/{projectId}/accounts, or give every test its own address.

What the emulator cannot tell you

  • The template. The subject, the wording and the sender you set in the console are never used.
  • The action URL. Emulator links point at the emulator. In a real project they point at the default handler on your Firebase Hosting domain, or at the custom action URL you configured, and a wrong custom URL only shows up there.
  • Delivery. Whether the email arrives at all, and how long it takes.

Against a real project: pick the link by mode

Firebase adds mode, oobCode, apiKey and optionally continueUrl and lang to every action link. Choose the link whose mode matches the flow (verifyEmail, resetPassword, recoverEmail) instead of trusting its position in the email.

zyntra.ts
type Email = {
  subject: string;
  body: string;
  html: string | null;
  links: { url: string; text: string }[];
};

// Waits up to 30 s on the server for the first email to the address.
export async function lastEmail(address: string): Promise<Email> {
  const res = await fetch(
    `https://api.zyntra.app/emails/last?email=${encodeURIComponent(address)}`,
    {
      headers: { "X-API-Key": process.env.ZYNTRA_API_KEY! },
      signal: AbortSignal.timeout(45_000),
    },
  );
  if (!res.ok) throw new Error(`No email for ${address}: ${res.status}`);
  return res.json();
}

export const testAddress = (tag: string) =>
  `${process.env.ZYNTRA_TEAM_ID}.${tag}-${Date.now()}@zyntramail.com`;
signup.staging.spec.ts
import { test, expect } from "@playwright/test";
import { lastEmail, testAddress } from "./zyntra";

test("sign-up email verifies the account", async ({ page }) => {
  test.setTimeout(90_000);
  const address = testAddress("firebase-signup");

  await page.goto("/signup");
  await page.getByLabel("Email").fill(address);
  await page.getByLabel("Password").fill("a-long-test-password");
  await page.getByRole("button", { name: "Sign up" }).click();

  const email = await lastEmail(address);
  const verify = email.links.filter(
    (l) => new URL(l.url).searchParams.get("mode") === "verifyEmail",
  );
  expect(verify).toHaveLength(1);

  await page.goto(verify[0]!.url);
  await expect(page.getByText(/verified/i)).toBeVisible();
});

Email enumeration protection changes two flows

Projects created on or after September 15, 2023 have email enumeration protection on by default. Two consequences for tests:

  • Password reset for an unknown address returns no error and sends no email. The only thing to assert is that nothing arrives.
  • Changing the email with updateEmail no longer works. Use verifyBeforeUpdateEmail: the address changes after the user opens the link sent to the new one. In the emulator that link has the request type VERIFY_AND_CHANGE_EMAIL. The email-change post covers the full test.

Questions

Should I drop the emulator tests once I have real-email tests?
No. Emulator tests are fast, free and cover your app's logic on every commit. Keep a handful of real-email tests for the template, the action URL and delivery, and run them against staging.
How do I test that a reset email is not sent to an unknown address?
Request the reset for a fresh address that has no account, then ask the inbox API for its last email and expect none. Zyntra's /emails/last waits up to 30 seconds and answers 404 when nothing came.