Overview
Supabase Auth sends the emails for sign-up, password reset, magic links, invites and email changes. In a hosted project, Supabase's built-in mailer only delivers to members of your Supabase organization and allows 2 emails per hour for the whole project. Real users never receive their reset or confirmation email, and retries fail on the hourly limit.
aSaaSin fixes this with the Send Email hook: Supabase Auth calls a Supabase Edge Function (send-email) that renders the email with React Email and sends it through Resend. Newsletter and waitlist emails sent from Next.js are unaffected and keep using services/email.
The setup is opt-in per Supabase project. Until the hook is created and enabled, Supabase keeps sending its built-in emails. The feature flags (ENABLE_AUTH, ENABLE_SUPABASE, ENABLE_RESEND) do not switch it: the hook is the switch, and the Edge Function reads its own secrets.
How it works
Supabase Auth ──signed POST──▶ Edge Function `send-email`
verify signature → plan → render → Resend
Mail link ──▶ app /auth/confirm (Continue button) ──verifyOtp──▶ session ──▶ target page- Auth triggers the hook instead of its own mailer.
- Send Email hook posts a signed payload (Standard Webhooks) to the function.
- Edge Function verifies the signature, plans one or two emails, renders HTML and plain text, and sends them with Resend.
/auth/confirmis the page every link points to. It verifies the one-time token only when the user presses Continue, then creates the session and redirects.
Everything the function imports lives under supabase/functions/, because the Supabase CLI only bundles that folder. The reusable parts (layout, Resend transport, sender parsing) are in _shared/email/, the Supabase-specific parts (payload, plan, redirects, webhook, copy) in _shared/auth-email/.
What is included
Supabase email_action_type | Link type | |
signup | Confirm sign-up | signup |
recovery | Password reset | recovery |
magiclink, email | Sign-in link plus one-time code | magiclink, email |
invite | Invitation | invite |
email_change | One or two emails, see below | email_change |
reauthentication | Code only, no link | none |
Secure Email Change sends two emails: one to the current address and one to the new address. Supabase crosses its fields on purpose (the current address gets token and token_hash_new, the new address gets token_new and token_hash). The function handles this and only answers 200 once Resend accepted both. With Secure Email Change off, only the new address gets an email.
Customize brand and copy
- Brand - name, logo and colors live in
supabase/functions/_shared/email/brandConfig.ts. The Edge Function cannot importconfig/orconstants/colors.ts, so these values are copies. A test (brand.test.ts) fails when they drift fromSITE.name,HexColorsor the logo file inpublic/. Change the brand in the app first, then updatebrandConfig.ts. - Copy - one file per locale, typed as
AuthEmailCopy:copy.en.ts(default) andcopy.de.tsas a second example. The brand name is interpolated. - Layout - inline-styled, table-based React elements rendered by
@react-email/render(HTML and plain text).
Add a locale
Add one file copy.<locale>.ts of type AuthEmailCopy next to copy.en.ts, then import it in supabase/functions/send-email/index.ts in place of copyEn. The function sends one locale per deployment; it does not pick a language per user.
// supabase/functions/send-email/index.ts
import { copyEn } from '../_shared/auth-email/copy.en.ts';
// To send German emails, import `copyDe` from copy.de.ts instead.Configuration
Follow the steps in this order. Rehearse on a separate Supabase project first if you can.
Step 1: Deploy the app first
The app contains the /auth/confirm page that every email link points to. Deploy it to production before you enable the hook (see Deployment).
Step 2: Deploy the Edge Function
supabase login
supabase link --project-ref <project-ref>
supabase functions deploy send-email --no-verify-jwt--no-verify-jwt is required: Auth signs the call instead of sending a user JWT. It is also set in supabase/config.toml. The handler rejects every request without a valid signature.
Step 3: Create the hook and set the secrets
- In the Supabase dashboard open Authentication → Hooks → Send Email and choose HTTPS. Keep it switched off while you set it up.
- Set the URL to
https://<project-ref>.supabase.co/functions/v1/send-email. - Generate the secret and copy the value (
v1,whsec_…). - Set the secrets with the Supabase CLI (quote the values):
supabase secrets set RESEND_API_KEY='<key>' EMAIL_FROM='Your App <noreply@example.com>' SEND_EMAIL_HOOK_SECRET='v1,whsec_…' AUTH_EMAIL_ALLOWED_ORIGINS='https://app.example.com,https://www.app.example.com'Then run the quick deploy check below and switch the hook on.
AUTH_EMAIL_ALLOWED_ORIGINS is required and comma separated. The first entry is the default origin for links and the logo, so put the canonical host first.
While the hook is on but a secret is missing, the function answers 500 Email hook is not configured, logs the missing variable names, and Auth reports an error to the user (resets and sign-ups fail visibly).
Step 4: Raise the emails-per-hour limit
Open Authentication → Rate Limits and set the emails-per-hour value. With the hook the 2/hour cap no longer applies. Start low and raise it only after checking your Resend plan quota.
Step 5: Keep the redirect allow list, check Resend tracking
- In Authentication → URL Configuration keep your production origins'
/auth/callback**andhttp://localhost:3000/auth/callback**in the redirect allow list. Links sent before the switch keep working through the code exchange in/auth/callback. - In Resend, make sure click and open tracking is off for the sending domain. A rewritten link breaks the one-time token.
Where each setting lives
| Where | Setting | Notes |
| Vercel / Next.js | RESEND_API_KEY, RESEND_NOTIFICATION_EMAIL, NEXT_PUBLIC_APP_URL | Unchanged. Used by newsletter and waitlist emails; NEXT_PUBLIC_APP_URL is also the fallback origin for auth actions when a request has no origin header |
| Supabase Edge Function secrets | RESEND_API_KEY, EMAIL_FROM | Same Resend key and sender as the app. Vercel variables are not visible to Edge Functions |
| Supabase Edge Function secrets | SEND_EMAIL_HOOK_SECRET | v1,whsec_…, generated by the dashboard when the hook is created |
| Supabase Edge Function secrets | AUTH_EMAIL_ALLOWED_ORIGINS | Required. First entry is the default origin |
| Supabase dashboard | Send Email hook, redirect allow list, emails per hour | Authentication → Hooks, URL Configuration, Rate Limits |
| Resend | Verified domain, API key with sending access, tracking off | Click and open tracking must be off |
Set Edge Function secrets with supabase secrets set, never in Vercel and never from your app's .env file. .env.example lists the names only. SUPABASE_URL is not needed because links point at the app, not at Supabase's /auth/v1/verify. If EMAIL_FROM has no display name, the brand name from brandConfig.ts is used.
Local development and testing
pnpm run email:preview # writes .local/email-preview/*.html and .txt, sends nothing
pnpm run test:auth-email # templates, webhook, plan, redirects, handler, confirm/forgot/reset actions
pnpm exec tsc --noEmit
pnpm run lint- Resend is faked in every test; nothing is sent.
- The preview builds the logo URL from
NEXT_PUBLIC_APP_URL, so the logo only loads while the app runs. - Local Supabase keeps using Mailpit (
http://127.0.0.1:54324) by default, because the[auth.hook.send_email]block insupabase/config.tomlstays commented out. Enabling it would send real mail through Resend instead. - Optional local hook test: run the function with the CLI (needs Docker), uncomment the hook block in
supabase/config.toml, and send to Resend's test recipientdelivered@resend.dev.
supabase functions serve send-email --env-file <env-file>Not verified: the Edge Function has run on Supabase in production, but this local supabase functions serve path has not been exercised. Use an env file that contains the four function secrets, not your app's .env.
Verify in production
- Deploy the app first, then the function, then create the hook.
- Sign up with an address outside your Supabase team. The mail arrives from your sender, the button leads to a Continue page, then to the dashboard with a success toast.
- Reset a password for an external account. The mail arrives, Continue opens
/reset-password, you set a password and land on the dashboard. - Reuse an old reset link. You should see "The link is invalid or has expired" on the forgot-password page, not a silent redirect.
- Change an email address (if you use it) and confirm. With Secure Email Change on, both emails arrive.
- Check Resend → Emails (delivered, no click tracking) and Supabase → Edge Functions →
send-email→ Logs (nosignature_invalidorsend_failed).
Use delivered@resend.dev for tests that must not reach a person.
Quick deploy check
# Not POST: expect 405
curl -i https://<project-ref>.supabase.co/functions/v1/send-email
# Unsigned POST: expect 401 when all four secrets are set, 500 otherwise
curl -i -X POST https://<project-ref>.supabase.co/functions/v1/send-email \
-H 'Content-Type: application/json' -d '{}'The 500 body only says Email hook is not configured.. The names of the missing variables appear in the Edge Function logs (config_missing), not in the response. These status codes follow the handler and its tests; they were not run against a deployed project for this page.
Troubleshooting
| Symptom | Where to look |
| "We could not send the link" on the forgot-password form | Edge Function logs for config_missing or send_failed (status and code), then Resend logs |
signature_invalid in the logs | Secret mismatch. Copy v1,whsec_… again into SEND_EMAIL_HOOK_SECRET |
action_unsupported in the logs | A security notification is enabled in Auth, see Security notifications below |
Link or logo points at supabase.co | AUTH_EMAIL_ALLOWED_ORIGINS is wrong, or the function is an old deploy |
| Link opens "Invalid link" | A mail client cut the query string. Request a new email |
| "The link is invalid or has expired" right away | The token was already used (a scanner or an earlier click) or is older than the OTP expiry |
auth.code_exchange_failed in app logs | An old PKCE link was opened in another browser. Request a new link |
| "Too many requests. Please wait a moment and try again." | Auth rate limits: emails per hour (Authentication → Rate Limits), and Supabase's per-user limit of one request per 60 seconds for resets, sign-ups and OTPs |
Security notes
- Signature first. The function verifies
webhook-id,webhook-timestampandwebhook-signatureover the raw body (Standard Webhooks, 5 minute tolerance) before it parses or renders anything. Bodies over 64 KB are refused. - Redirects are restricted. The link origin is the one in
redirect_toonly if it is listed inAUTH_EMAIL_ALLOWED_ORIGINS, otherwise the first entry. Only/auth/callbacksurvives as the post-login path. The callback and/auth/confirmaccept relative paths only. - Verify on click. Links open
/auth/confirm, which verifies the token only after Continue, so mail scanners and link previews cannot use it up. It also works from a different browser than the request. - Retries are safe. Resend idempotency keys derive from
webhook-id, so a retried hook never sends a second copy. - No secrets in logs. Logs contain action types, status codes, Resend error names and the names of missing variables only: no tokens, links, addresses, payloads or secrets.
Security notifications
Supabase can also send notifications such as "password changed" or "email changed". With the hook enabled they would arrive at this function, but there are no templates for them, so it answers 422 and logs action_unsupported. Keep these notifications disabled in the Authentication settings until you add templates (a new kind in plan.ts and a string per locale).
Rollout and rollback
- Rollout order: app, function, secrets, hook.
- Rollback: disable the hook, then the app if needed. Disabling the hook restores the sender Supabase would use without it: its built-in mailer (team members only, 2 emails per hour) unless you configured custom SMTP.
- Keep
/auth/confirmdeployed until the links already sent have expired.
Limitations
- Only the six action types in the table above are handled. Security notifications are not supported (answered with 422).
reauthenticationsends a code only, with no link.- One locale per deployment. There is no per-user language.
- Delivery is Resend only. Another provider means replacing
_shared/email/transport.ts. - The repository tests fake Resend. There is no automated end-to-end test against a live Supabase project.
- Local hook testing with
supabase functions servehas not been exercised. - The manual setup (hook, secrets, rate limit) is per Supabase project and is not scripted.