Send Supabase Auth emails through emails.sh
Supabase's built in auth emails are rate limited and come from Supabase's domain, which is why signups stop arriving once you have real traffic. This page wires a Send Email Auth Hook to an Edge Function so confirmation and reset mail goes out through emails.sh, on your domain.
Setup
- 01
Create a key
https://emails.sh/dashboard issues a key starting with esh_.
- 02
Set the function secrets
npx supabase secrets set EMAILSSH_API_KEY=esh_... SEND_EMAIL_HOOK_SECRET=v1,whsec_... Edge Functions read them from Deno.env, and they are not visible to your client.
- 03
Deploy the hook function
npx supabase functions deploy send-email --no-verify-jwt. The hook is called by Supabase Auth, which sends a webhook signature rather than a user JWT, so JWT verification must be off.
- 04
Enable the hook
Authentication, Hooks, Send Email Hook, point it at the function URL and paste the secret. Auth then calls your function instead of sending its own mail.
npm install supabase --save-dev[auth.hook.send_email]
enabled = true
uri = "https://<project-ref>.supabase.co/functions/v1/send-email"
# The secret is set with:
# npx supabase secrets set SEND_EMAIL_HOOK_SECRET="v1,whsec_..."
# Supabase signs each hook call with it, in the standard webhook format.The auth hook
supabase/functions/send-email/index.ts (Deno, Edge Functions)
import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0';
const hookSecret = Deno.env.get('SEND_EMAIL_HOOK_SECRET')!.replace('v1,whsec_', '');
const apiKey = Deno.env.get('EMAILSSH_API_KEY')!;
const from = Deno.env.get('EMAILSSH_FROM') ?? 'Acme <onboarding@emails.sh>';
interface HookPayload {
user: { email: string };
email_data: {
token: string;
token_hash: string;
redirect_to: string;
email_action_type: 'signup' | 'recovery' | 'magiclink' | 'invite' | 'email_change';
site_url: string;
};
}
Deno.serve(async (request) => {
const payload = await request.text();
const headers = Object.fromEntries(request.headers);
let data: HookPayload;
try {
// Supabase signs the hook with the standard webhooks format. Verify
// before acting, or anyone who finds the URL can make you send mail.
data = new Webhook(hookSecret).verify(payload, headers) as HookPayload;
} catch {
return new Response(JSON.stringify({ error: 'bad signature' }), { status: 401 });
}
const { user, email_data } = data;
const confirmUrl =
`${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 subject =
email_data.email_action_type === 'recovery' ? 'Reset your password' : 'Confirm your email';
const response = await fetch('https://emails.sh/v1/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
from,
to: [user.email],
subject,
html: `<p><a href="${confirmUrl}">${subject}</a></p>
<p>Or enter this code: <strong>${email_data.token}</strong></p>
<p>This link expires in one hour.</p>`,
text: `${subject}: ${confirmUrl}\n\nOr enter this code: ${email_data.token}`
})
});
if (!response.ok) {
// Returning an error tells Supabase Auth the send failed, which surfaces
// to the user instead of silently swallowing it.
const body = await response.text();
return new Response(JSON.stringify({ error: { http_code: response.status, message: body } }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
return new Response('{}', { headers: { 'Content-Type': 'application/json' } });
});Sending your own transactional mail
supabase/functions/send-receipt/index.ts
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const apiKey = Deno.env.get('EMAILSSH_API_KEY')!;
const from = Deno.env.get('EMAILSSH_FROM') ?? 'Acme <onboarding@emails.sh>';
Deno.serve(async (request) => {
// The caller's JWT, forwarded so RLS applies to the query below. Never use
// the service role key here: it bypasses every policy you wrote.
const authorization = request.headers.get('Authorization') ?? '';
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!,
{ global: { headers: { Authorization: authorization } } }
);
const {
data: { user }
} = await supabase.auth.getUser();
if (!user) return new Response('Unauthorized', { status: 401 });
const { orderId } = (await request.json()) as { orderId: string };
const { data: order, error } = await supabase
.from('orders')
.select('id, total_cents')
.eq('id', orderId)
.single();
if (error || !order) return new Response('Not found', { status: 404 });
await fetch('https://emails.sh/v1/emails', {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
from,
to: [user.email],
subject: `Receipt for order ${order.id}`,
html: `<p>Thanks. We charged $${(order.total_cents / 100).toFixed(2)}.</p>`,
text: `Thanks. We charged $${(order.total_cents / 100).toFixed(2)}.`,
idempotency_key: `receipt:${order.id}`
})
});
return Response.json({ ok: true });
});Worth knowing
The Auth Hook replaces the built in email entirely
Once the Send Email Hook is enabled, Supabase stops sending its own confirmation and recovery mail and calls your function instead. If your function errors, no email goes out at all, so watch its logs after you enable it.
Custom SMTP is the other route, and it is simpler
Authentication, Settings, SMTP lets you point Supabase at an SMTP server without writing a function. Use the hook when you want your own templates, per-type routing, or logging; use SMTP when you only want the from address and the rate limit changed.
Deploy the hook with --no-verify-jwt
Auth calls the hook with a webhook signature, not a user JWT. Leave JWT verification on and every hook call is rejected before your code runs, so signups stop working.
Never put a service role key in client code
It bypasses row level security completely. Edge Functions are the right place for privileged keys, and the receipt function above deliberately uses the anon key plus the caller JWT so policies still apply.
Questions
How do I use a custom email provider with Supabase Auth?
Either point custom SMTP at a provider in Authentication settings, or enable the Send Email Auth Hook and send from an Edge Function, as above.
Why are my Supabase confirmation emails not arriving?
The built in service is rate limited and sends from a shared domain. Moving to your own verified domain through a hook or custom SMTP fixes both the limit and the deliverability.
Can I keep Supabase templates and only change the sender?
Yes, that is exactly what custom SMTP does. The hook is for when you want to render the email yourself.
How do I build the confirmation link myself?
The hook payload gives you token_hash, email_action_type, site_url, and redirect_to. Combine them into {site_url}/auth/v1/verify?token={token_hash}&type={type}&redirect_to={redirect_to}.
The rest of the API
POST /v1/emails sends one and POST /v1/emails/batch sends up to 100 in a call. GET /v1/emails/:id returns the delivery events for a message, which is how you answer "did it arrive" without a support ticket. Webhooks push the same events to you as they happen.