Supabase returns email rate limit exceeded when your project tries to send more auth email than it is allowed to in a given window. On a new project that allowance belongs to the built-in mailer, which exists so you can try sign-up flows on day one and is not meant for real users. The fix is to point Supabase Auth at your own SMTP server and then set the limit yourself. Choose that server for transactional mail only. A sign-in code is mail a user is waiting on, and it should not travel the same path as your newsletter or a promotion.
Why Supabase returns "email rate limit exceeded"
Every Supabase project starts with a built-in email service, so confirmation emails, magic links and password resets work before you configure anything. That service is deliberately capped. Supabase's SMTP guide sets it at 2 messages an hour, and it refuses to deliver to addresses outside your project's team. It is there for trying things out, not for production traffic.
Custom SMTP does not remove the limit. Once you save it, Supabase starts your project at 30 messages an hour, and you change that value on the Rate Limits page. Supabase can change these numbers, so check its rate limits page for the current ones.
The error comes back as JSON from the Auth API. Supabase issue #34136 shows it coming from the password recovery endpoint, auth/v1/recover:
{
"code": "over_email_send_rate_limit",
"message": "email rate limit exceeded"
}
What makes it confusing is that nothing else looks broken. Signups can appear to succeed while the confirmation emails never arrive. The error response is the only thing that tells you why.
Which limit you hit: built-in mailer or your own SMTP
There are four places a limit can sit, and the fix depends on which one stopped you.
| Where the limit sits | When you hit it | What you see | Fix |
|---|---|---|---|
| Supabase's built-in mailer (hosted project, no custom SMTP) | After 2 test sign-ups, resets or magic links in an hour | over_email_send_rate_limit from the Auth API |
Set up custom SMTP. The built-in cap is not meant to be raised for production. |
| Supabase Auth's per-project email rate limit (hosted project, custom SMTP on) | When real traffic or a test script goes past the emails-per-hour value, which starts at 30 | The same over_email_send_rate_limit error |
Raise the value under Authentication → Rate Limits, or find what is sending too much |
| Local development with the Supabase CLI | Repeated resets or sign-ups against your local stack | The same error, from your local Auth server (issue #34136, CLI 2.19.5) | Raise email_sent under [auth.rate_limit] in supabase/config.toml |
| Your SMTP provider | When you pass the provider's plan, daily or ramp-up limit | An SMTP reply code in the provider's logs, not Supabase's JSON code | Check the provider's limits and logs. Let temporary failures retry. |
The first three come from Supabase. The built-in mailer also refuses any address outside your project's team, so real users can get nothing even under the cap. The fourth is the one people miss after switching to custom SMTP. Supabase's limit is fine, but the provider is refusing the mail.
Quick check: confirm the error before you change anything
Before changing settings, confirm which error you actually have.
- Read the response body your client receives from the sign-up, magic link or reset call. If
codeisover_email_send_rate_limit, Supabase stopped the send before it reached any mail server. - Check your project's auth logs for the same code around the time of the failed send. A run of them in a few minutes usually points to a loop or a test script, not to real users.
- If there is no Supabase error and the email still never arrived, the send left Supabase. The problem is delivery, which is a different fix. Our checklist for password reset emails that never arrive walks through it.
In supabase-js the same code is on the error object, so you can branch on it instead of showing a generic failure:
const { error } = await supabase.auth.resetPasswordForEmail(email)
if (error?.code === 'over_email_send_rate_limit') {
showMessage('Too many emails requested. Check your inbox for an earlier one, or try again in a few minutes.')
} else if (error) {
showMessage('Something went wrong. Please try again.')
}
Fix 1: Stop retry loops that burn your quota
On a limit this small, the quota is often spent by your own code. Common sources:
- Double clicks on "Send magic link" or "Reset password". Each click is a send. Disable the button after the first request.
- Effects that run twice. React's StrictMode runs effects twice in development. A reset or OTP request fired from an effect goes out twice.
- Automatic retries. A fetch wrapper or a form library that retries on failure will retry a rate limit error too, which spends more quota and gets the same answer.
- Test scripts and end-to-end suites that create a fresh user on every run against a hosted project.
A short cooldown on the client fixes the first three:
let lastSent = 0
const COOLDOWN_MS = 60_000
async function requestReset(email) {
if (Date.now() - lastSent < COOLDOWN_MS) return
lastSent = Date.now()
submitButton.disabled = true
await supabase.auth.resetPasswordForEmail(email)
setTimeout(() => (submitButton.disabled = false), COOLDOWN_MS)
}
For local work, the CLI does not send real email by default. Local messages land in a local inbox (Mailpit, formerly Inbucket), and supabase status prints its URL. Reading test mail there costs nothing against any hosted limit.
Fix 2: Raise or adjust the auth rate limits in Supabase
Locally, the reporter on issue #34136 fixed the password reset error by adding this to supabase/config.toml:
[auth.rate_limit]
email_sent = 10
Restart the local stack after changing it. Use a number that fits how you test, not a huge one. A sensible limit catches loops in development before they reach production.
On a hosted project, the dashboard has a Rate Limits section under Authentication with an emails-per-hour value. The value is yours to change once custom SMTP is on. With the built-in mailer you are working inside Supabase's testing cap, so the real fix is Fix 3.
Keep a limit in place in production, because it is part of how you stop abuse once an app is live. Someone scripting sign-ups against your form sends real mail to real or fake addresses under your domain. An emails-per-hour ceiling sized for your normal peak, plus a CAPTCHA on the sign-up form, limits how much of that mail goes out before you notice.
Fix 3: Replace the built-in mailer with custom SMTP
Custom SMTP is the fix Supabase points people to for anything beyond testing, and it lifts the built-in cap and the team-only rule.
The steps are the same with any provider:
- Add and verify your sending domain with the provider. That usually means DNS records for SPF and DKIM, and a DMARC policy on the domain.
- Create SMTP credentials in the provider's dashboard.
- Open the SMTP settings in Supabase. They are in your project's Authentication settings. Dashboards move, so look for the Custom SMTP toggle.
- Turn on Custom SMTP and fill in the fields: host, port, username, password, sender email and sender name. The sender address must be on the domain you verified.
- Save, then raise the emails-per-hour value under Authentication → Rate Limits to fit your traffic. It starts at 30.
If you cannot set up DNS on a domain yet, Supabase also offers a Send Email Hook, which hands each auth email to your own code or an HTTP endpoint to send.
Verify your sending domain and send a test email
A custom SMTP setup that sends from an unverified domain swaps one failure for another. The rate limit error goes away, and the mail lands in spam or bounces. Before sending to real users:
- Confirm SPF, DKIM and DMARC resolve for the sending domain. Our guide to SPF, DKIM and DMARC for transactional email covers what each record does and what a passing setup looks like.
- Trigger a password reset to an address you control at Gmail and another at Outlook, and check that both arrive in the inbox.
- Open the message headers and confirm SPF and DKIM pass for your domain, not for the provider's.
- Send a second reset right away to confirm your new rate limit lets it through.
If the connection fails rather than the delivery, the port is the usual suspect. Some hosts block 587 or 465 outbound. The SMTP ports guide explains which port expects which kind of TLS.
When the error persists with custom SMTP
Once custom SMTP is on, a failed send can come from Supabase or from the provider, and they look different.
From Supabase: you still get over_email_send_rate_limit in the Auth API response. The mail never left Supabase, and your provider's logs show nothing for that attempt. Raise the emails-per-hour value or find the loop that is spending it.
From the provider: there is no Supabase rate limit code. The provider's sending logs show the attempt with an SMTP reply. A 4xx reply is temporary, often a plan, daily or new-account ramp limit, and the right response is to wait and retry. A 5xx reply is permanent until you change something, such as an unverified From domain or a suppressed recipient. Retrying a 5xx does nothing.
Bursts make this worse. If your app sends a batch of transactional mail at once, such as 500 order confirmations after an import, it can pass a provider's per-day or per-minute limit even when your monthly total is small. Queue the batch, send at a steady rate, and treat 4xx replies as "later", not "failed".
Keep sign-in codes and resets landing as volume grows
Once the rate limit is fixed, the job that is left is keeping these emails in the inbox. A user waiting on a sign-in code will wait a minute. They will not wait an hour, and they will not check spam.
That is why the SMTP you plug into Supabase should not be your marketing tool's relay. Newsletters and promotions draw complaints and unsubscribes. Password resets do not. When both go out from the same service, and often the same IPs, mailbox providers judge them together, and a bad campaign can push the next morning's sign-in codes into spam. Use a sender built for transactional mail, keep promotions somewhere else, and give each its own subdomain if you can.
As volume grows, watch three numbers in your provider's dashboard: bounces, complaints and deferrals from Gmail, Outlook and Yahoo. Rising deferrals usually show up before inbox placement drops, which gives you time to fix the cause.
Set up Supabase Auth with Groundmail SMTP
Groundmail is a transactional email API and SMTP relay that sends from its own mail servers and IP space. Password resets, receipts and alerts go out on the transactional pool, product and newsletter mail is kept separate on its own servers and IPs, and cold outbound and bought lists are banned. These are the values for Supabase's Custom SMTP form:
Host: smtp.groundmail.dev
Port: 587 (STARTTLS), or 2525 if your host blocks 587
Username: groundmail
Password: gmsmtp_… # an SMTP credential from the dashboard
Sender: noreply@yourdomain.com # must be on a domain you verified
Name: Your app
You create the SMTP credential in the dashboard or with POST /smtp-credentials. It can send mail and nothing else, and you can revoke it without touching DNS. Our Supabase guide covers the full setup, including the Send Email Hook.
Our limits are easy to tell apart. A daily ramp-up limit on a new account returns a temporary 451, so the client retries later. Anything that needs your action, such as a From address on an unverified domain, returns a permanent 554 with the reason. Suppressed recipients are refused. Hard bounces and complaints go on your suppression list automatically, and delivered, bounced and complained events arrive as signed webhooks. Sending to people outside your own team needs a card on file. The SMTP relay page has the details.
On cost, the free plan includes 3,000 emails a month, up to 100 a day, across 3 domains. That is enough for a beta, but plan for the daily cap if a launch could bring more than 100 sign-ups in a day. Pay as you go is $15 a month with 20,000 emails included. The pricing page has the full breakdown.
To try it with your Supabase project, create your free account, verify your domain, paste these values into Supabase's Custom SMTP form, and send yourself a password reset.