Skip to content

← All articles

Authentication emails: Supabase, Auth0 and Firebase through Duva

Signup confirmation, magic link, password reset, one-time code: these emails often leave from the authentication provider itself, from its domain and with its templates. To have them leave from your domain instead, with your own text and a record of what happens to them, each of the three major providers offers a different route. This guide shows them side by side, with complete code for each.

Three providers, three mechanisms

Provider Mechanism Who writes the message Where the code runs
Supabase Send Email hook Your code A Supabase Edge Function
Auth0 Custom email provider Auth0, from your templates An Auth0 Action
Firebase Link generated by the Admin SDK Your code Your server
  • Supabase calls your function instead of sending: you receive the data (code, link) and write the message.
  • Auth0 writes the message from its templates and hands it to you: your Action only has to pass it on.
  • Firebase has no hook to replace its sending: your server generates the link, then sends the email.

What you need, in every case

  • A verified domain in Duva and an API key created for that domain ("API keys" screen of the dashboard).
  • A Duva account that is open for sending: a sandbox account is limited (see the FAQ).
  • Duva's API key stored in the provider's or your server's secrets. It must never be in a web or mobile application.

Supabase: the Send Email hook

Supabase Auth sends the signup confirmation, magic link and password recovery emails itself. With the Send Email hook, instead of sending the email, Supabase calls a URL you choose with everything needed to write it, and your code sends it. You need a Supabase project and the Supabase CLI.

How the hook works

For each authentication email, Supabase sends a signed POST request to your URL, in the open "Standard Webhooks" format (the same one Duva's webhooks use, see Verifying a webhook signature). The body contains:

  • user: the user concerned, including user.email.
  • email_data: token (the 6-digit code), token_hash, redirect_to, site_url and email_action_type (signup, magiclink, recovery, invite, email_change or reauthentication).

Your function verifies the signature, writes the message, sends it, then answers 200 with an empty JSON object, which is what Supabase expects on success.

The function

Create the function with supabase functions new send-email, then put this in supabase/functions/send-email/index.ts:

import { Webhook } from "npm:[email protected]";

const hookSecret = Deno.env.get("SEND_EMAIL_HOOK_SECRET")!.replace("v1,whsec_", "");
const duvaKey = Deno.env.get("DUVA_API_KEY")!;
const duvaDomain = Deno.env.get("DUVA_DOMAIN")!; // for example example.com
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;

const SUBJECTS: Record<string, string> = {
  signup: "Confirm your email address",
  magiclink: "Your sign-in link",
  recovery: "Reset your password",
  invite: "You have been invited",
  email_change: "Confirm your new address",
  reauthentication: "Your confirmation code",
};

const fail = (status: number, message: string) =>
  Response.json({ error: { http_code: status, message } }, { status });

Deno.serve(async (req) => {
  if (req.method !== "POST") return new Response("not allowed", { status: 405 });

  let user: { id: string; email: string };
  let data: { token: string; token_hash: string; redirect_to: string; email_action_type: string };
  try {
    const payload = await req.text();
    ({ user, email_data: data } = new Webhook(hookSecret).verify(
      payload,
      Object.fromEntries(req.headers),
    ) as { user: typeof user; email_data: typeof data });
  } catch {
    return fail(401, "invalid signature");
  }

  const link =
    `${supabaseUrl}/auth/v1/verify?token=${data.token_hash}` +
    `&type=${data.email_action_type}&redirect_to=${encodeURIComponent(data.redirect_to)}`;
  const text =
    data.email_action_type === "reauthentication"
      ? `Your code: ${data.token}`
      : `Click this link: ${link}\n\nOr enter this code: ${data.token}`;

  const response = await fetch(`https://api.duva.ca/v1/${duvaDomain}/messages`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${duvaKey}`,
      "Content-Type": "application/json",
      // A retry by Supabase for the same email never creates a second one.
      "Idempotency-Key": `supabase-${user.id}-${data.email_action_type}-${data.token_hash || data.token}`,
    },
    body: JSON.stringify({
      from: `Example <accounts@${duvaDomain}>`,
      to: [user.email],
      subject: SUBJECTS[data.email_action_type] ?? "Notification",
      text,
    }),
  });
  if (!response.ok) return fail(500, `Duva refused the message (HTTP ${response.status})`);

  return Response.json({});
});

Deploy and connect

  1. Deploy without JWT verification, since Supabase Auth calls the function, not a user: supabase functions deploy send-email --no-verify-jwt.
  2. In the Supabase dashboard, open Authentication > Hooks, create a "Send Email hook", choose HTTPS, paste the function's address and click Generate Secret.
  3. Save the function's secrets: supabase secrets set SEND_EMAIL_HOOK_SECRET="v1,whsec_..." DUVA_API_KEY="dv_..." DUVA_DOMAIN="example.com".
  4. Create a test account: the confirmation email now leaves through Duva.

Specific to Supabase

  • The message text is yours. This example sends plain text. Add an html field for a layout, and adapt the subjects and text to your language and tone.
  • Email change with double confirmation. When Supabase confirms both the old and the new address, it also provides token_new and token_hash_new: write two messages then, one per address. Supabase's documentation describes this case.

Auth0: the custom email provider

Auth0 sends the address verification, password reset and one-time code (passwordless sign-in) emails. With a custom email provider, you write an Action, a small Node.js function, that Auth0 calls for each email to deliver. It receives the message already written (subject, HTML, text, recipient) and only has to send it. You need an Auth0 account with access to the dashboard.

The Action

Auth0 writes the message from your email templates: the Action only passes it on to Duva. The event.notification object contains to, from, subject, html and text.

/**
 * Delivers an Auth0 email through the Duva API.
 * Secrets to define in the editor: DUVA_API_KEY and DUVA_DOMAIN.
 */
exports.onExecuteCustomEmailProvider = async (event, api) => {
  const { to, from, subject, html, text } = event.notification;

  const body = { from, to: [to], subject };
  if (html) body.html = html;
  if (text) body.text = text;

  let response;
  try {
    response = await fetch(`https://api.duva.ca/v1/${event.secrets.DUVA_DOMAIN}/messages`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${event.secrets.DUVA_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(body),
    });
  } catch (error) {
    // Network unavailable: Auth0 can try again.
    api.notification.retry(`Duva is unreachable: ${error.message}`);
    return;
  }

  if (response.status === 429 || response.status >= 500) {
    // Rate limit or a passing outage: try again.
    api.notification.retry(`Duva answered HTTP ${response.status}`);
  } else if (!response.ok) {
    // Permanent refusal (invalid address, key refused...): retrying is pointless.
    api.notification.drop(`Duva refused the message (HTTP ${response.status})`);
  }
};

Two choices to know about:

  • api.notification.retry() asks Auth0 to try again later: use it for a rate limit (429) or a passing outage (5xx), which can resolve on their own.
  • api.notification.drop() gives up on the email: use it for a permanent refusal that another attempt would not change. A validation error (422) or a refused key are examples.

Connect the Action

  1. In the Auth0 dashboard, open Branding > Email Provider.
  2. Turn on Use my own email provider and choose Custom Provider.
  3. In From, enter the sending address: its domain must be your Duva domain, for example [email protected].
  4. Paste the Action's code into the editor. With the key icon in the left menu, add the secrets DUVA_API_KEY and DUVA_DOMAIN.
  5. Click Save to deploy, then Send Test Email.

Specific to Auth0

  • A duplicate is possible after an outage. If the network drops after Duva accepted the message but before Auth0 gets the answer, the new attempt sends a second email. It is rare and harmless for a one-time code or a reset link, which both stay valid.
  • The templates stay in Auth0. To change an email's text, subject or language, edit the templates in Auth0 (Branding > Email Templates): the Action sends what they produce.

Firebase Authentication can send address verification, password reset and email-link sign-in emails. When the client SDK asks for them, Google sends them, with a template that can only be customized so far. Firebase has no hook to replace that sending: the intended route is to generate the link on your server with the Admin SDK, then send the email yourself. You need a server where the Firebase Admin SDK runs: a cloud function, a Node.js server, and so on.

The Admin SDK exposes generatePasswordResetLink, generateEmailVerificationLink and generateSignInWithEmailLink. Each returns an address that you place in your email. Here is the password reset, in Node.js:

import { createHash } from "node:crypto";
import { getAuth } from "firebase-admin/auth";

const DUVA_DOMAIN = process.env.DUVA_DOMAIN; // for example example.com

// Where the user comes back to; its domain must be authorized in the Firebase console.
const actionCodeSettings = { url: "https://app.example.com/sign-in" };

async function sendWithDuva({ to, subject, text, html, idempotencyKey }) {
  const response = await fetch(`https://api.duva.ca/v1/${DUVA_DOMAIN}/messages`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.DUVA_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify({ from: `Example <accounts@${DUVA_DOMAIN}>`, to: [to], subject, text, html }),
  });
  if (!response.ok) throw new Error(`Duva refused the message (HTTP ${response.status})`);
}

export async function sendPasswordReset(email) {
  let link;
  try {
    link = await getAuth().generatePasswordResetLink(email, actionCodeSettings);
  } catch (error) {
    // No account for this address: we do not reveal it (see below).
    if (error.code === "auth/user-not-found") return;
    throw error;
  }
  await sendWithDuva({
    to: email,
    subject: "Reset your password",
    text: `To choose a new password, open this link: ${link}`,
    html: `<p>To choose a new password, <a href="${link.replaceAll("&", "&amp;")}">click here</a>.</p>`,
    // The link is unique: a retry of this same send does not create a second email.
    idempotencyKey: `firebase-${createHash("sha256").update(link).digest("hex")}`,
  });
}

The two other cases follow the same pattern: generateEmailVerificationLink(email, actionCodeSettings) to confirm an address, and generateSignInWithEmailLink(email, actionCodeSettings) for email-link sign-in (which requires handleCodeInApp: true in actionCodeSettings, and email-link sign-in turned on in Firebase).

Have your server called instead of the client SDK

For Duva to replace Google's sending, the application must no longer call the client SDK's sendPasswordResetEmail() or sendEmailVerification(): it calls your function (a callable function, an HTTP endpoint), which generates the link and sends the email as above. As long as the client calls those methods, Google sends its own email.

Specific to Firebase

  • Do not reveal whether an account exists. For the password reset, answer exactly the same thing whether or not the address has an account. That is why the example ignores the auth/user-not-found error: otherwise anyone could test addresses to learn which ones are registered. Also limit the number of requests per address and per IP.
  • Links expire. Firebase links have a limited lifetime; do not store them, and generate one only at the moment you send.

Good to know, whatever the provider

  • Sent does not mean received. Duva answers queued: the message is accepted for sending, not yet delivered. To find out what happened to an email, read its events or receive them through a webhook; the guide An email did not arrive shows how.
  • The sending address must be on your Duva domain. The domain of the from field must be exactly the API key's domain, the one in the address you call (/v1/<domain>/messages); a subdomain is refused.
  • Your templates, your language. The text and layout are yours (in your code for Supabase and Firebase, in Auth0's templates): adapt them to the user's language and add your brand.

The full API reference is in the documentation.