Supabase Auth
Send your Supabase sign-up, magic-link, password-reset and security emails from your own domain through SendSend.
Supabase Auth can hand every email it wants to send to an HTTPS function (the Send Email hook). The function below receives it, checks the signature, builds the email and sends it with SendSend. We do not have an SMTP relay, so the hook is how to connect them.
Set it up
- Verify a sending domain in SendSend, and create an API key with only
emails:send. - Save the code below as
supabase/functions/send-email/index.tsand deploy it:supabase functions deploy send-email --no-verify-jwt. The hook is authenticated by its signature, not a Supabase login. - In the Supabase dashboard, go to Authentication → Hooks → Send Email, choose HTTPS, enter the function’s URL, and generate a secret.
- Set the secrets:
supabase secrets set SENDSEND_API_KEY=ss_live_… SENDSEND_FROM="Acme <auth@mail.acme.com>" SEND_EMAIL_HOOK_SECRET='v1,whsec_…' - Sign up a test user. The email arrives from your domain.
// Supabase Edge Function: sends your Supabase Auth emails (sign-up confirmation, magic link, password reset, invite,
// email change, reauthentication) through SendSend instead of Supabase's built-in mailer.
//
// 1. supabase functions deploy send-email --no-verify-jwt (put this file at supabase/functions/send-email/index.ts)
// 2. supabase secrets set SENDSEND_API_KEY=ss_live_... SENDSEND_FROM="Acme <auth@mail.acme.com>" SEND_EMAIL_HOOK_SECRET='v1,whsec_...'
// 3. Dashboard -> Authentication -> Hooks -> Send Email -> HTTPS -> the function URL, and copy the generated secret into step 2.
//
// The key only needs the `emails:send` permission. Auth emails carry sign-in links, so leave click tracking OFF for this domain.
import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0';
const FROM = Deno.env.get('SENDSEND_FROM')!;
const KEY = Deno.env.get('SENDSEND_API_KEY')!;
const SUPABASE_URL = Deno.env.get('SUPABASE_URL')!;
const hookSecret = (Deno.env.get('SEND_EMAIL_HOOK_SECRET') ?? '').replace('v1,whsec_', '');
type Payload = {
user: { email: string; new_email?: string };
email_data: { token: string; token_hash: string; redirect_to: string; email_action_type: string; site_url: string; token_new?: string; token_hash_new?: string };
};
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
const link = (hash: string, type: string, redirect: string) => `${SUPABASE_URL}/auth/v1/verify?token=${encodeURIComponent(hash)}&type=${encodeURIComponent(type)}&redirect_to=${encodeURIComponent(redirect)}`;
function compose(p: Payload): { to: string; subject: string; html: string }[] {
const d = p.email_data;
const button = (url: string, label: string) => `<p><a href="${esc(url)}" style="display:inline-block;background:#1a56db;color:#fff;padding:12px 22px;border-radius:8px;text-decoration:none;font-weight:600">${esc(label)}</a></p>`;
const code = (c: string) => `<p>Or enter this code: <strong style="font-size:18px;letter-spacing:2px">${esc(c)}</strong></p>`;
const one = (subject: string, html: string) => [{ to: p.user.email, subject, html }];
switch (d.email_action_type) {
case 'signup': return one('Confirm your email', `<p>Welcome! Confirm your email address to finish signing up.</p>${button(link(d.token_hash, 'signup', d.redirect_to), 'Confirm email')}${code(d.token)}`);
case 'magiclink': return one('Your sign-in link', `<p>Use this link to sign in. It works once and expires soon.</p>${button(link(d.token_hash, 'magiclink', d.redirect_to), 'Sign in')}${code(d.token)}`);
case 'email': return one('Your sign-in code', `<p>Enter this code to sign in.</p>${code(d.token)}`);
case 'recovery': return one('Reset your password', `<p>Someone asked to reset the password for this account. If it was not you, ignore this email.</p>${button(link(d.token_hash, 'recovery', d.redirect_to), 'Reset password')}${code(d.token)}`);
case 'invite': return one('You have been invited', `<p>You have been invited to join.</p>${button(link(d.token_hash, 'invite', d.redirect_to), 'Accept invite')}`);
case 'reauthentication': return one('Your confirmation code', `<p>Enter this code to confirm it is you.</p>${code(d.token)}`);
case 'email_change': {
const newTo = p.user.new_email ?? p.user.email;
// Secure email change sends one message to each address. Supabase names the hashes the other way round for backward
// compatibility: the current address uses `token` with `token_hash_new`; the new address uses `token_new` with `token_hash`.
if (d.token_hash_new && d.token_new) {
return [
{ to: p.user.email, subject: 'Confirm your email change', html: `<p>Confirm the change of email address on your account.</p>${button(link(d.token_hash_new, 'email_change', d.redirect_to), 'Confirm change')}${code(d.token)}` },
{ to: newTo, subject: 'Confirm your new email address', html: `<p>Confirm this address for your account.</p>${button(link(d.token_hash, 'email_change', d.redirect_to), 'Confirm new address')}${code(d.token_new)}` },
];
}
const hash = d.token_hash || d.token_hash_new || '';
return [{ to: newTo, subject: 'Confirm your new email address', html: `<p>Confirm this address for your account.</p>${button(link(hash, 'email_change', d.redirect_to), 'Confirm new address')}${code(d.token || d.token_new || '')}` }];
}
// Security notices Supabase can ask you to send: they carry no link or code.
case 'password_changed_notification': return one('Your password was changed', '<p>The password on your account was just changed. If this was not you, reset it now and contact us.</p>');
case 'email_changed_notification': return one('Your email address was changed', '<p>The email address on your account was just changed. If this was not you, contact us right away.</p>');
case 'phone_changed_notification': return one('Your phone number was changed', '<p>The phone number on your account was just changed. If this was not you, contact us right away.</p>');
case 'identity_linked_notification': return one('A sign-in method was added', '<p>A new way to sign in was added to your account. If this was not you, contact us right away.</p>');
case 'identity_unlinked_notification': return one('A sign-in method was removed', '<p>A way to sign in was removed from your account. If this was not you, contact us right away.</p>');
case 'mfa_factor_enrolled_notification': return one('Two-factor sign-in was added', '<p>A new two-factor method was added to your account. If this was not you, contact us right away.</p>');
case 'mfa_factor_unenrolled_notification': return one('Two-factor sign-in was removed', '<p>A two-factor method was removed from your account. If this was not you, contact us right away.</p>');
default: throw new Error(`Unhandled email type: ${d.email_action_type}`);
}
}
Deno.serve(async (req) => {
const body = await req.text();
let payload: Payload;
try { payload = new Webhook(hookSecret).verify(body, Object.fromEntries(req.headers)) as Payload; }
catch { return new Response(JSON.stringify({ error: { http_code: 401, message: 'Invalid signature' } }), { status: 401, headers: { 'content-type': 'application/json' } }); }
try {
const id = req.headers.get('webhook-id') ?? crypto.randomUUID();
for (const [i, m] of compose(payload).entries()) {
const res = await fetch('https://api.sendsend.email/v1/emails', {
method: 'POST',
// The same webhook-id on a retry means the same key, so a retry never sends the same email twice.
headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': `supabase-${id}-${i}` },
body: JSON.stringify({ from: FROM, to: [m.to], subject: m.subject, html: m.html }),
});
if (!res.ok) throw new Error(`SendSend: ${(await res.json().catch(() => ({})))?.message ?? res.status}`);
}
return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });
} catch (e) {
return new Response(JSON.stringify({ error: { http_code: 500, message: (e as Error).message } }), { status: 500, headers: { 'content-type': 'application/json' } });
}
});Notes
- Leave click tracking off for this domain. Rewriting the links in sign-in emails can break them.
- Retries are safe: the hook’s id becomes the idempotency key, so Supabase retrying never sends a second email.
- Change the wording and HTML in
compose. The example covers every email type Supabase can send, including the two-address secure email change. - If the function returns an error, Supabase shows it to the user as a failed sign-up or reset, so watch the function logs and the SendSend request logs.
- Not tested against a live Supabase project. The payload and the verification follow Supabase’s Send Email hook documentation.