Email and links

PreviousNext

Password reset, address verification and email-link sign-in — what works out of the box, and what the Dynamic Links shutdown changed.

Firebase sends three kinds of action email: password reset, address verification, and sign-in links. All three arrive as a URL carrying mode and oobCode parameters.

What works out of the box

FlowDefault behaviourNeeds setup
Password resetOpens Firebase's hosted page in a browserNo
Email verificationOpens Firebase's hosted pageNo
Email-link sign-inButton is hiddenYes

Password reset and verification work with zero configuration. The user changes their password on Google's page and comes back to the app to sign in — a perfectly good experience, and the one the starter ships with.

Email-link sign-in cannot degrade that way: the whole point is landing back in the app, so sign-in.tsx hides the entry point until it is configured.

Password reset

app/(auth)/forgot-password.tsx
await sendPasswordResetEmail(
  auth,
  email.trim(),
  linkUrl ? { url: linkUrl, handleCodeInApp: true } : undefined
);

With enumeration protection on — the default since September 2023 — this resolves successfully even for an address with no account, so the form cannot be used to discover who has one. The screen says "if an account exists" for the same reason.

In the app

When EXPO_PUBLIC_FIREBASE_LINK_URL is set and the domain is claimed, the link opens /reset-password instead:

app/(auth)/reset-password.tsx
const address = await verifyPasswordResetCode(auth, oobCode);
// …show the form, tell the user which account this is for
await confirmPasswordReset(auth, oobCode, password);
router.replace('/sign-in');

verifyPasswordResetCode runs before the form renders so an expired link shows "that link has expired" rather than a form that fails on submit.

Email verification

Firebase signs a new user in immediately, verified or not, so there is no blocked state to hold them in:

app/(auth)/sign-up.tsx
await sendEmailVerification(user).catch(() => {});

A card in Settings does the nagging and offers a resend. To make verification mandatory, gate the (tabs) guard on user.emailVerified.

When the link comes back into the app:

providers/auth-provider.tsx
await applyActionCode(auth, link.oobCode);
await auth.currentUser?.reload(); // emailVerified is cached on the User
setUser(auth.currentUser);

The reload() is not optional. The User object caches emailVerified, so without it the app keeps showing the unverified card after a successful verification.

Firebase's name for what Supabase calls a magic link.

app/(auth)/email-link.tsx
await sendSignInLinkToEmail(auth, address, {
  url: linkUrl,
  handleCodeInApp: true,
});
 
await SecureStore.setItemAsync(PENDING_EMAIL_KEY, address);

The address is persisted because signInWithEmailLink needs it back, and the link may be opened from a mail app rather than this one.

Completion happens in the provider:

if (isSignInWithEmailLink(auth, url)) {
  const email = await SecureStore.getItemAsync(PENDING_EMAIL_KEY);
  if (!email) return;
  await signInWithEmailLink(auth, email, url);
  await SecureStore.deleteItemAsync(PENDING_EMAIL_KEY);
}

isSignInWithEmailLink is asked rather than parsing mode=signIn by hand — the SDK is the authority, and lib/auth-link.ts deliberately returns null for those so the two never double-handle a link.

The supported approach now:

  1. Firebase mints the link on your project's Firebase Hosting domain (<project>.firebaseapp.com by default, or a custom one via linkDomain).
  2. Your app claims that domain through Universal Links on iOS and App Links on Android.
app.json
{
  "ios": { "associatedDomains": ["applinks:your-project-id.firebaseapp.com"] },
  "android": {
    "intentFilters": [
      {
        "action": "VIEW",
        "autoVerify": true,
        "data": [
          { "scheme": "https", "host": "your-project-id.firebaseapp.com" }
        ],
        "category": ["BROWSABLE", "DEFAULT"]
      }
    ]
  }
}

Then set the destination:

.env.local
EXPO_PUBLIC_FIREBASE_LINK_URL=https://your-project-id.firebaseapp.com/finish-sign-in

Three constraints worth stating plainly:

  • url must be https. A custom scheme (myapp://) is rejected outright.
  • The domain must be listed under Authentication → Settings → Authorized domains. <project>.firebaseapp.com and <project>.web.app are there by default.
  • This needs a build. Associated domains are a native entitlement and assetlinks.json verification happens at install time, so it cannot work in Expo Go.

The scaffold does not put those blocks in app.json — a placeholder host claims nothing, and the CLI writes expo.scheme as a string, so a scheme array would be clobbered on the next scaffold.

firebase.json already ships a hosting block pointed at public/, so firebase deploy --only hosting works the moment you want a custom linkDomain.

lib/auth-link.ts is pure and unit-tested, because deep-link bugs otherwise only surface on a real device with a real email:

export function parseAuthLink(url: string): AuthLink | null;

It handles two shapes the naive version misses: a custom-scheme deep link, which new URL() parses inconsistently across platforms, and a link nested inside a link= parameter, which is what a Hosting domain produces — the outer URL has no oobCode of its own.

Customising the emails

Authentication → Templates in the console. You can change the sender name, subject, body and reply-to.

To send from your own domain, set up a custom SMTP provider under Authentication → Templates → SMTP settings. Firebase's default sender is noreply@<project>.firebaseapp.com, which is fine for development and reaches spam folders more often than a verified domain in production.

Learn more