PigeonAtlas

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

  1. Verify a sending domain. Add a subdomain such as mail.yourapp.com in 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.
  2. Deploy the function below as send-email, with your PigeonAtlas API key as a secret.
  3. 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.
  4. Raise the limit. Authentication → Rate Limits, and set emails per hour to what your signups need.
supabase/functions/send-email/index.ts
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" } });
});
Secrets and deploy
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