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.
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
| Need | Typical path |
|---|---|
| Stock verification / reset / email-link templates from Firebase | Firebase Auth’s built-in email is fine to start |
| Custom HTML, brand layout, code-in-subject, bilingual copy | App-owned send → Rovela |
Separate reputation on mail.yourdomain.com | Verified Rovela domain + your from |
| Welcome / receipt / invite that is not a Firebase Auth template | Always app-owned → Rovela |
| You still want Firebase sessions and client SDKs | Keep 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 (
applyActionCodeon a custom handler page, or your own OTP check) - A stable
Idempotency-Keyper logical send - Secrets: never put Rovela keys in the client SDK
Rovela owns:
POST /emailswithAuthorization: Bearer+ asending_accesskey- Queueing, signing, retries, bounce/complaint handling
A 200 means accepted and queued, not “already in the inbox” (Quick Start).
Prerequisites
- Verified sending domain in the Rovela dashboard. Unverified
fromdomains are refused with400(Quick Start). Prefer a subdomain such asmail.example.comfor auth mail (Why OTP and magic-link emails go to spam). - An API key that looks like
re_…. Prefersending_accessfor production senders (Authentication). - A Firebase project with Authentication enabled and the Admin SDK available in Cloud Functions (or another trusted Node server).
- 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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
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:
generatePasswordResetLink(email, actionCodeSettings)orgenerateSignInWithEmailLink(email, actionCodeSettings)on the Admin SDK- Embed the returned URL in HTML/text you control
POST /emailswith anIdempotency-Keysuch asfirebase-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):
400naming your domain —fromnot verified in this Rovela org400recipient is suppressed: …— prior bounce/complaint; ask for another address401— bad or revoked key429— honourRetry-After; keep the sameIdempotency-Keyon 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
- Verify
mail.example.com(or your chosen domain) in Rovela; SPF/DKIM/DMARC pass - Create a
sending_accesskey; store only in Cloud Functions secrets (or server env)—never in the client SDK - For Auth action emails: Admin
generate*Link+ custom HTML via Rovela; optional custom action handler page - For welcome:
onCreate/ identity trigger +Idempotency-Keyfirebase-welcome-{uid} - For product OTP: table/collection for HMAC hashes + expiry + attempts; resolve email via Admin SDK
fetchwith bothhtmlandtext+Idempotency-Key- Watch delivery via dashboard message page or Rovela webhooks—not by assuming
200means inbox
Soft docs: Send Email · Authentication · Email verification codes · Quick Start.
- support@rovela.dev