Auth rate limits and abuse protection
The Auth rate limits on SupaHost (a fixed email sending limit and GoTrue's defaults for the rest), the 429 errors users see, and app-side ways to stop fake sign-ups without a CAPTCHA setting.
Auth limits how often certain actions can happen, so a script can't flood your users' inboxes, guess passwords or fill your database with fake accounts. On SupaHost the limits aren't configurable. This page lists what applies, what users see when they hit a limit, and what you can add in your app.
The limits
| What's limited | On SupaHost |
|---|---|
| Emails sent by Auth (confirmations, magic links, resets, invites, email changes) | Fixed at 30 per rate-limit window (GOTRUE_RATE_LIMIT_EMAIL_SENT=30) |
| Repeat emails to the same address | GoTrue's default minimum gap between emails to one user |
| Token refreshes, OTP and link verifications, anonymous sign-ins, other sign-in endpoints | GoTrue's default limits |
| Phone and SMS | Not applicable: phone sign-in is turned off |
None of these can be changed from the dashboard. The email limit applies to the whole project, not per user, so a burst of sign-ups can use it up for everyone.
How clients are counted
Limits that apply per client use the client's IP address, which Auth reads from the X-Forwarded-For header that SupaHost's gateway sets. That has one practical consequence: if your server calls Auth on behalf of users (for example, a server action that calls signInWithPassword), every one of those calls comes from your server's IP address and they share one allowance. Prefer calling sign-in methods from the browser or app when you expect heavy traffic, or spread server-side calls over time.
What users see
When a limit is hit, Auth returns HTTP 429 with an error code:
| Code | Meaning | What to show |
|---|---|---|
over_email_send_rate_limit |
Too many emails were sent | "We can't send another email right now. Try again in a few minutes." |
over_request_rate_limit |
Too many requests from this client | "Too many attempts. Wait a moment and try again." |
Repeat requests for the same address inside the per-user gap also fail, with a message telling the user how many seconds to wait. Show that message as it is.
Handle these in your UI:
const { error } = await supabase.auth.signInWithOtp({ email })
if (error?.status === 429) {
showNotice('Please wait a minute before asking for another email.')
disableSendButton(60_000)
} else if (error) {
showNotice(error.message)
}
over_request_rate_limit during normal use usually means a bug, such as a React effect that calls getUser() or refreshSession() on every render. Check the network tab for repeated Auth calls.
Planning around the email limit
- Bulk invites. Inviting a whole team from a script will stop after 30 emails. Space invites out, or create the links with
auth.admin.generateLinkand send them through your own email provider. See Auth emails and templates. - Launch days. A spike of sign-ups can exhaust the limit while confirmation is on. Offer OAuth sign-in alongside email so not every new user needs an email.
- Testing. Automated tests that sign up real addresses use the same allowance as your users. Run them against a separate project or branch, or create users with
auth.admin.createUser({ email_confirm: true }), which sends no email.
Protecting sign-ups without a CAPTCHA setting
Supabase Auth can check a CAPTCHA token on sign-up and sign-in, but on SupaHost there's no setting to turn that on. Put the protection in your app instead. In rough order of effort:
1. Keep confirmation on
Leave Skip email confirmation off in Dashboard → your project → Auth tab → Sign-in & security. Bots that sign up with random addresses never confirm, so they never get a session.
2. Block bad domains in the database
A trigger on auth.users can reject sign-ups before the row is written. For example, refusing disposable email domains you list:
create table public.blocked_email_domains (domain text primary key);
alter table public.blocked_email_domains enable row level security;
create function public.reject_blocked_domains()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
if exists (
select 1 from public.blocked_email_domains
where domain = lower(split_part(new.email, '@', 2))
) then
raise exception 'sign-ups from this email domain are not allowed';
end if;
return new;
end;
$$;
create trigger check_email_domain
before insert on auth.users
for each row execute function public.reject_blocked_domains();
Users from a blocked domain get a generic "Database error saving new user" response, which is fine for this purpose. See that KB article if you see it unexpectedly.
3. Put a bot check in front of sign-up
For real CAPTCHA protection, turn on Disable new sign-ups so the public signUp endpoint refuses new accounts, and create accounts through an Edge Function that checks a CAPTCHA token first. Invites still work while sign-ups are disabled. With Cloudflare Turnstile:
// functions/request-account/index.ts
import { createClient } from 'npm:@supabase/supabase-js@2'
const admin = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
{ auth: { autoRefreshToken: false, persistSession: false } }
)
Deno.serve(async (req) => {
const { email, turnstileToken } = await req.json()
const form = new FormData()
form.append('secret', Deno.env.get('TURNSTILE_SECRET')!)
form.append('response', turnstileToken)
const check = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: form,
}).then((r) => r.json())
if (!check.success) return Response.json({ error: 'bot_check_failed' }, { status: 400 })
const { error } = await admin.auth.admin.inviteUserByEmail(email, {
redirectTo: 'https://bookings.example.com/welcome',
})
if (error) return Response.json({ error: error.message }, { status: 400 })
return Response.json({ ok: true })
})
Add TURNSTILE_SECRET in the Functions tab's Secrets card, and add CORS headers if the browser calls the function directly (see CORS for browser calls). The user receives the Invite email and sets a password on your welcome page. Note that invites count toward the email limit like any other Auth email.
Turning off public sign-ups also blocks new users from OAuth and magic links, so this suits apps where email sign-up is the main path.
4. Limit what new accounts can do
Use RLS so a fresh or anonymous account can't do much damage: cap inserts per user with a check in a policy or trigger, and require a confirmed email or aal2 for sensitive tables. See Roles, custom claims and RBAC and Anonymous sign-ins.
5. Watch for spikes
A quick query in the SQL editor shows unusual sign-up patterns:
select split_part(email, '@', 2) as domain, count(*)
from auth.users
where created_at > now() - interval '1 day'
group by 1
order by 2 desc
limit 20;