From 506e0fcfc33f73f4557da3864acd72a5c6624a0a Mon Sep 17 00:00:00 2001 From: Otitodev <198799600+Otitodev@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:40:35 +0100 Subject: [PATCH 1/2] docs: a Supabase Edge Function that sends Auth emails through SESKit --- docs/guides/supabase/send-email.ts | 383 +++++++++++++++++++++++++++++ 1 file changed, 383 insertions(+) create mode 100644 docs/guides/supabase/send-email.ts diff --git a/docs/guides/supabase/send-email.ts b/docs/guides/supabase/send-email.ts new file mode 100644 index 0000000..19ff0d4 --- /dev/null +++ b/docs/guides/supabase/send-email.ts @@ -0,0 +1,383 @@ +// Supabase Auth "Send Email" hook -> SESKit. +// +// Supabase calls this with a signed request each time Auth needs to send an +// email. It checks the signature, writes the message, and hands it to SESKit. +// +// Secrets (supabase secrets set ...): +// SEND_EMAIL_HOOK_SECRET the hook secret from Auth > Hooks, "v1,whsec_..." +// SESKIT_URL where SESKit runs, e.g. https://mail.example.com +// SESKIT_API_KEY a SESKit API key made for this function +// FROM_ADDRESS a sender on a domain verified in SESKit +// APP_NAME optional, used in subjects and bodies +// SUPABASE_URL is provided by Supabase. + +const encoder = new TextEncoder(); + +// How long we wait for SESKit. Supabase gives the whole hook 5 seconds. +const SESKIT_TIMEOUT_MS = 3000; + +// A signature older or newer than this is refused, so a captured request +// cannot be replayed later. +const TOLERANCE_SECONDS = 5 * 60; + +export type Config = { + hookSecret: string; + seskitUrl: string; + seskitApiKey: string; + fromAddress: string; + authUrl: string; + appName: string; +}; + +export type HookPayload = { + user: { id: string; email: string; new_email?: string }; + email_data: { + token?: string; + token_hash?: string; + token_new?: string; + token_hash_new?: string; + redirect_to?: string; + email_action_type: string; + }; +}; + +export type Mail = { to: string; token: string; tokenHash: string }; + +export type Message = { subject: string; text: string; html: string }; + +function base64ToBytes(value: string): Uint8Array { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +function toHex(bytes: ArrayBuffer): string { + return [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +// Standard Webhooks: the signature is HMAC-SHA256 over "id.timestamp.body", +// sent as "v1," (several may be listed, separated by spaces). +export async function verifySignature( + body: string, + headers: Headers, + secret: string, + nowSeconds: number = Math.floor(Date.now() / 1000), +): Promise { + const id = headers.get("webhook-id"); + const timestamp = headers.get("webhook-timestamp"); + const signatures = headers.get("webhook-signature"); + if (!id || !timestamp || !signatures) return false; + + const sent = Number(timestamp); + if (!Number.isInteger(sent) || Math.abs(nowSeconds - sent) > TOLERANCE_SECONDS) return false; + + let key: CryptoKey; + try { + const raw = base64ToBytes(secret.replace(/^v1,whsec_/, "")); + key = await crypto.subtle.importKey("raw", raw, { name: "HMAC", hash: "SHA-256" }, false, [ + "verify", + ]); + } catch { + return false; + } + + const signed = encoder.encode(`${id}.${timestamp}.${body}`); + for (const candidate of signatures.split(" ")) { + const [version, value] = candidate.split(","); + if (version !== "v1" || !value) continue; + let given: Uint8Array; + try { + given = base64ToBytes(value); + } catch { + continue; + } + // verify() compares in constant time. + if (await crypto.subtle.verify("HMAC", key, given, signed)) return true; + } + return false; +} + +// The link Supabase's own templates build: the user's browser goes to Auth, +// which checks the token and sends them on to redirect_to. +export function confirmationLink( + authUrl: string, + tokenHash: string, + type: string, + redirectTo: string | undefined, +): string { + const url = new URL("/auth/v1/verify", authUrl); + url.searchParams.set("token", tokenHash); + url.searchParams.set("type", type); + if (redirectTo) url.searchParams.set("redirect_to", redirectTo); + return url.toString(); +} + +// One email per call, except a secure email change, which needs two. Supabase +// names the pairs backwards for compatibility: the "_new" suffix does NOT mean +// the new address. The current address gets token + token_hash_new and the new +// address gets token_new + token_hash. +export function mailsFor(payload: HookPayload): Mail[] { + const { user, email_data: data } = payload; + if (data.email_action_type !== "email_change") { + return [{ to: user.email, token: data.token ?? "", tokenHash: data.token_hash ?? "" }]; + } + if (data.token_hash && data.token_hash_new) { + const mails: Mail[] = [ + { to: user.email, token: data.token ?? "", tokenHash: data.token_hash_new }, + ]; + if (user.new_email) { + mails.push({ to: user.new_email, token: data.token_new ?? "", tokenHash: data.token_hash }); + } + return mails; + } + const to = user.new_email ?? user.email; + return [ + { + to, + token: data.token || data.token_new || "", + tokenHash: data.token_hash || data.token_hash_new || "", + }, + ]; +} + +// Stable for one email and different for the next one, so Supabase retrying +// the same hook cannot send twice but a second request for a reset still +// sends. Only a hash of the token goes in, and a hash of the whole thing. +export async function idempotencyKey( + action: string, + userId: string, + mail: Mail, +): Promise { + const input = `${action}\n${userId}\n${mail.to}\n${mail.tokenHash}\n${mail.token}`; + return `supabase-${toHex(await crypto.subtle.digest("SHA-256", encoder.encode(input)))}`; +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +type Copy = { subject: string; intro: string; button?: string; link: boolean; code: boolean }; + +const COPY: Record Copy> = { + signup: (app) => ({ + subject: `Confirm your email for ${app}`, + intro: `Confirm your email address to finish creating your ${app} account.`, + button: "Confirm email", + link: true, + code: false, + }), + invite: (app) => ({ + subject: `You have been invited to ${app}`, + intro: `You have been invited to ${app}. Accept the invitation to set up your account.`, + button: "Accept invitation", + link: true, + code: false, + }), + magiclink: (app) => ({ + subject: `Your sign-in link for ${app}`, + intro: `Use this to sign in to ${app}.`, + button: "Sign in", + link: true, + code: true, + }), + recovery: (app) => ({ + subject: `Reset your ${app} password`, + intro: `Someone asked to reset the password for your ${app} account.`, + button: "Reset password", + link: true, + code: false, + }), + email_change: (app) => ({ + subject: `Confirm your new email for ${app}`, + intro: `Confirm this change to the email address on your ${app} account.`, + button: "Confirm change", + link: true, + code: false, + }), + email: (app) => ({ + subject: `Your ${app} sign-in code`, + intro: `Enter this code to sign in to ${app}.`, + link: false, + code: true, + }), + reauthentication: (app) => ({ + subject: `Confirm it is you on ${app}`, + intro: `Enter this code to confirm a change to your ${app} account.`, + link: false, + code: true, + }), +}; + +export const SUPPORTED_ACTIONS = Object.keys(COPY); + +const IGNORE_NOTE = "If you did not ask for this, you can ignore this email."; + +// Everything interpolated into the HTML is escaped. The link is built by +// confirmationLink() from a URL we construct, never taken from the payload. +export function render( + action: string, + appName: string, + mail: Mail, + link: string, +): Message | null { + const make = COPY[action]; + if (!make) return null; + const copy = make(appName); + + const textParts = [copy.intro, ""]; + const htmlParts = [`

${escapeHtml(copy.intro)}

`]; + if (copy.link && mail.tokenHash) { + textParts.push(`${copy.button}: ${link}`, ""); + htmlParts.push( + `

${escapeHtml(copy.button ?? "Continue")}

`, + ); + } + if (copy.code && mail.token) { + textParts.push(`Your code: ${mail.token}`, ""); + htmlParts.push(`

Your code: ${escapeHtml(mail.token)}

`); + } + textParts.push(IGNORE_NOTE); + htmlParts.push(`

${escapeHtml(IGNORE_NOTE)}

`); + + return { subject: copy.subject, text: textParts.join("\n"), html: htmlParts.join("\n") }; +} + +function reply(status: number, message?: string): Response { + const body = message ? { error: { http_code: status, message } } : {}; + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +// Logs carry a category and a status, never an address, a token or a link. +function log(event: string, detail: Record = {}): void { + console.error(JSON.stringify({ event, ...detail })); +} + +export async function handle( + req: Request, + config: Config, + send: typeof fetch = fetch, +): Promise { + if (req.method !== "POST") return reply(405, "Method not allowed"); + + const body = await req.text(); + if (!(await verifySignature(body, req.headers, config.hookSecret))) { + log("signature_rejected"); + return reply(401, "Invalid signature"); + } + + let payload: HookPayload; + try { + payload = JSON.parse(body); + if (typeof payload.user?.email !== "string" || typeof payload.user?.id !== "string") { + throw new Error("no user"); + } + if (typeof payload.email_data?.email_action_type !== "string") throw new Error("no action"); + } catch { + log("payload_rejected"); + return reply(400, "Unreadable payload"); + } + + const action = payload.email_data.email_action_type; + if (!SUPPORTED_ACTIONS.includes(action)) { + // A signed request for something we have no template for. Failing it would + // block the user's sign-in over an email; skipping it is visible in logs. + log("unsupported_action", { action: /^[a-z_]{1,40}$/.test(action) ? action : "invalid" }); + return reply(200); + } + + for (const mail of mailsFor(payload)) { + const link = confirmationLink( + config.authUrl, + mail.tokenHash, + action, + payload.email_data.redirect_to, + ); + const message = render(action, config.appName, mail, link); + if (!message) return reply(200); + + let response: Response; + try { + response = await send(`${config.seskitUrl}/v1/emails`, { + method: "POST", + headers: { + Authorization: `Bearer ${config.seskitApiKey}`, + "Content-Type": "application/json", + "Idempotency-Key": await idempotencyKey(action, payload.user.id, mail), + }, + body: JSON.stringify({ + from: config.fromAddress, + to: mail.to, + subject: message.subject, + text: message.text, + html: message.html, + }), + signal: AbortSignal.timeout(SESKIT_TIMEOUT_MS), + }); + } catch { + // Timed out or could not connect. Supabase retries a 503, and the + // idempotency key makes the retry safe. + log("seskit_unreachable"); + return reply(503, "Could not reach SESKit"); + } + + if (response.status === 429 || response.status >= 500) { + log("seskit_unavailable", { status: response.status }); + return reply(503, "SESKit could not take the message"); + } + if (!response.ok) { + // A key or a sender that SESKit refuses will be refused again. Retrying + // would not help, so say so once. + log("seskit_refused", { status: response.status }); + return reply(400, "SESKit refused the message"); + } + } + return reply(200); +} + +type DenoRuntime = { + env: { get(name: string): string | undefined }; + serve(handler: (req: Request) => Promise | Response): void; +}; + +function configFromEnv(env: DenoRuntime["env"]): Config | null { + const get = (name: string) => env.get(name)?.trim() ?? ""; + const config = { + hookSecret: get("SEND_EMAIL_HOOK_SECRET"), + seskitUrl: get("SESKIT_URL").replace(/\/+$/, ""), + seskitApiKey: get("SESKIT_API_KEY"), + fromAddress: get("FROM_ADDRESS"), + authUrl: get("SUPABASE_URL"), + appName: get("APP_NAME") || "your account", + }; + const missing = Object.entries({ + SEND_EMAIL_HOOK_SECRET: config.hookSecret, + SESKIT_URL: config.seskitUrl, + SESKIT_API_KEY: config.seskitApiKey, + FROM_ADDRESS: config.fromAddress, + SUPABASE_URL: config.authUrl, + }) + .filter(([, value]) => !value) + .map(([name]) => name); + if (missing.length) { + log("config_missing", { missing: missing.join(",") }); + return null; + } + return config; +} + +const deno = (globalThis as { Deno?: DenoRuntime }).Deno; +if (deno) { + deno.serve((req) => { + const config = configFromEnv(deno.env); + return config ? handle(req, config) : reply(500, "Not configured"); + }); +} From 6b446c99fc4640cdea13082c646bb4b12d2fb506 Mon Sep 17 00:00:00 2001 From: Otitodev <198799600+Otitodev@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:42:00 +0100 Subject: [PATCH 2/2] docs: a guide to sending Supabase auth emails through SESKit --- docs/guides/supabase-auth-emails.md | 157 ++++++++++++++++++++++++++++ mkdocs.yml | 2 + 2 files changed, 159 insertions(+) create mode 100644 docs/guides/supabase-auth-emails.md diff --git a/docs/guides/supabase-auth-emails.md b/docs/guides/supabase-auth-emails.md new file mode 100644 index 0000000..40b187b --- /dev/null +++ b/docs/guides/supabase-auth-emails.md @@ -0,0 +1,157 @@ +# Send Supabase auth emails through SESKit + +Supabase Auth sends sign-up confirmations, password resets and magic links +itself, from a shared mailer that is meant for development and is tightly +rate limited. Supabase can hand that job to your own code instead. This guide +gives it a small function that passes each email to SESKit, so it is sent from +your domain and shows up in the dashboard, the events and the suppression +list like any other message. + +```text +Supabase Auth → your Edge Function → POST /v1/emails on SESKit → Amazon SES +``` + +!!! caution "Not yet run against a live Supabase project" + The function is tested on its own: signatures, links, templates, retries and + the case where SESKit is down. It has not yet been run by SESKit's + maintainers against a real Supabase project. Do the [test signup](#test-it) + on a project you can afford to break before you rely on it. + + The function is TypeScript because Supabase Edge Functions run on Deno. + It runs on Supabase, not on your SESKit server, and SESKit itself needs no + Node.js. + +## Before you start + +- **SESKit reachable over HTTPS from the internet.** The function runs on + Supabase's servers and calls SESKit, so `http://localhost:8000` will not do. +- **A verified sender** — see [verify a sender](verify-a-sender.md). The function + sends from one address you choose, such as `auth@example.com`. +- **SES out of the sandbox.** In the [sandbox](ses-sandbox.md) SES only delivers + to verified addresses, so your real users would not get their emails. +- **A SESKit API key made for this function**, so you can revoke it without + touching anything else. +- The [Supabase CLI](https://supabase.com/docs/guides/cli), logged in and linked + to your project. + +## Add the function + +Create it and replace its contents with the code below: + +```bash +supabase functions new send-email +``` + +Save the code as `supabase/functions/send-email/index.ts`. A copy to download is +at [`send-email.ts`](supabase/send-email.ts). + +```ts +--8<-- "docs/guides/supabase/send-email.ts" +``` + +Deploy it **without JWT verification**: + +```bash +supabase functions deploy send-email --no-verify-jwt +``` + +Supabase Auth calls a hook before it has issued the user a token, so there is no +JWT to check. That does not leave the function open: it refuses any request that +does not carry a valid signature from Supabase (see [what it checks](#what-it-checks)). + +## Turn the hook on + +1. Set the secrets that do not depend on the hook: + + ```bash + supabase secrets set \ + SESKIT_URL=https://mail.example.com \ + SESKIT_API_KEY=sk_... \ + FROM_ADDRESS=auth@example.com \ + APP_NAME="Your app" + ``` + +2. In the Supabase dashboard, open **Authentication → Hooks → Send Email**, choose + **HTTPS**, and set the URL to + `https://.supabase.co/functions/v1/send-email`. +3. Generate the secret there. It looks like `v1,whsec_...`. Save the hook, then + give the function the secret straight away: + + ```bash + supabase secrets set SEND_EMAIL_HOOK_SECRET='v1,whsec_...' + ``` + +Until the last step is done the function answers every request with an error, so +do it in one sitting and not on a busy project. + +## Test it + +1. Sign up with an address you control, then ask for a password reset and a magic + link. +2. Each should arrive from your sender within a few seconds. Open SESKit's + **Emails** page: each appears there, and delivery shows up as it would for any + other message. +3. If one does not, [look at the function's logs](#when-it-fails). + +## What it sends + +| Supabase sends | The email | Has | +|---|---|---| +| `signup` | Confirm your email | a link | +| `invite` | You have been invited | a link | +| `magiclink` | Your sign-in link | a link and a code | +| `recovery` | Reset your password | a link | +| `email_change` | Confirm your new email | a link; a **secure** change sends two emails, one to each address | +| `email` | Your sign-in code | a code | +| `reauthentication` | Confirm it is you | a code | + +Supabase can also send security notifications (a changed password, a linked +identity and similar). The function has no template for these and skips them: +Auth carries on, and the log says what was skipped. To send one, add an entry to +`COPY` in the code. + +The wording is short and plain. Change the text in `COPY` to match your voice; the +HTML is built with every value escaped, so keep it that way if you edit it. + +## What it checks + +Supabase signs each request with the [Standard Webhooks](https://www.standardwebhooks.com/) +scheme, and the function refuses one that is unsigned, altered, signed with another +secret, or older than five minutes. The signature is the only thing that proves a +request came from your Supabase project, which is why the JWT check can be off. + +Keep the hook secret and the SESKit key in Supabase's secrets and nowhere else. +Neither belongs in the code or in your repository. + +## When it fails + +Supabase allows the whole hook **5 seconds**. The function gives SESKit 3 of them. + +| What happened | The function answers | What Supabase does | +|---|---|---| +| SESKit did not answer in time, is down, or is busy | `503` | Retries, up to three times, two seconds apart | +| SESKit refused the message — a wrong key, an unverified sender | `400` | Returns an error to your app; the user sees the sign-up or reset fail | +| The signature is missing or wrong | `401` | Returns an error to your app | + +A retry cannot send an email twice. Each one is sent with an `Idempotency-Key` +built from the email's kind, the user, the address and the token, so SESKit +returns the first message and sends nothing more. A second reset request makes a +new token and so sends a new email. + +The function's logs, under **Edge Functions → send-email → Logs**, carry a short +event name and a status. They never contain an address, a token or a link. + +!!! warning "SESKit is now in the sign-in path" + If SESKit is down for longer than Supabase's retries, users cannot confirm an + account or reset a password until it is back. Supabase's own mailer has the + same dependency on Supabase. Decide whether that trade is right for your app. + +## Limits + +- **Supabase's own email rate limits.** Supabase's documentation does not say + whether turning the hook on lifts them. Look at your project's Auth rate-limit + settings after you enable it. +- **One sender.** Every email comes from `FROM_ADDRESS`. +- **A suppressed address fails the request.** SESKit refuses to send to an address + on the [suppression list](suppression.md) (`422`), the function answers `400`, and + the user sees the sign-up or reset fail. diff --git a/mkdocs.yml b/mkdocs.yml index ac22f51..b840cff 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -124,6 +124,7 @@ plugins: - guides/ses-sandbox.md: What the SES sandbox restricts and how to leave it. - guides/delivery-events.md: How SES reports deliveries and bounces, SQS versus HTTPS ingestion, and open/click tracking. - guides/webhooks.md: Registering endpoints, verifying the HMAC signature, retries, and the SSRF rules. + - guides/supabase-auth-emails.md: A Supabase Edge Function that sends Auth emails (sign-up, reset, magic link) through SESKit instead of Supabase's shared mailer - setup, what it sends, retries and failure behaviour. - guides/metrics.md: The six counts and five rates, and what each rate divides by. - guides/suppression.md: What lands on the suppression list, what a suppressed send answers, and one-click unsubscribe. Reference: @@ -178,6 +179,7 @@ nav: - Leave the SES sandbox: guides/ses-sandbox.md - Delivery events: guides/delivery-events.md - Webhooks: guides/webhooks.md + - Supabase auth emails: guides/supabase-auth-emails.md - Suppression: guides/suppression.md - Reading your metrics: guides/metrics.md - Reference: