# 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.

Source: https://supahost.app/docs/auth/rate-limits · Updated 2026-11-11

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:

```ts
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.generateLink` and send them through your own email provider. See [Auth emails and templates](/docs/auth/email-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:

```sql
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](/kb/database-error-saving-new-user) 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:

```ts
// 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](/docs/functions/cors)). 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](/docs/auth/custom-claims-rbac) and [Anonymous sign-ins](/docs/auth/anonymous).

### 5. Watch for spikes

A quick query in the SQL editor shows unusual sign-up patterns:

```sql
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;
```

## Related

- [Not receiving Auth emails](/kb/not-receiving-auth-emails)
- [Security of anonymous sign-ins](/kb/anonymous-sign-in-security)
- [Production checklist](/docs/security/production-checklist)
