Every production Supabase project needs custom SMTP, because the built-in sender only emails your own team and allows 2 messages an hour. To send through OwnMailServer, open your project's Authentication settings, go to SMTP, and enter smtp.ownmailserver.com, port 587, any username and an API key as the password, with a From address on your verified domain. Then raise Supabase's 30-an-hour starting limit to match your volume. It takes about ten minutes.

Approach Good for Code
Custom SMTP Most projects: Supabase's templates, sent from your domain None
Send Email Hook + our API Your own templates, languages per user, logging One Edge Function

Why Supabase needs custom SMTP

Supabase Auth sends email for signup confirmation, magic links, password resets, invitations, email changes and reauthentication. Out of the box it uses a shared sender that, as of October 2026, Supabase documents like this:

  • It only delivers to addresses in your organization's Team settings. Anyone else gets Email address not authorized.
  • It allows 2 messages an hour, and Supabase says this can change without notice.
  • It has no delivery or uptime guarantee.

That's enough to try Supabase, not to run an app. With custom SMTP, sign-in emails come from your domain, signed with your DKIM key, and every bounce and complaint shows up in your dashboard.

What you need before you start

  • An approved OwnMailServer account with a verified sending domain. The From address must be on that domain, or we refuse the email with 550. The API docs list the DNS records, and the DKIM guide explains them.
  • An API key, created in your dashboard under API keys & SMTP. Choose Sending only (the default), limited to your sending domain, so the key Supabase stores can't do anything but send. Make one just for Supabase, so you can revoke it without touching your other apps.
  • Ideally a separate From address or subdomain for auth email, such as no-reply@auth.acme.com. Supabase recommends this too: if a marketing campaign draws complaints, your password resets aren't caught up in it.

Step 1: Enter the SMTP settings

In the Supabase dashboard, open your project, then Authentication, then the SMTP settings, and turn on custom SMTP:

Field Value
Sender email no-reply@auth.acme.com (on your verified domain)
Sender name Your app's name, such as Acme
Host smtp.ownmailserver.com
Port 587
Username ownmailserver (we accept any value)
Password Your API key

Save. From now on Supabase can email any address. Supabase connects on 587, upgrades with STARTTLS and logs in, which is what we require.

To set the same thing from a script or CI, use Supabase's Management API with an access token from your account settings:

curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \
  -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "external_email_enabled": true,
    "smtp_admin_email": "no-reply@auth.acme.com",
    "smtp_sender_name": "Acme",
    "smtp_host": "smtp.ownmailserver.com",
    "smtp_port": "587",
    "smtp_user": "ownmailserver",
    "smtp_pass": "'"$OMS_API_KEY"'"
  }'

Step 2: Raise the rate limit, in step with warm-up

When you turn on custom SMTP, Supabase sets a deliberately low limit: 30 auth emails an hour. Change it on the Rate Limits page in the Authentication settings.

Two limits now apply, and the lower one wins:

  • Supabase's hourly limit, which you set.
  • Our daily warm-up cap. A new account starts at a few hundred recipients a day and rises about 25% after each clean day (how warm-up works). Your dashboard shows today's cap.

If Supabase allows more than our cap, we answer with a temporary 450 and Supabase reports a sending error. So raise Supabase's limit to roughly your cap divided by the hours your users are active, and raise it again as the cap grows. Auth email is usually low volume, so most projects never get close.

Step 3: Test it

Sign up with an address that isn't on your Supabase team, or trigger a reset:

const { error } = await supabase.auth.resetPasswordForEmail("jane@example.com", {
  redirectTo: "https://acme.com/update-password",
});

Then search for the address in your OwnMailServer dashboard to see the delivery, or ask the Claude connector what happened to it. If nothing arrived:

You see Cause Fix
Supabase: Email address not authorized Custom SMTP isn't saved or enabled Save the SMTP settings again
535 / authentication failed Password isn't a valid API key Paste the full key, or create a new one
550 Sender domain isn't verified, or the account is pending Finish domain setup in the dashboard
450 Warm-up cap or 10 a second reached Wait, or lower Supabase's rate limit
Supabase: rate limit exceeded Supabase's own hourly limit Raise it under Rate Limits

After 5 failed logins from one IP address we block that address for 15 minutes, so fix a wrong password before retrying many times.

Step 4: Edit the templates

Supabase writes these emails, so you change the wording under Authentication, then Email Templates: confirm signup, invite, magic link, change email address, reset password and reauthentication. Templates use Go template variables such as {{ .ConfirmationURL }} and {{ .Token }}.

Keep auth emails plain: a short line, one button, the same link as text, and no tracking or marketing. That's what mailbox providers expect from a password reset, and it's what gets it to the inbox quickly.

Option B: A Send Email Hook that calls our API

If you want your own templates, per-user languages or a record of each send in your database, use Supabase's Send Email Hook. When the hook is on, Supabase stops using SMTP and calls your endpoint for every auth email. You build the email and send it through POST /emails.

Create an Edge Function and its secrets. The hook secret comes from Authentication, then Hooks, when you add a Send Email Hook pointing to the function's URL:

supabase functions new send-email
supabase secrets set OMS_API_KEY=... SEND_EMAIL_HOOK_SECRET="v1,whsec_..."
// supabase/functions/send-email/index.ts
import { Webhook } from "https://esm.sh/standardwebhooks@1.0.0";

const hookSecret = Deno.env.get("SEND_EMAIL_HOOK_SECRET")!.replace("v1,whsec_", "");
const PROJECT_REF = "your-project-ref";

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

Deno.serve(async (req) => {
  const payload = await req.text();
  const headers = Object.fromEntries(req.headers);
  let user, email_data;
  try {
    ({ user, email_data } = new Webhook(hookSecret).verify(payload, headers) as any);
  } catch {
    return new Response(JSON.stringify({ error: { http_code: 401, message: "Invalid signature" } }), { status: 401 });
  }

  const link = `https://${PROJECT_REF}.supabase.co/auth/v1/verify?` + new URLSearchParams({
    token: email_data.token_hash,
    type: email_data.email_action_type,
    redirect_to: email_data.redirect_to,
  });

  const res = await fetch("https://api.ownmailserver.com/emails", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${Deno.env.get("OMS_API_KEY")}`,
      "Content-Type": "application/json",
      // The same hook call retried won't send twice.
      "Idempotency-Key": `supabase-${email_data.token_hash}`,
    },
    body: JSON.stringify({
      from: "Acme <no-reply@auth.acme.com>",
      to: [user.email],
      subject: subjects[email_data.email_action_type] ?? "Message from Acme",
      html: `<p><a href="${link}">Continue</a></p><p>Or enter this code: ${email_data.token}</p>`,
      text: `Continue: ${link}\nOr enter this code: ${email_data.token}`,
    }),
  });
  if (!res.ok) {
    const body = await res.json();
    return new Response(JSON.stringify({ error: { http_code: res.status, message: body.message } }), { status: 500 });
  }
  return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } });
});
supabase functions deploy send-email --no-verify-jwt

Deploy with --no-verify-jwt: Supabase Auth signs the hook call with the hook secret, not a user's JWT, and the function checks that signature itself.

Two details from Supabase's docs to handle before you rely on it. With Secure Email Change on, an email_change call carries two token pairs, one for each address, and Supabase notes the field names are reversed from what you'd expect, so follow its hook docs for that case. Newer versions of the hook can also send security notifications, such as password_changed_notification; give those their own subject and text rather than the fallback above.

When this isn't the right fit

If your project only sends a handful of auth emails, any SMTP provider with a free tier will do, and Supabase's limits are the real constraint, not cost. We're a good fit when you want auth and app email from one provider on your own domain, with bounces, complaints and warm-up handled for you. The Firebase guide covers the same setup for Firebase, and pay-as-you-go pricing is $0.20 per 1,000 emails with a $10 monthly minimum.