← Back to blog

Firebase Auth Custom Email with Rovela (2026)

Send custom OTP, verification, and welcome mail from Cloud Functions via Rovela POST /emails—Firebase owns Auth; Rovela delivers. Not a Firebase Auth replacement; distinct from Clerk, Supabase, and Auth.js guides.

Updated November 4, 2026
Firebase Auth Custom Email with Rovela (2026)

Firebase Auth owns identity — Rovela delivers the mail

Firebase Authentication gives you email/password, social providers, phone auth, email action links, and Admin SDK helpers to mint verification, password-reset, and email-link sign-in URLs. Many teams ship with Firebase’s built-in template emails (sent by Google), then need custom transactional copy—branded verification HTML, bilingual welcome notes, OTP digits you mint yourself, or a dedicated mail. subdomain with its own reputation.

This guide keeps Firebase Auth for identity and sends app-owned transactional mail—verification links from the Admin SDK, welcome messages on auth.user().onCreate, product OTP / step-up codes—by calling Rovela’s HTTP API from a trusted Cloud Function or backend. Firebase owns users and sessions; your function owns templates and rate limits; Rovela delivers.

Rovela does not replace Firebase Auth. It does not create users, issue ID tokens, apply oobCodes, or verify OTP codes. Your Cloud Function (or API) owns tokens, hashes, and redeem logic; Rovela only queues, DKIM-signs, and delivers via POST https://api.rovela.dev/emails (Send Email API, Quick Start).

This is not Clerk + Rovela, not Supabase + Rovela, and not Auth.js / NextAuth + Rovela. The integration surface here is the Firebase Admin SDK + Cloud Functions secrets (or any Node server that already uses firebase-admin).

When to keep Firebase email vs route through Rovela

NeedTypical path
Stock verification / reset / email-link templates from FirebaseFirebase Auth’s built-in email is fine to start
Custom HTML, brand layout, code-in-subject, bilingual copyApp-owned send → Rovela
Separate reputation on mail.yourdomain.comVerified Rovela domain + your from
Welcome / receipt / invite that is not a Firebase Auth templateAlways app-owned → Rovela
You still want Firebase sessions and client SDKsKeep Auth for identity; swap only the mail transport for custom flows

Common pattern: use the Admin SDK’s email action link APIs (generateEmailVerificationLink, generatePasswordResetLink, generateSignInWithEmailLink) so Firebase still owns the oobCode lifecycle, then embed those links in mail you send with Rovela. For product OTP that is not an Auth template, mint codes yourself, store hashes, and verify in your backend.

What you own vs what Rovela sends

Your Firebase project (plus Cloud Functions / backend) owns:

  • Users, ID tokens, and Auth providers (Firebase Auth)
  • Generating action links via the Admin SDK (or minting your own OTP digits with a CSPRNG)
  • Storing HMAC hashes / expiry / attempt counters for app-owned codes (Firestore or your DB—never the raw code)
  • Rate limits (“one send per minute per address”)
  • Redeem / verify logic (applyActionCode on a custom handler page, or your own OTP check)
  • A stable Idempotency-Key per logical send
  • Secrets: never put Rovela keys in the client SDK

Rovela owns:

  • POST /emails with Authorization: Bearer + a sending_access key
  • Queueing, signing, retries, bounce/complaint handling

A 200 means accepted and queued, not “already in the inbox” (Quick Start).

Prerequisites

  1. Verified sending domain in the Rovela dashboard. Unverified from domains are refused with 400 (Quick Start). Prefer a subdomain such as mail.example.com for auth mail (Why OTP and magic-link emails go to spam).
  2. An API key that looks like re_…. Prefer sending_access for production senders (Authentication).
  3. A Firebase project with Authentication enabled and the Admin SDK available in Cloud Functions (or another trusted Node server).
  4. Never put the Rovela key in the Firebase client SDK, a React Native / Flutter app, or a public repo. Only Cloud Functions (or a trusted server) may call Rovela.

Store secrets in Cloud Functions

Use Cloud Functions parameterized configuration / Secret Manager (or your host’s secret store)—not functions.config() hard-coding and not client env that ships to browsers:

# Example with Firebase CLI + Secret Manager (names are conventional)
firebase functions:secrets:set ROVELA_API_KEY
# paste re_your_sending_access_key when prompted
firebase functions:secrets:set ROVELA_FROM
# paste: Acme <security@mail.example.com>
firebase functions:secrets:set CODE_PEPPER
# paste a long random pepper for app-owned OTP hashes

In code, bind secrets to the function (exact syntax depends on your Functions version). Rotate by updating the secret and redeploying; revoke the old Rovela key in the dashboard when done (Authentication).

Pattern A: custom verification email (Admin link + Rovela)

Firebase’s client sendEmailVerification() uses Google’s templates. For branded HTML, generate the link on the server with the Admin SDK, then fetch Rovela. The user must already exist (Admin link APIs require an existing Auth user).

Callable / HTTPS sketch (functions/src/sendVerification.ts)—illustrative Node 18+ style:

import { getAuth } from "firebase-admin/auth";
import { onCall, HttpsError } from "firebase-functions/v2/https";
import { defineSecret } from "firebase-functions/params";

const ROVELA_URL = "https://api.rovela.dev/emails";
const rovelaKey = defineSecret("ROVELA_API_KEY");
const rovelaFrom = defineSecret("ROVELA_FROM");

export const sendCustomVerificationEmail = onCall(
  { secrets: [rovelaKey, rovelaFrom] },
  async (request) => {
    if (!request.auth?.uid) {
      throw new HttpsError("unauthenticated", "sign_in_required");
    }

    const auth = getAuth();
    const user = await auth.getUser(request.auth.uid);
    if (!user.email) {
      throw new HttpsError("failed-precondition", "no_email");
    }
    if (user.emailVerified) {
      return { ok: true, alreadyVerified: true };
    }

    // Firebase owns the oobCode; you own the HTML wrapper
    const link = await auth.generateEmailVerificationLink(user.email, {
      url: "https://acme.example/verified", // continue URL you control
      handleCodeInApp: false,
    });

    const sendId = (request.data?.sendId as string) || crypto.randomUUID();
    const apiKey = rovelaKey.value();
    const from = rovelaFrom.value();

    const res = await fetch(ROVELA_URL, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        Accept: "application/json",
        "Idempotency-Key": `firebase-verify-${user.uid}-${sendId}`.slice(0, 256),
      },
      body: JSON.stringify({
        from,
        to: user.email,
        subject: "Verify your Acme email",
        html:
          `<p>Hi${user.displayName ? ` ${escapeHtml(user.displayName)}` : ""},</p>` +
          `<p>Confirm your email for Acme:</p>` +
          `<p><a href="${link}">Verify email</a></p>` +
          `<p>If you did not create an account, ignore this message.</p>`,
        text:
          `Confirm your email for Acme: ${link}\n` +
          `If you did not create an account, ignore this message.`,
      }),
    });

    const data = await res.json().catch(() => ({}));
    if (!res.ok) {
      // Do not log API keys. Surface status + provider message only.
      console.error("rovela_send_failed", res.status, (data as { message?: string }).message);
      throw new HttpsError("internal", "send_failed");
    }

    return { ok: true, emailId: (data as { id?: string }).id };
  },
);

function escapeHtml(s: string) {
  return s
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

Point users at a custom email action handler on your domain if you want the verify page branded too—the oobCode is still Firebase’s. Soft docs: Email verification codes (for digit OTP) · Send Email.

Pattern B: welcome email on auth.user().onCreate

When a user is created (email/password, Admin create, or some provider flows), send a welcome note that is not an Auth template. Prefer an async Auth trigger over a blocking beforeUserCreated if the send can take time—blocking Auth triggers must finish quickly or the client operation fails.

import { getAuth } from "firebase-admin/auth";
import { onUserCreated } from "firebase-functions/v2/identity"; // or auth.user().onCreate on v1
import { defineSecret } from "firebase-functions/params";

const ROVELA_URL = "https://api.rovela.dev/emails";
const rovelaKey = defineSecret("ROVELA_API_KEY");
const rovelaFrom = defineSecret("ROVELA_FROM");

export const welcomeOnCreate = onUserCreated(
  { secrets: [rovelaKey, rovelaFrom] },
  async (event) => {
    const email = event.data.email;
    const uid = event.data.uid;
    const name = event.data.displayName || "there";
    if (!email) return;

    const res = await fetch(ROVELA_URL, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${rovelaKey.value()}`,
        "Content-Type": "application/json",
        Accept: "application/json",
        // Stable across Function retries for the same user
        "Idempotency-Key": `firebase-welcome-${uid}`.slice(0, 256),
      },
      body: JSON.stringify({
        from: rovelaFrom.value(),
        to: email,
        subject: "Welcome to Acme",
        html:
          `<p>Hi ${escapeHtml(name)}, your Acme account is ready.</p>` +
          `<p><a href="https://acme.example/dashboard">Open your dashboard</a></p>`,
        text: `Hi ${name}, your Acme account is ready. Open https://acme.example/dashboard`,
      }),
    });

    if (!res.ok) {
      const data = await res.json().catch(() => ({}));
      console.error("rovela_send_failed", res.status, (data as { message?: string }).message);
      // Throwing may retry the trigger depending on your Functions setup —
      // prefer a durable queue if you need guaranteed delivery.
      throw new Error("rovela_send_failed");
    }
  },
);

function escapeHtml(s: string) {
  return s
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

Exact trigger import names differ between Functions v1 (functions.auth.user().onCreate) and v2 identity events—follow your Firebase Functions docs for the version you deploy. For template-driven welcome copy, see Welcome email from a template.

Pattern C: app-owned OTP (Firebase session, your code)

When you want a product OTP (step-up confirm, email change, sensitive action) with your own HTML—while the user already has a Firebase ID token—mint the code on the server, store only a hash, and deliver with Rovela. Firebase still owns the session; your function owns the challenge.

import { createHash, randomInt, timingSafeEqual } from "node:crypto";
import { getAuth } from "firebase-admin/auth";
import { onCall, HttpsError } from "firebase-functions/v2/https";
import { defineSecret } from "firebase-functions/params";

const ROVELA_URL = "https://api.rovela.dev/emails";
const rovelaKey = defineSecret("ROVELA_API_KEY");
const rovelaFrom = defineSecret("ROVELA_FROM");
const codePepper = defineSecret("CODE_PEPPER");

export const sendStepUpOtp = onCall(
  { secrets: [rovelaKey, rovelaFrom, codePepper] },
  async (request) => {
    if (!request.auth?.uid) {
      throw new HttpsError("unauthenticated", "sign_in_required");
    }

    const user = await getAuth().getUser(request.auth.uid);
    const email = user.email;
    if (!email) throw new HttpsError("failed-precondition", "no_email");

    const sendId = (request.data?.sendId as string) || crypto.randomUUID();
    // CSPRNG six digits — never Math.random()
    const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
    const pepper = codePepper.value();
    const codeHash = createHash("sha256")
      .update(`${pepper}:${email}:${code}`)
      .digest("hex");

    // Upsert hash + expiry in Firestore / your DB (omitted). Never store the raw code.

    const res = await fetch(ROVELA_URL, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${rovelaKey.value()}`,
        "Content-Type": "application/json",
        Accept: "application/json",
        "Idempotency-Key": `firebase-otp-${user.uid}-${sendId}`.slice(0, 256),
      },
      body: JSON.stringify({
        from: rovelaFrom.value(),
        to: email,
        subject: `Your Acme code: ${code}`,
        html:
          `<p>Your verification code is</p>` +
          `<p style="font-size:28px;font-weight:600;letter-spacing:4px"><b>${code}</b></p>` +
          `<p>It expires in 10 minutes. If you did not ask for it, ignore this email.</p>`,
        text:
          `Your verification code is ${code}. It expires in 10 minutes. ` +
          `If you did not ask for it, ignore this email.`,
      }),
    });

    const data = await res.json().catch(() => ({}));
    if (!res.ok) {
      throw new HttpsError("internal", "send_failed");
    }

    return { ok: true, emailId: (data as { id?: string }).id };
  },
);

// Verify in a second callable with timingSafeEqual against the stored hash, then delete the row.

Product choice between codes and links: Email verification: code vs link. Security notes for links: Magic link security best practices.

Pattern D: password reset / email-link sign-in (same idea)

Same split as verification:

  1. generatePasswordResetLink(email, actionCodeSettings) or generateSignInWithEmailLink(email, actionCodeSettings) on the Admin SDK
  2. Embed the returned URL in HTML/text you control
  3. POST /emails with an Idempotency-Key such as firebase-reset-{uidOrEmailHash}-{sendId}

Do not send resets from the client with a Rovela key. Resolve the destination email from Admin getUser / trusted input after auth checks—not from an unauthenticated client alone without rate limits.

Idempotency on Function retries

Cloud Functions, your queue, and client retries can all replay a send. Without Idempotency-Key, a timeout can deliver two welcome emails or two OTP codes that disagree.

Per the emails API:

  • Header ≤ 256 characters
  • Remembered for 24 hours; replay returns the original response
  • Derive from something stable (firebase-welcome-{uid}, firebase-otp-{uid}-{sendId})
  • A deliberate resend that mints a new code must use a new key

Errors you will actually hit

Same shape everywhere (Quick Start):

  • 400 naming your domain — from not verified in this Rovela org
  • 400 recipient is suppressed: … — prior bounce/complaint; ask for another address
  • 401 — bad or revoked key
  • 429 — honour Retry-After; keep the same Idempotency-Key on retry

Do not tell the user “check your inbox” when the API refused the send. On Auth triggers, decide deliberately whether a Rovela failure should throw (retry) or be logged after you enqueue a local job.

Checklist

  1. Verify mail.example.com (or your chosen domain) in Rovela; SPF/DKIM/DMARC pass
  2. Create a sending_access key; store only in Cloud Functions secrets (or server env)—never in the client SDK
  3. For Auth action emails: Admin generate*Link + custom HTML via Rovela; optional custom action handler page
  4. For welcome: onCreate / identity trigger + Idempotency-Key firebase-welcome-{uid}
  5. For product OTP: table/collection for HMAC hashes + expiry + attempts; resolve email via Admin SDK
  6. fetch with both html and text + Idempotency-Key
  7. Watch delivery via dashboard message page or Rovela webhooks—not by assuming 200 means inbox

Soft docs: Send Email · Authentication · Email verification codes · Quick Start.

  • support@rovela.dev