Custom email for Supabase Auth

Supabase Auth sends confirmation, magic link, recovery, and invite mail out of the box, from a shared address, at a rate limit meant for development. The moment you have real users you need those messages coming from your domain. There are two ways to do that and they solve different problems.

4 min read

Why the default stops working

The built-in sender exists so signup works on day one. It is deliberately constrained.

  • Mail arrives from a Supabase address, not yours, so it looks unrelated to your product.
  • The rate limit on the built-in sender is low by design. A launch will hit it.
  • You have no delivery log. When a user says the email never arrived, you have nothing to check.
  • You cannot authenticate it with your own DKIM, so it will never align with your domain under DMARC.

Both fixes below solve all four. Pick based on how much control you want over the message body.

Option one: custom SMTP

Supabase Auth accepts SMTP credentials in the dashboard under Authentication, then Emails, then SMTP settings. Supabase keeps generating and templating the messages; only the transport changes.

FieldValue
HostYour provider's SMTP host
Port587
Username and passwordThe credentials your provider issues
Sender emailAn address on a domain you have verified
Sender nameWhat recipients see, e.g. Acme

This is the smaller change and the right one if the default templates are close enough. You edit the templates in the dashboard, Supabase substitutes {{ .ConfirmationURL }} and friends, and your provider carries the message.

Its limits are real, though: templates are dashboard-managed rather than in your repo, you cannot easily add per-user data that Supabase does not know about, and you are back on SMTP with its handshake latency. For a hosted Supabase project sending from a long-lived service that is acceptable, and you should still read why Gmail SMTP fails in production before you point it at a personal mailbox.

Option two: a Send Email Auth Hook

The hook is the better answer if you want the mail to live in your codebase. Supabase stops sending entirely and instead calls an endpoint you own, handing you the user and the token. You render whatever you like and send it over HTTPS.

Configure the hook to point at an Edge Function or any HTTPS endpoint. The payload looks like this:

JSON
{
  "user": {
    "id": "8b2c...",
    "email": "someone@example.com"
  },
  "email_data": {
    "token": "918273",
    "token_hash": "pkce_5f...",
    "redirect_to": "https://acme.com/welcome",
    "email_action_type": "signup",
    "site_url": "https://acme.com"
  }
}

email_action_type is what you branch on: signup, magiclink, recovery, invite, email_change, and reauthentication. Build the link yourself from token_hash and redirect_to.

supabase/functions/send-email/index.ts
Deno.serve(async (req) => {
  const payload = await req.json();
  const { user, email_data } = payload;

  const link =
    `${email_data.site_url}/auth/v1/verify` +
    `?token=${email_data.token_hash}` +
    `&type=${email_data.email_action_type}` +
    `&redirect_to=${encodeURIComponent(email_data.redirect_to)}`;

  const copy = {
    signup: { subject: 'Confirm your email', cta: 'Confirm your email address' },
    magiclink: { subject: 'Your sign-in link', cta: 'Sign in to Acme' },
    recovery: { subject: 'Reset your password', cta: 'Choose a new password' },
    invite: { subject: 'You have been invited to Acme', cta: 'Accept the invitation' }
  }[email_data.email_action_type as 'signup'] ?? {
    subject: 'Action required',
    cta: 'Continue'
  };

  const res = await fetch('https://emails.sh/v1/emails', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${Deno.env.get('EMAILSSH_API_KEY')}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      from: 'Acme <hello@acme.com>',
      to: [user.email],
      subject: copy.subject,
      html: `<p>${copy.cta}: <a href="${link}">${link}</a></p><p>This link expires in one hour.</p>`,
      text: `${copy.cta}: ${link}\n\nThis link expires in one hour.`,
      idempotency_key: `${email_data.email_action_type}:${email_data.token_hash}`
    })
  });

  if (!res.ok) {
    return new Response(JSON.stringify({ error: { message: 'send failed' } }), { status: 500 });
  }
  return new Response('{}', { headers: { 'Content-Type': 'application/json' } });
});

Set the secret before you deploy, and deploy without JWT verification so Supabase Auth can reach it with its own hook secret instead.

Shell
supabase secrets set EMAILSSH_API_KEY=esh_live_your_key_here
supabase functions deploy send-email --no-verify-jwt

Verify the hook signature

The hook endpoint is a URL on the public internet that sends email when it is called. Left unauthenticated, anyone who finds it can make your app mail arbitrary addresses. Supabase signs each request with the hook secret, and you must check it.

TypeScript
import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0';

const wh = new Webhook(Deno.env.get('SEND_EMAIL_HOOK_SECRET')!.replace('v1,whsec_', ''));
const body = await req.text();
const payload = wh.verify(body, Object.fromEntries(req.headers)) as HookPayload;

Verify first, parse second. A handler that parses before verifying has already trusted the input.

Which one to choose

You wantUse
The default flows, from your own domain, in ten minutesCustom SMTP
Templates in version control and reviewed in pull requestsAuth Hook
Per-user content Supabase does not have, like a plan nameAuth Hook
Localised mail chosen by a column on your users tableAuth Hook
A delivery log tied to your own message idsEither, but the hook gives you the id directly

Do not skip the DNS

Neither option helps if the domain in your from address is not authenticated. A password reset from an unauthenticated domain is exactly the shape of a phishing message, and filters treat it accordingly. Work through SPF, DKIM, and DMARC explained for developers once, and if reset mail is already landing in spam, why your password reset email goes to spam covers the rest.

The full integration, including the client-side flow, is at Supabase. If you are learning this stack on a student project, the students page has a shorter path.

Questions

Does the Send Email Auth Hook replace custom SMTP?
Yes. When a Send Email hook is enabled, Supabase Auth stops sending mail itself and calls your endpoint instead. You do not need SMTP settings configured as well, and if both are present the hook wins.
Can I keep Supabase's token generation and only change the email?
That is exactly what both options do. Supabase still creates and validates the token, the OTP, and the redirect. You control only the transport and, with the hook, the message body.
What happens if my hook endpoint returns an error?
The auth operation fails and the user sees an error, so the endpoint needs to be reliable. Return a 200 quickly and handle retries on your side rather than doing slow work before responding.
Why is my Supabase auth email going to spam?
Usually because the sending domain is not authenticated with DKIM aligned to the From address, or because the default Supabase sender is being used for production mail. Verify your own domain and send from it.

Give your agent an address it can answer from.

Create an inbox