Supabase Email Rate Limit Exceeded: Why This Codebase Doesn't Fight It
Supabase's built-in email provider caps auth emails project-wide. This codebase relies on that limit by design (R-13) instead of building a bespoke one — and builds one anyway, elsewhere.
"Email rate limit exceeded" comes from Supabase Auth's built-in email provider, which caps how many authentication emails a project can send — confirmation, magic link, password reset, resend — over a fixed window, the same for every plan tier. This codebase hits that ceiling by design in one specific path, and its fix isn't a workaround: it's a comment explaining why no workaround was built, tied to a named rule in this project's own security review.
What actually triggers this error
Supabase Auth's built-in email sending exists to get a new project working without asking for SMTP credentials on day one, and it's deliberately conservative about volume — a low, fixed, project-wide ceiling that applies the same way regardless of plan. One behavior is worth knowing before you go looking for the bug in your own code: a signup attempt that fails validation before any email would have been sent can still count against the limit. That's not a guess — it's a documented bug report against Supabase's own auth server, confirmed by triggering five failed signups (bad password, no email sent) and watching the fifth one return 429 Email rate limit exceeded anyway. If you're hammering a signup form in development with intentionally-invalid input to test error states, you can burn through the quota without a single real email going out.
Where this codebase actually meets the limit
src/lib/actions/account.ts has exactly one code path that calls Supabase's email-sending API directly — resending the signup confirmation email for a user who hasn't verified yet:
// src/lib/actions/account.ts
/**
* Resends the signup confirmation email for the "email unverified" banner
* (spec §9). Bound directly to a `<form action={...}>` with no client
* wrapper needed; always redirects back to the dashboard with a status flag
* rather than returning state, so it works from a plain Server Component.
* Relies on Supabase Auth's own per-project email rate limiting (R-13 —
* durable, not process memory) rather than a bespoke limiter.
*/
export async function resendVerificationEmail(): Promise<void> {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user?.email) {
redirect("/dashboard");
}
const { error } = await supabase.auth.resend({
type: "signup",
email: user.email,
});
if (error) {
console.error("account: resendVerificationEmail failed", error);
redirect("/dashboard?verification=error");
}
// ...
}
Nothing in this repository configures a custom SMTP provider for Supabase Auth's own emails — the app's Resend integration (src/lib/email/purchaseWelcome.ts, refundRequest.ts) sends application email, the purchase receipt and refund notices, calling api.resend.com directly from application code. It has nothing to do with what supabase.auth.resend() uses under the hood. Unless the connected Supabase project's dashboard has a custom SMTP provider configured, calls to resendVerificationEmail() go through Supabase's own limited built-in sender and inherit its ceiling. That's the gap this codebase's own go-live checklist tracks separately, as dashboard configuration rather than application code.
Why this isn't fought with a bespoke limiter — the R-13 decision
The comment above names the decision directly: rely on Supabase's own rate limiting rather than building a custom one, because it's "durable, not process memory." That phrase is doing real work. A rate limiter implemented as an in-memory counter in a serverless function resets every time a new instance spins up — which, on most serverless platforms, is often. A user who happens to hit two different warm instances gets two full quotas. Supabase's limiter lives in its own infrastructure, keyed by project, so it holds regardless of which instance of this app's code happens to run the request. For a "please resend my confirmation email" button, borrowing a durable limiter that already exists is a smaller surface than building and testing one from scratch.
The other rate limiter in this codebase, and why it's built differently
This app does build its own rate limiter elsewhere — the download route enforces a per-user hourly download cap with an atomic, serialized database RPC (record_download_within_limit), designed specifically to survive concurrent requests without a race condition. The two decisions look inconsistent side by side until you compare what each one is protecting:
| Email resend | File download | |
|---|---|---|
| What a race condition costs if it slips through | One extra confirmation email | An extra download against a purchased, metered entitlement |
| Who else is affected by getting it wrong | Nobody — worst case is a duplicate email | Every buyer, if the limit becomes unenforceable |
| Where the limiter lives | Supabase's own infrastructure (borrowed) | This app's own Postgres, behind a SECURITY DEFINER RPC (built) |
| Why that choice fits | The consequence of a miss is trivial | The consequence of a miss is a scarce, purchased resource being over-issued |
Building a bespoke limiter is real engineering work — the download route's version needed an atomic RPC specifically to close a TOCTOU race between checking the count and recording a download, detailed in the gated-downloads post rather than repeated here. That effort is spent where getting it wrong has a cost. Email resends don't meet that bar, so the app leans on a limiter it didn't have to build, test, or maintain — a decision the comment makes explicit instead of leaving it to look like an oversight.
The shape of the error, and how this codebase's own 429s look
supabase.auth.resend() doesn't throw when the limit trips — it returns an error object, which resendVerificationEmail() above logs and redirects past with a generic status flag. That's a deliberately quiet failure mode for a low-stakes action a user can just try again. Contrast that with how the download route responds when its rate limit trips:
// src/app/api/download/route.ts
if (result.status === 429) {
return NextResponse.json(
{ error: result.message },
{
status: 429,
headers: { "Retry-After": String(result.retryAfterSeconds) },
},
);
}
An explicit 429 status and a Retry-After header the caller can actually parse — because the download route is an API a client might call programmatically and retry against, where a swallowed error and a generic redirect would be the wrong contract. The email-resend path is a form submission from a person, not an API a script calls, so redirecting with a status flag is enough; building out the same explicit-status contract there would be effort spent on a caller that doesn't exist.
What to actually do about the error
| Symptom | Cause | Fix |
|---|---|---|
429 Email rate limit exceeded while testing signup repeatedly in development | Supabase's built-in provider's low ceiling, shared across every auth email type | Space out test signups, or configure a custom SMTP provider in the Supabase dashboard for local/staging projects |
| The limit trips after several failed signup attempts, no emails sent | Failed validation still counts against the quota in Supabase's auth server | Fix the client-side validation causing repeated failed attempts rather than assuming the limiter itself is broken |
| Upgrading from Free to Pro doesn't raise the built-in limit | The built-in email provider's rate limit isn't a plan-tier feature | Configure your own SMTP provider (Resend, Postmark, SES) in the Supabase Auth dashboard — the limit that actually raises is on your provider's account, not Supabase's |
| A "resend confirmation" button appears to silently do nothing | resend() returned an error that's logged server-side but not surfaced to the user | Check server logs for the console.error this codebase's own handler emits, and consider surfacing a rate-limited state in the UI rather than a generic redirect |
| Rate limiting works differently across two serverless instances of a bespoke limiter | An in-memory counter that resets per cold start | Move the limiter's state into a database or other durable store shared across instances — the same reasoning behind relying on Supabase's own limiter here |
Frequently asked questions
Does upgrading my Supabase plan raise the email rate limit? No — the built-in email provider's limit isn't a paid-plan feature; multiple developers have reported hitting the same ceiling on Pro as on Free. The actual fix for production volume is configuring your own SMTP provider in the Auth dashboard, which moves sending (and its limits) onto your own provider account instead of Supabase's shared default sender.
Why would a failed signup count against an email rate limit?
Because Supabase's auth server appears to increment the counter as part of processing the signup attempt itself, before the outcome (email sent or not) is determined — a behavior documented as a bug against Supabase's own auth repository rather than intended design. If you're testing invalid-input error states against a real Supabase project, expect that testing to consume quota even though no email goes out.
Should every app build its own rate limiter instead of relying on a vendor's? No — the decision in this codebase is specifically that relying on Supabase's limiter is correct for low-stakes email resends, not that vendor limiters are always the right call. The app's own download-rate-limiter exists precisely because the resource it protects (a purchased, metered download) has a cost profile a shared vendor default isn't designed around. This kind of "what does Supabase give you for free, and where do you still have to build it yourself" question runs through the rest of this app's Supabase decisions too — Supabase vs PlanetScale inventories the same trade for row-level security, line by line against this storefront's own migrations.
Is there a per-user cooldown separate from the project-wide cap? Community reports describe a shorter, per-user cooldown specifically on resending the same email, layered on top of the project-wide cap — so a user clicking "resend" repeatedly can hit an individual limit before the whole project's quota is affected. Check Supabase's own current rate-limits documentation for the exact figures rather than trusting a specific number here: defaults are exactly the kind of thing vendors change between when this is written and when it's read.
Templates in this post
ASoc Catalyst markets an AI-automation agency with a service grid and three-tier pricing, ASoc Chain is a DeFi-protocol marketing site with self-custody messaging, and ASoc Cognition markets an AI-consulting agency with case results — all built on the same Supabase auth stack this post's rate-limit decision comes from.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
