Supabase "email rate limit exceeded": cause, fix, all limits
A 429 Too Many Requests from Supabase Auth means you crossed one of eleven 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
Checked against Supabase's auth rate limits documentation on 22 October 2026. Every one of these returns 429 Too Many Requests when exceeded.
| What you're doing | Endpoint | Counted per | Default limit |
|---|---|---|---|
| Sending any auth email | every Auth endpoint that sends email | Project | 2 per hour on the built-in provider |
| Sending any auth SMS | every Auth endpoint that sends SMS | Project | 30 per hour |
| Signing up or signing in | /auth/v1/signup, /auth/v1/recover, /auth/v1/resend, /auth/v1/magiclink, /auth/v1/otp, /auth/v1/user | IP address | 30 per 5 minutes, bursts up to 30 (anonymous sign-ins not included) |
| OTP / magic link, per user | /auth/v1/otp | User | 1 per 60 seconds |
| Signup confirmation, per user | /auth/v1/signup | User | 1 per 60 seconds |
| Password reset, per user | /auth/v1/recover | User | 1 per 60 seconds |
| Verifying a token | /auth/v1/verify | IP address | 30 per 5 minutes, bursts up to 30 |
| Token endpoint: password, refresh token, ID token and PKCE grants | /auth/v1/token | IP address | 150 per 5 minutes, bursts up to 30 |
| MFA challenge / verify | /auth/v1/factors/:id/challenge, /auth/v1/factors/:id/verify | IP address | 15 per minute, bursts up to 30 |
| Anonymous sign-ins | /auth/v1/signup with no email or phone | IP address | 30 per hour |
| Web3 sign-ups and sign-ins | /auth/v1/token | IP address | 30 per 5 minutes, bursts up to 30 |
Three things to notice. First, the limits are counted against three different things: your whole project (email and SMS sends), the caller's IP address (sign-ins, verification, the token endpoint, MFA and anonymous sign-ins), and the individual user (the 60-second windows). Each is its own bucket — you can be nowhere near the project's email cap, on an IP well under its sign-in limit, and still get a 429 because this user asked twice in forty seconds. Second, the IP-based limits are token buckets: a full bucket lets a short burst through (up to 30 requests, or up to the hourly limit for anonymous sign-ins), then refills at the listed rate, and requests are rejected until it does. Third, only one of these is fixed: the MFA limit. Everything else, anonymous sign-ins included, is customizable under Authentication > Rate Limits in the dashboard or through the Management API, and the email cap is lifted by configuring custom SMTP or a Send Email hook.
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 is a brute-force control, and the one limit you cannot change. It allows 15 challenge or verify requests per minute per IP address, with bursts up to 30. A TOTP code is six digits, so throttling guesses is what keeps a second factor from becoming a formality. Because it is counted per IP address, it slows each source down separately rather than capping the guesses against one account.
The verify limit (30 requests per 5 minutes per IP) 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 per IP address caps how fast one source can mint throwaway identities. It is not a project-wide ceiling: requests spread across many addresses each get their own 30, so treat it as a speed bump. Supabase strongly recommends invisible CAPTCHA or Cloudflare Turnstile for anonymous sign-ins, and your RLS policies still have to hold — see why anonymous sign-ins need their own RLS treatment. The number itself can be changed under Authentication > Rate Limits, or as auth.rate_limit.anonymous_users in supabase/config.toml for local development.
The token endpoint's 150 requests per 5 minutes per IP is the one you can hit legitimately. It covers password sign-ins and session refreshes alike, which sounds generous until a client bug puts refreshSession() in a render loop or a polling interval. If one client is hitting it, 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.
import { createClient } from "@supabase/supabase-js";
// Users kept hitting the 60s resend window, so we added our own
// "Resend email" button that calls this route.
export async function POST(request: Request) {
const body = await request.json();
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
const result = await supabase.auth.admin.generateLink({
type: "signup",
email: body.email,
});
return Response.json({ sent: true, link: result.data });
}
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?
All of them except MFA. The customizable ones are set under Authentication > Rate Limits in the dashboard or through the Management API (the rate_limit_* keys of the project's auth config). Locally, the [auth.rate_limit] section of supabase/config.toml holds the email, SMS, anonymous, sign-in, verification and token-refresh limits. The built-in provider's 2 emails per hour is lifted by custom SMTP or a Send Email hook. The MFA challenge and verify limit is 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 IP-based limits on sign-ins, verification, the token endpoint, MFA and anonymous sign-ins still apply.
Why is /auth/v1/token limited to 150 requests per 5 minutes?
Because a healthy client refreshes on a timer measured in minutes. Hitting that limit from one client 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.
See which of your Supabase tables anyone can read, free
Connect your Supabase project read-only. GuardLayer checks RLS, policies, grants and functions as they are right now, and gives you the SQL to fix each problem. It never reads your rows and can't change anything.
Check my Supabase projectPrefer to check code instead? Scan your repo, no signup.