Supabase auth rate limit exceeded: the full table
A 429 Too Many Requests from Supabase Auth means you crossed one of nine documented rate limits — most often the built-in email provider's 2 messages per hour, or the 60-second per-user window on signup, OTP and password-reset requests. Find which endpoint you hit in the table below; the fix is usually custom SMTP or client-side throttling, not raising the limit.
These limits are not arbitrary throttling. Most of them are the anti-abuse layer that stops your project being used as a spam relay and stops your login endpoint being brute-forced. That reframes the whole problem: the goal is to design around them, not to turn them up.
The full table, from Supabase's docs
Verified against Supabase's auth rate limits documentation. Every one of these returns 429 when exceeded.
| What you're doing | Endpoint | Limit |
|---|---|---|
| Sending any auth email | /auth/v1/signup, /auth/v1/recover, /auth/v1/user | 2 per hour on the built-in provider |
| Sending OTPs | /auth/v1/otp | 30 per hour |
| OTP / magic link, per user | /auth/v1/otp | 1 per 60 seconds |
| Signup confirmation, per user | /auth/v1/signup | 1 per 60 seconds |
| Password reset, per user | /auth/v1/recover | 1 per 60 seconds |
| Verifying a token | /auth/v1/verify | 360 per hour |
| Refreshing a session | /auth/v1/token | 1800 per hour |
| MFA challenge / verify | /auth/v1/factors/:id/challenge, /auth/v1/factors/:id/verify | 15 per hour |
| Anonymous sign-ins | /auth/v1/signup | 30 per hour |
Two things to notice. First, the per-user 60-second windows are separate from the hourly project-wide caps — you can be well under 30 OTPs an hour and still get a 429 because this user asked twice in forty seconds. Second, four of these are not configurable: verification requests, token refresh, MFA challenges, and anonymous sign-ins are fixed. The rest can be adjusted in Auth settings.
Why am I getting "email rate limit exceeded" in development?
Because you're on the built-in email service and it allows 2 messages per hour, full stop.
This single number explains most of the confusion around Supabase auth email. Two signups, or one signup plus one password reset, and you are done for the hour — which is roughly ninety seconds of normal testing. The next attempt fails, usually surfacing as Error sending confirmation email rather than as an obvious 429.
The fix is custom SMTP, and it is the fix for production regardless: the built-in service also only delivers to your organization's team members, and carries no delivery SLA. Once you configure your own provider, the 2-per-hour cap is replaced by a rate you set yourself, and you're limited by your provider instead.
The limits that are protecting you
It's worth being precise about which of these are conveniences and which are security controls, because that determines whether raising them is reasonable.
The 60-second per-user windows are enumeration and harassment controls. Without them, /auth/v1/recover is a free email cannon: point it at a list of addresses and Supabase sends password-reset mail to every real account, at your expense, from your domain. The window makes that impractically slow. The endpoint is also designed to respond identically for addresses that don't exist — the same anti-enumeration reasoning behind Supabase's silence on duplicate signups. One caveat: open issue supabase/auth#2398 reports that the per-user cooldown itself undermines this, because only a real account can trip it.
The MFA limit of 15 challenges per hour is a brute-force control. A TOTP code is six digits. Fifteen guesses an hour is the difference between a second factor and a formality. Do not raise this one.
The 360-per-hour verify limit is the same idea for OTP codes. Email OTPs are typically six digits too, and the verify endpoint is where you'd guess them.
The anonymous sign-in limit of 30 per hour caps how fast someone can mint throwaway identities. If you use anonymous auth, that number is your ceiling on automated abuse — worth reading alongside why anonymous sign-ins need their own RLS treatment.
1800 token refreshes per hour is the one you can hit legitimately. It's roughly one every two seconds project-wide, which sounds generous until a client bug puts refreshSession() in a render loop or a polling interval. If you're hitting this, you have a client bug, not a capacity problem.
The workaround that removes the protection
Here's the pattern that shows up when the 60-second window gets in the way of a nice UX. Add your own "Resend" endpoint, have it call the Admin API, done — no window, no 429.
- Warningapp/api/resend-confirmation/route.ts:6
API route without input validation
Validate the parsed body against a schema (e.g. zod safeParse) before use, and return 400 on failure. Consider rate limiting for unauthenticated routes.
The scanner flags the missing input validation, and that's the smaller half of what's wrong. Walk the route:
- It is unauthenticated. Anyone on the internet can POST to it.
- It takes an arbitrary
emailfrom the request body and never checks it. - It runs as
service_role, so it bypasses every limit and policy Supabase would have applied. - It returns
generateLink's data to the caller — and that response contains the action link, which is a working credential for that account. You have built an endpoint that hands an attacker a login link for any address they name.
You have replaced a 60-second delay with an unauthenticated account-takeover primitive. If you build a resend endpoint, it must at minimum: require a valid session or a signed token proving the caller owns the flow, validate the email against a schema before use (see validating API route input), enforce your own per-address cooldown, and never return the generated link to the client — hand it to your mailer server-side and return nothing but { ok: true }.
Working with the limits instead
Disable the client-side button, not the server-side limit. A 60-second countdown on the resend button costs one useState and eliminates the 429 for real users entirely. Almost every 429 in a normal app comes from impatient double-clicking.
Handle the error properly. Supabase returns an AuthApiError with status 429 and a code such as over_email_send_rate_limit. Branch on the status and tell the user how long to wait, rather than surfacing "something went wrong".
const { error } = await supabase.auth.resend({
type: "signup",
email,
});
if (error?.status === 429) {
// Per-user window. Don't retry in a loop — show a countdown.
setCooldown(60);
} else if (error) {
setMessage(error.message);
}
Never retry a 429 automatically. An exponential-backoff wrapper around an auth call turns one user's impatience into a sustained hammering of a limit that exists to stop exactly that.
Configure custom SMTP before you invite anyone. It removes the 2-per-hour wall and the team-members-only restriction in one move.
Quick self-check
-- Are users repeatedly requesting mail? A cluster of these is a UX
-- problem (no cooldown on the button) before it's a limits problem.
select email,
confirmation_sent_at,
recovery_sent_at,
email_confirmed_at
from auth.users
where email_confirmed_at is null
and confirmation_sent_at > now() - interval '24 hours'
order by confirmation_sent_at desc;
Then check Auth Logs for 429s. A steady trickle across many addresses is normal user impatience. A burst of /auth/v1/recover 429s across addresses that don't belong to your users is someone probing you — and the rate limit is the thing that stopped it.
FAQ
Which limits can I actually change? Email sends, OTP sends, and the per-user 60-second windows on signup, OTP and recovery are configurable in Auth settings. Verification requests, token refresh, MFA challenges and anonymous sign-ins are fixed.
What's the difference between "email rate limit exceeded" and the 60-second window? The first is a project-wide hourly cap on messages sent. The second is per-user and per-endpoint: this address already asked within the last minute. You can hit the second while nowhere near the first.
Does using custom SMTP remove the rate limits? It replaces the built-in provider's 2-per-hour cap with a rate you configure. The per-user 60-second windows and the verify, refresh, MFA and anonymous limits still apply.
Why is /auth/v1/token limited to 1800 per hour?
Because a healthy client refreshes on a timer measured in minutes. Hitting 1800 an hour means something is refreshing in a loop — check for a useEffect without a dependency array before you ask for more headroom.
I got a 429 on login. Is someone attacking me? Possibly, and that's the limit doing its job. Check Auth Logs for the source and the addresses being tried. If they're addresses that don't exist in your project, it's a credential-stuffing or enumeration attempt, not your users.
Catch this before it ships — free
GuardLayer scans every push for this and 33 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.