How to Test Firebase Authentication Flows
Short answer
Firebase Auth spans client SDK state, ID tokens, Cloud Functions, Firestore profiles, and security rules—UI-green login is not proof of a valid session. Use the Auth emulator (or a dedicated test project), per-run users via Admin SDK or custom tokens, and probe Assert on APIs and Firestore—not shared test@company.com accounts in parallel CI.
Part of Testing Guides by auth and identity.
Who this is for
Teams shipping Firebase Authentication on web (often with Firestore, Cloud Functions, and Hosting) who need Playwright E2E that survives parallel CI, provider additions, and verification gates—not one-off scripts that log in once against staging.
Typical stacks: Next.js + Firebase SDK, Vite/React + Firebase, Flutter web with Firebase, or Lovable/Supabase-adjacent apps that added Firebase Auth later.
Why testing Firebase Auth matters
Auth bugs are rarely cosmetic. They show up as:
- Revenue loss — paywalled features reachable without a valid ID token; trial users retain access after subscription lapse because client state says "logged in" while custom claims were revoked.
- Security incidents — cross-tenant data exposure when Firestore rules trust client-written
tenantId; disabled users still holding refresh tokens; OAuth account linking creating duplicate identities. - Support load — email verification loops, password reset links that expire silently, phone OTP rate limits blocking legitimate users in prod while tests never covered resend cooldown.
- Compliance exposure — GDPR/CCPA delete-account flows that remove Auth user but leave PII in Firestore; audit logs missing for admin impersonation.
Firebase makes it easy to appear logged in on the client (onAuthStateChanged fires) while your backend rejects requests— or the opposite: API accepts stale tokens because verify middleware is misconfigured. E2E must assert the full boundary: Auth user record, ID token claims, Firestore profile, and protected HTTP routes.
Complexity map
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
| Emulator vs cloud project | Identity Toolkit rate limits on signup | Random 429 in CI | Auth emulator for automated runs |
connectAuthEmulator order | Called after first Auth op | Silent connection to prod | Init emulator before app bootstrap in tests |
| Email verification gate | Protected route checks emailVerified | Cannot click real inbox in CI | Admin SDK toggle or transactional email guide |
| Password reset | OOB code single-use, expiry | Link consumed by bot prefetch | Extract oobCode param; call confirm API directly |
| Wrong password / lockout | Generic Firebase error codes | Asserting toast copy flakes | Probe session null + auth/wrong-password if exposed |
| Custom token login | Service account in CI secrets | Skipped entirely | Seed route mints token per runId |
| Custom claims refresh | Claims updated server-side | Client token stale up to 1h | Force token refresh or re-login in test |
| Google / Apple OAuth | Popup blocked headless | Hangs forever | Split: emulator fake provider OR API Arrange for post-login tests |
| Anonymous → permanent | Link credential collision | "Account exists" edge | Seed anonymous user; link with fixture email |
| Phone auth | Real SMS cost | Untested in CI | Emulator test numbers + fixed codes |
| Firestore profile | onCreate function lag | Probe finds no /users/{uid} | expect.poll on probe |
| Security rules | Client set() appears to work locally | False confidence | Probe via Admin SDK; rules unit tests separately |
| Multi-tenant (Identity Platform) | Wrong tenant ID in token | Cross-tenant access | Seed per-tenant user; probe tenant claim |
| Disabled user | disabled: true on record | Error message varies | Probe sign-in failure + no session cookie |
| Token refresh / expiry | Long test suite | Mid-test 401 | Short-lived test tokens or refresh helper |
| Account deletion | Auth deleted, Firestore orphaned | GDPR failure | Probe both Auth and Firestore absence |
Emulator vs dedicated test project
| Auth emulator | Firebase test project (cloud) | |
|---|---|---|
| CI cost | Free, local | Quota + billing alerts |
| Rate limits | None for auth ops | Identity Toolkit limits apply |
| OAuth providers | Fake/simplified | Real Google/Apple redirects |
| Phone SMS | Fixed test codes | Test phone numbers configured in console |
| Parallel workers | Excellent (isolated) | Risky if sharing users |
| Production parity | Rules/emulator quirks | Closest to prod behavior |
Recommendation: Default all Playwright CI to the Auth + Firestore emulators. Keep a small nightly or pre-release job against a dedicated test Firebase project only for flows that truly require real OAuth redirects—document which scenarios need that job so they do not block every PR.
Start emulators in CI
# firebase.json should declare emulators.auth.port (default 9099) and firestore
firebase emulators:start --only auth,firestore --project demo-test &
EMULATOR_PID=$!
# Wait for ports — do not start Playwright before this
npx wait-on tcp:9099 tcp:8080
npm run test:e2e
kill $EMULATOR_PID
Set for Admin SDK and server-side seed routes:
export FIREBASE_AUTH_EMULATOR_HOST=127.0.0.1:9099
export FIRESTORE_EMULATOR_HOST=127.0.0.1:8080
Client SDK (must run before any auth method):
import { connectAuthEmulator, getAuth } from 'firebase/auth';
const auth = getAuth();
if (process.env.NEXT_PUBLIC_USE_AUTH_EMULATOR === 'true') {
connectAuthEmulator(auth, 'http://127.0.0.1:9099', { disableWarnings: true });
}
Import order matters: if your app calls signInAnonymously on module load before connectAuthEmulator, tests hit production Auth silently.
Arrange: users without UI signup
Most E2E tests are about features after login, not the login form itself. Creating users through the signup UI for every spec is slow, couples tests to CSS changes, and collides in parallel CI.
Pattern A — Admin SDK in seed route (preferred)
Expose a test-only HTTP route (guarded by env flag) that your Playwright tests call in Arrange:
// POST /api/test/seed-user
// Body: { runId: string, emailVerified?: boolean, claims?: object }
// Response: { customToken: string, uid: string }
import { getAuth } from 'firebase-admin/auth';
const email = `e2e-${runId}@test.local`;
const user = await getAuth().createUser({
email,
password: `pw-${runId}`,
emailVerified: emailVerified ?? true,
});
if (claims) {
await getAuth().setCustomUserClaims(user.uid, claims);
}
const customToken = await getAuth().createCustomToken(user.uid, claims);
return { customToken, uid: user.uid };
Playwright Arrange:
const runId = test.info().parallelIndex + '-' + Date.now();
const { customToken } = await request.post('/api/test/seed-user', {
data: { runId, emailVerified: true },
}).then(r => r.json());
await page.goto('/test/sign-in-with-token'); // app page that calls signInWithCustomToken
await page.evaluate(async (token) => {
const { getAuth, signInWithCustomToken } = await import('firebase/auth');
await signInWithCustomToken(getAuth(), token);
}, customToken);
Pattern B — session cookie for SSR apps
Next.js and other SSR frameworks often use session cookies minted server-side (firebase-admin createSessionCookie). Seed route creates user + sets HttpOnly cookie—Playwright context.addCookies() or one navigation to /api/test/login?runId= avoids client SDK timing issues entirely.
Pattern C — UI signup only when testing signup
Reserve UI signup specs for:
- Registration form validation
- Password strength policy messaging
- Terms acceptance checkbox
- Marketing attribution on signup
Everything else: Pattern A or B.
Email and password flows
| Flow | What goes wrong in prod | Arrange | Act | Assert |
|---|---|---|---|---|
| Login success | Session not propagated to SSR | Seed verified user | UI login OR custom token | Probe /api/me returns uid; Firestore profile exists |
| Wrong password | Brute-force lockout untested | Seed user | Bad password | Probe no session; optional auth/wrong-password |
| Unverified email gate | Dashboard loads, API 403 | emailVerified: false | Hit protected route | Probe 401/403 until Admin verifies |
| Weak password rejected | Weak pw stored | None | Submit short password | Probe user not created |
| Email already in use | Duplicate accounts | Seed existing email | Register again | Probe single user row; UI error optional |
| Password reset happy path | Link expired in email | Seed user | Request reset | Mailtrap inbox → extract link (email guide) |
| Password reset expired code | User stuck | Seed expired OOB code | Submit new password | Probe failure; no password change |
| Change password re-auth | Recent login required | Seed user, stale session | Change password | Probe requires re-auth flow |
Firebase returns stable error codes (auth/user-not-found, auth/wrong-password)—prefer probing API behavior over asserting exact error string copy, which changes with i18n.
Email verification
Three legitimate approaches—pick by what the test actually covers:
-
Admin SDK (fastest for gates) — create user with
emailVerified: false, confirm protected API returns 403, callupdateUser({ emailVerified: true }), confirm 200. Does not test email template or link format. -
Disposable inbox (full path) — use Mailtrap/Mailosaur to capture Firebase verification email, extract link,
page.goto(link). Tests template + routing. See transactional email testing. -
Emulator OOB helpers — emulator exposes verification codes in some flows; check current Firebase emulator docs for OOB retrieval APIs.
Bot prefetch pitfall: some email scanners GET verification links before the user does, consuming single-use codes. Mitigations: use emulator Admin toggle for CI; for inbox tests, extract oobCode query param and POST to Firebase's verify endpoint instead of full link navigation (same pattern as Stytch E2E guidance).
Custom tokens and custom claims
Custom claims drive authorization in many Firebase apps (role, tenantId, plan). Claims embed in the ID token but refresh asynchronously on the client after setCustomUserClaims.
await admin.auth().setCustomUserClaims(uid, {
role: 'editor',
tenantId: runId,
plan: 'pro',
});
After updating claims in Arrange, force refresh in the browser before Act:
await page.evaluate(async () => {
const auth = (await import('firebase/auth')).getAuth();
await auth.currentUser?.getIdToken(true);
});
E2E negative test: user with role: 'viewer' must receive 403 from admin API even if UI hides buttons—probe the API, not just hidden DOM.
For multi-tenant Identity Platform, pass tenantId when creating users and minting tokens; assert probes cannot read another tenant's Firestore path.
Google, Apple, and social sign-in
Real OAuth in headless Linux CI is painful (popups, 2FA on test Google accounts, Apple JS SDK constraints). Use a tiered strategy:
| Tier | Coverage | When |
|---|---|---|
| Post-login E2E | Custom token as Google-linked user | Every PR — features after OAuth |
| Emulator / fake provider | Button → emulator stub user | PR if emulator supports provider |
| Headed / manual / nightly | Full Google popup | Weekly or pre-release |
| TrueCoverage | Prod auth_provider=google.com share | Prioritize tier-2/3 if slice is large |
Linking flows (same email, password account + Google) need explicit scenarios: probe one Firebase uid after link; attempt duplicate link and expect auth/account-exists-with-different-credential.
See also OAuth social login guide.
Anonymous authentication
Anonymous auth is easy to overlook but common in demos and mobile-first web apps.
Test:
- Anonymous user can access allowed routes
- Linking email/password or Google promotes the same uid (probe uid unchanged,
isAnonymousfalse) - Reinstall / cleared storage creates new anonymous user—no data bleed (probe old uid inaccessible)
- Server rejects anonymous token on paid APIs (probe 403)
Phone authentication
Never send real SMS in CI. With the Auth emulator, use documented test phone numbers—verification codes are fixed (commonly 123456 for +1 650-555-3434 in emulator docs; verify against your emulator version).
Cover:
- Valid code → session established, probe profile created
- Invalid code → no session
- Resend cooldown — rapid resend should fail (may need clock fixture)
- Phone number already linked to another account
For cloud test projects, configure test phone numbers in Firebase Console with fixed codes—still avoid real numbers.
Firestore profile sync and Cloud Functions
A typical pattern:
Auth onCreate → Cloud Function → creates /users/{uid}
Tests that probe Firestore immediately after signup flake when the function is cold or slow.
await expect.poll(
async () => {
const res = await request.get(`/api/test/probe-user/${uid}`);
return (await res.json()).displayName;
},
{ timeout: 15_000 },
).toBe('Expected Name');
Also test failure paths: function throws → user sees error state, Auth user rolled back or marked incomplete (probe both sides). If you use emulator functions, wire Functions emulator too—Auth-only emulator with prod Functions causes confusing partial behavior.
Security rules and Auth together
Client SDK writes can appear to succeed in dev with loose rules. Minimum E2E bar:
- User A cannot read
/users/{userB_uid}/private(probe via client SDK in test or API returning rules evaluation) - Unauthenticated request to protected collection fails
- Custom claim required for admin collection—token without claim fails
Run @firebase/rules-unit-testing in CI for exhaustive rules matrices; keep one E2E per critical isolation boundary as integration proof.
Disabled accounts, deletion, and session revocation
| Action | Assert |
|---|---|
| Admin disables user | Sign-in fails; existing refresh tokens rejected on next API call |
| User deletes account | Auth user gone; Firestore PII removed or anonymized (probe) |
| Password changed elsewhere | Old session invalidated if you implement revocation |
signOut | Cookie cleared; probe /api/me 401 |
Deletion flows are GDPR-relevant—probe downstream systems (Storage objects, Algolia index row), not only deleteUser().
CI checklist
connectAuthEmulatorbefore app init in test envFIREBASE_AUTH_EMULATOR_HOSTset for Admin SDK seed routes- Unique email per worker:
e2e-${parallelIndex}-${runId}@test.local - No production Firebase config in CI secrets for default PR job
- Seed routes disabled when
NODE_ENV=production - Global teardown removes emulator users if reusing long-lived emulator (usually ephemeral CI VMs skip this)
- Playwright
storageStateper test when isolating sessions—do not reuse logged-in state across specs with different roles
Anti-patterns
| Anti-pattern | Why it fails | Better approach |
|---|---|---|
Shared test@company.com | Parallel workers overwrite password/session | Per-run email via seed |
| UI login in every spec | 5–15s overhead; form churn | Custom token Arrange |
| Assert Firebase UI error text | i18n and copy changes | Probe session + error code |
Only client onAuthStateChanged | SSR/API may disagree | Probe HTTP with Bearer token |
| Skip unverified-email path | Ship paywall bypass | Negative scenario with Admin toggle |
| Test against prod Firebase | Quota, PII, lockout | Emulator default |
waitForTimeout(3000) after signup | Function still cold | expect.poll probe |
| Ignore custom claim refresh | Flaky role tests | getIdToken(true) after claims update |
Example scenario
Situation: An unverified user signs in and navigates to /dashboard.
Expected outcome: Access denied until email is verified—no partial data leak.
Why UI-only automation breaks: Dashboard shell renders but API returns empty arrays—test passes on skeleton UI.
- Arrange: Auth emulator + Admin SDK create user with emailVerified: false for runId.
- Act: Sign in via custom token or UI, then goto /dashboard.
- Assert: Probe /api/me or /api/dashboard returns 403; after Admin sets emailVerified: true and token refresh, probe returns 200 with user data.
TestChimp workflow: Instrument sign_in events with auth_provider and verification_state; compare prod vs test runs in TrueCoverage.
Same Arrange/Act/Assert pattern as expired-coupon checkout.
Connect scenarios to your QA workflow
Capture business rules in markdown test plans and enforce them with seed routes and probe Assert. Link SmartTests with // @Scenario: for requirement traceability. Use /testchimp test on PRs; /testchimp explore on SmartTest paths for non-functional gaps (ExploreChimp).
Related scenarios
- OAuth social login — popup vs redirect, account linking
- Magic links & passwordless — email OOB overlap
- MFA / 2FA — Firebase multi-factor enrollment
- RBAC permissions — claims vs role matrices
- Transactional email — verification and reset emails
- Built with Lovable — common Firebase + vibe-coded stacks
External references
- Firebase Auth emulator
- Connect Firestore emulator
- Admin SDK create custom tokens
- Custom claims
- Phone auth testing
- Rules unit testing
- Identity Platform multi-tenancy
Frequently asked questions
Should I use the Firebase Auth emulator or a test project for E2E?
Use the Auth emulator for every PR—no rate limits, parallel-safe users, no PII in Google cloud. Reserve a dedicated cloud test project for nightly jobs that must exercise real Google or Apple OAuth redirects; document which scenarios require that job.
How do I log in without the signup UI on every test?
Add a test-only seed route that creates a user with Admin SDK and returns a custom token or session cookie scoped to runId. Playwright Arrange calls signInWithCustomToken or sets cookie, then tests focus on post-login behavior.
How do I test email verification without clicking emails in CI?
Fast path: create user with emailVerified false, probe protected API 403, Admin updateUser to verified true, refresh token, probe 200. Full path: capture Firebase verification email via Mailtrap and navigate link—or extract oobCode to avoid bot prefetch consuming the link.
Why do my tests pass login but fail on API calls?
Client onAuthStateChanged does not guarantee SSR or REST middleware sees a valid ID token. Probe your API with the same session cookie or Authorization header the app uses. After setCustomUserClaims, call getIdToken(true) before acting as that role.
How do I test Firestore user profiles created by Cloud Functions?
Poll a probe endpoint or Firestore read with expect.poll and 10–15s timeout—never fixed sleep. If functions run in cloud while Auth uses emulator, align emulators or probe via Admin SDK consistently.
Can I run Firebase Auth E2E in parallel Playwright workers?
Yes with emulator + unique email per worker (e2e-{parallelIndex}-{timestamp}@test.local). Avoid shared credentials. storageState fixtures should not reuse sessions across specs with different roles or tenants.
We support email, Google, and phone sign-in—how do we know each is covered?
Compare auth_provider and verification_state distributions in prod vs test runs via TrueCoverage. If Google dominates prod but tests only cover password, prioritize OAuth-tier tests or nightly headed runs for that slice—track scenarios in markdown with // @Scenario: links.
Firebase + Supabase—which auth guide applies?
Firebase-first stacks use this guide; Supabase Auth + RLS uses [Supabase E2E](/guides/integrations/testing-supabase-playwright). Both need per-run users—see [shared auth gotcha](/guides/gotchas/e2e-shared-auth-state-pollution).
Should every spec call the login UI?
No—UI login in every test is slow and flaky. Use custom token or cookie Arrange; reserve one spec for login UX. See [login every test gotcha](/guides/gotchas/playwright-login-every-test-slow-flaky).
Apply these patterns in your repo
Run `/testchimp init` to connect TestChimp to your repo, then `/testchimp test` on PRs to turn these patterns into maintained SmartTests. Use `/testchimp evolve` when you want to expand coverage as your app grows.