← All posts
·7 min read·GuardLayer

Fix: Error sending confirmation email (Supabase)

SupabaseAuthSMTPEmailSecurity

Error sending confirmation email is a 500 from Supabase Auth meaning the message never left the mail provider — the signup itself usually succeeded. The real reason is in Auth Logs, not the API response, and it's almost always custom SMTP misconfiguration, a broken email template, or the built-in provider's 2-emails-per-hour limit. Fix the mail path; do not fix it by disabling email confirmation.

The error text is deliberately unhelpful for the same reason Database error saving new user is: Auth caught an exception on a path reachable by an unauthenticated caller and refused to leak its internals. Everything you need is one layer down.

Where is the actual error?

Dashboard → Logs → Auth Logs, filtered to the minute you reproduced it. The full message is appended to the generic one, and it is specific enough to diagnose on sight. Three real examples, all from public Supabase threads:

Error sending confirmation email: dial tcp: address http://smtp.gmail.com:587: too many colons in addresssupabase/supabase#10294. The host field contained a protocol prefix. It wants smtp.gmail.com, not http://smtp.gmail.com.

Error sending confirmation email: gomail: could not send email 1: 450 Unexpected token j in JSON at position 59discussion #28554. The email template broke the provider's JSON payload; the fix was quoting the {{ }} template variables the way the provider's docs show.

Error sending confirmation email: dial tcp <ip>:25: i/o timeout — the classic. Port 25 is blocked on essentially every cloud network. Use 587 (STARTTLS) or 465 (implicit TLS); Supabase's own SMTP configuration example uses "smtp_port": 587.

Discussion #36181, titled exactly Error sending confirmation email, gets the same first answer from a Supabase collaborator: check the Auth logs, because this is almost always the SMTP provider responding with an error.

The cause that isn't your fault

If you never configured custom SMTP, you're on the built-in service, and it has two hard walls that Supabase documents plainly.

The first is volume. Per Supabase's SMTP docs: "Currently this value is set to 2 messages per hour." Two. Testing a signup flow four times in ten minutes exhausts it, and the third attempt fails with this error. See Supabase auth rate limits and what to do about them for the full table.

The second is the recipient. The built-in service only delivers to addresses belonging to your organization's team members. Everyone else fails with Email address not authorized. So the flow works perfectly while you test with your own address, and breaks the moment a real user signs up — which is exactly when you notice.

Neither is a bug. The built-in mailer is explicitly best-effort with no delivery SLA, intended for exploring features and testing templates. Production needs custom SMTP. That is the fix, not a workaround.

The dangerous fix

Search this error and you will find the fastest possible answer: turn off Confirm email in Authentication → Providers. Signups start working immediately. The error is gone.

You have also just shipped an app where anyone can create an account on any email address they do not control.

That matters more than it sounds, because email is load-bearing in most Supabase apps in ways people forget:

  • Domain-based access. Any policy or server check shaped like auth.jwt() ->> 'email' like '%@acme.com' now grants an attacker access to Acme's tenant, because nothing proved they can read mail at acme.com.
  • Account pre-registration. An attacker signs up as cfo@yourcustomer.com before the real person does. When the real CFO arrives, the address is taken — and in the unconfirmed-signup case reported in discussion #29327, signing up again on the same unconfirmed address does not overwrite the first password. The attacker's password survives.
  • Invitation and recovery flows. Anything that treats "this user has this email" as established fact is now trusting an unverified claim.

If you genuinely want passwordless-style onboarding without confirmation, that's a design decision — make it deliberately, write down that email is untrusted input, and never key authorization off it. Turning the toggle off at 2am to unblock a demo is not that decision.

Don't hardcode your way out either

The other common escape hatch is to stop using Supabase's mailer and send the confirmation yourself from an Edge Function. Reasonable. The part that goes wrong is where the SMTP credential ends up:

guardlayer scan · supabase/functions/send-confirmation/index.tsLive engine output
Check failed
67/100 · C
  • Criticalsupabase/functions/send-confirmation/index.ts:4

    Credential assigned to a string literal

    Replace the literal with process.env.<NAME>, keep the real value in an untracked .env, and rotate the exposed value.
  • Warningsupabase/functions/send-confirmation/index.ts:1

    Edge function without auth validation

    Read the Authorization header and verify the JWT (supabase.auth.getUser(token)) before doing any work, or set verify_jwt = true in the function config.

Two findings, and both matter. The function has no auth check, so anyone who learns its URL can make it send mail — you've built an open relay wearing your domain's reputation. And the SMTP password is a literal in the source.

An SMTP password is a send-as-you credential. Anyone who lifts it from your repo can send mail that passes SPF and DKIM for your domain — which is a phishing kit aimed at your own users, and a fast route to your sending reputation being burned. Treat it exactly like any other server secret: read it from Deno.env.get("SMTP_PASSWORD"), set it with supabase secrets set, never write it as a literal. The same discipline applies across the app; see keeping secrets out of a Next.js app for the full pattern.

How do I fix "Error sending confirmation email"?

Read the appended message in Auth Logs and match it to one of these, in this order:

  1. too many colons or no such host → the SMTP host field is wrong. Bare hostname, no protocol, no port appended.
  2. i/o timeout → you're on port 25. Change to 587.
  3. 535 or an authentication failure → the SMTP username and the sender address got swapped. Most providers want an API-key username (often literally apikey), not your email address.
  4. 450 or a JSON parse error → your email template. Quote the template variables per your provider's docs, and test the template in the provider's own console before blaming Supabase.
  5. Email address not authorized → built-in provider, non-team recipient. Configure custom SMTP.
  6. Nothing in the logs at all → you hit a rate limit before the send was attempted. Wait an hour, or look at the rate limit table.

Whatever you do, re-enable email confirmation before you ship.

Quick self-check

-- Who actually confirmed their address? Run this after any period
-- where confirmation was switched off "temporarily".
select
  count(*) filter (where email_confirmed_at is not null) as confirmed,
  count(*) filter (where email_confirmed_at is null)     as unconfirmed,
  count(*)                                               as total
from auth.users;

-- The specific risk: unconfirmed accounts that are nonetheless active.
select email, created_at, last_sign_in_at
from auth.users
where email_confirmed_at is null
  and last_sign_in_at is not null
order by created_at desc;

That second query is the one to care about. An unconfirmed account that has never signed in is an abandoned signup. An unconfirmed account that has signed in is a live session on an address nobody proved they own.

FAQ

Does this error mean the user wasn't created? Usually not. Auth typically creates the row and then fails on the send, so the account exists in auth.users with email_confirmed_at null. Check before you tell the user to sign up again.

Why did it work yesterday and not today? Two usual answers: you crossed the built-in provider's 2-per-hour limit, or you edited an email template and broke the payload your provider expects.

Is Error sending recovery email the same problem? Yes. Same mail path, different template — password reset instead of signup. Error sending magic link too. Diagnose all three the same way.

Can I raise the built-in email limit? The email rate limit is configurable in Auth settings, but the built-in provider still won't deliver to non-team addresses and still has no delivery SLA. Raising the number doesn't turn it into a production mailer.

Is turning off email confirmation ever acceptable? In local development, yes. In production, only if you have consciously decided that an email address is an unverified label and nothing in your app — no RLS policy, no server check, no invite flow — treats it as proof of identity.

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.

Keep reading

A Solvion project — see also Reglog — EU AI Act changelog, Proceedly, Solenna and Solvion Solutions.