Supabase Auth
Supabase “email rate limit exceeded”
Your app is still sending Auth email through Supabase's built-in provider, which is meant for development. Send it through your own provider instead and the limit becomes yours to set. With PigeonAtlas that is one Edge Function and one verified domain.
Why it happens
Supabase's built-in sender allows 2 emails per hour per project, and only to addresses on your
organization's team. Supabase says plainly that it is
not meant for production use.
Every signup confirmation, magic link, password reset and invite counts, so the third person to sign up in an hour
gets the error (its code is over_email_send_rate_limit).
Apps built with Lovable or Bolt on Supabase run into it the day they launch, because nothing in the builder asks where Auth mail goes. Supabase's rate limits page says the email limit can be configured once you use custom SMTP or the Send Email hook, and not before.
The fix, in about five minutes
-
Verify a sending domain. Add a subdomain such as
mail.yourapp.comin PigeonAtlas and publish the DKIM, SPF and MX records it shows. Nothing sends until they resolve, and Auth mail from a domain you own is also mail that lands in the inbox. -
Deploy the function below as
send-email, with your PigeonAtlas API key as a secret. -
Turn on the hook. In the Supabase dashboard, Authentication → Auth Hooks → Send Email,
choose the function, generate the secret and set it as
SEND_EMAIL_HOOK_SECRET. - Raise the limit. Authentication → Rate Limits, and set emails per hour to what your signups need.
import { Webhook } from "https://esm.sh/standardwebhooks@1.0.0";
const hookSecret = Deno.env.get("SEND_EMAIL_HOOK_SECRET")!.replace("v1,whsec_", "");
const subjects: Record<string, string> = {
signup: "Confirm your email",
magiclink: "Your sign-in link",
recovery: "Reset your password",
invite: "You have been invited",
email_change: "Confirm your new email",
};
Deno.serve(async (req) => {
const payload = await req.text();
const { user, email_data } = new Webhook(hookSecret).verify(
payload,
Object.fromEntries(req.headers),
) as any;
const link = `${Deno.env.get("SUPABASE_URL")}/auth/v1/verify?token=${email_data.token_hash}` +
`&type=${email_data.email_action_type}&redirect_to=${email_data.redirect_to}`;
const response = await fetch("https://pigeonatlas.com/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${Deno.env.get("PIGEONATLAS_API_KEY")}`,
"Content-Type": "application/json",
// Supabase retries a hook that times out; one token, one email.
"Idempotency-Key": `auth-${email_data.token_hash}`,
},
body: JSON.stringify({
from: "Acme <auth@mail.acme.com>",
to: [user.email],
subject: subjects[email_data.email_action_type] ?? "Your sign-in link",
html: `<p><a href="${link}">Continue</a></p><p>Or enter the code ${email_data.token}.</p>`,
text: `Continue: ${link}\n\nOr enter the code ${email_data.token}.`,
}),
});
// A non-200 tells Supabase Auth the email was not sent, so the user
// sees an error instead of waiting for mail that never comes.
if (!response.ok) {
return new Response(JSON.stringify({ error: { http_code: response.status, message: await response.text() } }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
return new Response("{}", { headers: { "Content-Type": "application/json" } });
});
supabase secrets set PIGEONATLAS_API_KEY=pa_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx supabase secrets set SEND_EMAIL_HOOK_SECRET="v1,whsec_..." supabase functions deploy send-email --no-verify-jwt
--no-verify-jwt is right here: Supabase Auth signs the request with the hook secret rather than a user
token, and the function checks that signature before it sends anything. The full payload is in Supabase's
Send Email hook reference.
What about SMTP Settings?
Supabase also accepts a custom SMTP server under Authentication → SMTP Settings, and it starts you at 30 emails an hour until you change it. PigeonAtlas SMTP submission is not open to the internet yet, because the password is your API key and it waits on TLS. When it opens, the SMTP route becomes four fields and no function. The hook keeps working either way.
What the free plan gives you
1,000 emails a month from one verified domain, no card. That is room for an app's sign-ups, magic links and resets while it finds its first users, and the same account sends the rest of your app's mail through the Edge Function route. Plans and prices.
Fix the rate limit on a domain you own.
Start free