HomeGuidesSupabase signup user enumeration, tested

Guide

Supabase signup user enumeration, tested

supabase.auth.signUp returns a fake user for an existing email with Confirm email on and user_already_exists with it off. How apps leak it, and the fixes.

14 min read

Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.

You asked Cursor, Lovable, Bolt or Claude Code for a signup form on Supabase. It works: supabase.auth.signUp({ email, password }), a confirmation email arrives, the user clicks it. What the happy path does not show is what that same call returns when the email already belongs to someone, how a bot turns that into a list of your customers, and what changes the moment someone turns off the Confirm email setting.

User enumeration is when an attacker can ask your app "is this email registered?" and get a reliable yes or no. It is the first step in credential stuffing and phishing: confirm the address exists, then target it. Supabase tries to hide this on signup, but the hiding is thin, and most AI-written forms undo it. This guide walks through what signUp actually returns, how apps leak it anyway, what resetPasswordForEmail and signInWithOtp do, and the fixes, with everything we could test run against the real Supabase Auth server.

Tested on October 30, 2026. We built the open source Supabase Auth server from source (commit c3348e8; the newest master, fcc491d, only adds a SECURITY.md file) and ran it with its migrations on PostgreSQL 17.10, PostgREST 16.5 for the Data API, a local mail catcher, and @supabase/supabase-js 2.118.0 on Node 24.22.1. We tested with Confirm email on (the default) and off (autoconfirm), and with anonymous sign-ins on. We did not test on a hosted Supabase project, so hosted defaults and dashboard behavior come from Supabase's docs, not from us. VibeLocked is independent and not affiliated with Supabase.

The short version

  1. With Confirm email on, an existing email returns a fake success, not an error. The giveaway is identities: [] and no email sent.
  2. With Confirm email off, an existing email returns 422 user_already_exists. That is a plain yes or no.
  3. The UI usually leaks it anyway. Checking data.user.identities.length or showing the raw error turns the form into an oracle.
  4. A public profiles or users table is the bigger leak. If anyone can read emails, enumeration is one query.
  5. resetPasswordForEmail and signInWithOtp leak too. A repeat reset, or OTP with shouldCreateUser: false, tells real from unknown.
  6. Timing differs. An existing email short circuits fast, a new one runs a password hash and sends mail, so it is slower.
  7. Confirm email off is a security setting, not a convenience toggle. It hands anyone a JWT for any email, which breaks RLS that trusts the email claim.

1. What signUp returns for an existing email, Confirm email on

Supabase's signUp reference warns: "Be aware that if a user account exists in the system you may get back an error message that attempts to hide this information from the user." The reference also notes that with Confirm email on, signing up with an existing confirmed email returns an obfuscated, fake user object, and with it off it returns "User already registered."

We created a confirmed user, then called signUp three ways against the same local server.

signUp input (Confirm email on) error data.session data.user.identities email sent What it tells an attacker
A brand new email null null one entry 1 Normal signup
An existing confirmed email null null [] (empty) 0 This email is registered
The same email in upper case null null [] (empty) 0 Matching is case insensitive

For the existing email, the "user" that came back had a fresh random id (not the real user's id), role set to an empty string, email_confirmed_at: null, and user_metadata equal to whatever we passed in the signup call. The real account was never touched: it still signed in with its original password, and the password we tried in the fake signup did not work. This matches the Auth source: when the email belongs to a confirmed user, Signup returns sanitizeUser, which assigns a new UUID and sets Identities to an empty slice (signup.go).

So on paper the response "looks the same." In practice two things give it away: identities is empty, and no confirmation email is sent. An attacker who can observe either one has their answer.

2. What changes with Confirm email off

Turn off Confirm email (the dashboard setting under Authentication, Sign In / Providers, Email; GOTRUE_MAILER_AUTOCONFIRM in self hosted Auth) and the obfuscation disappears.

signUp input (Confirm email off) Result
A brand new email Success with a full session, email_confirmed_at set, user_metadata.email_verified: true
An existing confirmed email 422 user_already_exists, "User already registered"

That 422 is an unambiguous "this email exists." In the source, the autoconfirm branch returns ErrorCodeUserAlreadyExists instead of the sanitized fake user. People turn Confirm email off to make signup feel instant, or because their SMTP is not set up. They usually do not realize it also makes enumeration trivial, and it does something worse, covered in section 7.

3. How apps leak it even with Confirm email on

Supabase's obfuscation only helps if your app does not unwrap it. The common AI-written patterns that undo it:

  • Checking identities.length. Supabase's docs suggest this exact check to detect an existing user, so AI tools copy it into the client and show "that email is already registered." That is an enumeration oracle with a friendly label.
  • A public profiles or users table. If RLS lets anon read rows, or the signup form looks the email up first to say "account found," anyone can list who is registered. This is the same mistake covered in Supabase password reset security.
  • Different screens or copy. Showing an error toast for the "taken" case and a success screen for the new case is a visible yes or no, even if the API response was obfuscated.
  • Watching whether an email arrives. A signup that sends no confirmation email is an existing account. An attacker who controls a catch all inbox or times the response can tell.
  • Timing. In our test the existing email came back in a median of about 21 ms (it short circuits), while a new email took about 83 ms because Auth hashes the password, inserts a row, and sends mail. Our numbers came from a mail catcher on the same machine, so read the direction, not the exact figures: the existing case is the fast one.

The fix: treat every signup the same

Do not read identities, do not show the raw error, and send everyone to the same "check your email" screen. A Server Action keeps the Supabase response out of the browser entirely.

// app/signup/actions.ts
'use server'
import { createClient } from '@/utils/supabase/server'

const SAME_MESSAGE =
  'Check your inbox. If that email can be used, we sent a link to confirm your account.'

export async function signUpAction(_prev: unknown, formData: FormData) {
  const email = String(formData.get('email') ?? '').trim()
  const password = String(formData.get('password') ?? '')
  const captchaToken = String(formData.get('captchaToken') ?? '')

  const supabase = await createClient()
  const { data, error } = await supabase.auth.signUp({
    email,
    password,
    options: { captchaToken },
  })

  // Never branch the UI on this. An existing email returns either a fake
  // user with identities: [] (Confirm email on) or user_already_exists
  // (Confirm email off). Both must look identical to the visitor.
  if (error && error.code !== 'user_already_exists') {
    console.error('signup failed', error.code)
  }

  // Same screen whether the account is new, already exists, or errored.
  return { message: SAME_MESSAGE }
}

Pair it with CAPTCHA and the signup rate limit (next section), because a consistent message does not stop a bot that can still watch timing or whether mail arrives, it only removes the easy tell.

4. resetPasswordForEmail, signInWithOtp, and sign in

Signup is not the only endpoint that can leak. We tested the others against the same server.

Call Real / confirmed email Unknown email Leak?
resetPasswordForEmail, first call empty success, 1 email empty success, no email No, first call is safe
resetPasswordForEmail, repeat within 60 s 429 over_email_send_rate_limit empty success Yes, the second call sorts the list
signInWithOtp (magic link), default sends a link creates the user and sends a link No, but it silently creates accounts
signInWithOtp with shouldCreateUser: false proceeds 422 otp_disabled, "Signups not allowed for otp" Yes
signInWithPassword wrong password: 400 invalid_credentials 400 invalid_credentials No, both say "Invalid login credentials"
resend type signup 200 {} 200 {} No

Two takeaways. Password sign in and resend are the well behaved ones: the same answer for both. The reset repeat and the otp_disabled response are real oracles. The reset repeat is covered in depth, with the fix (treat over_email_send_rate_limit as success and pad the response time), in Supabase password reset security. For signInWithOtp, if you set shouldCreateUser: false to stop silent account creation, you reintroduce enumeration, so put CAPTCHA on that form and show the same "check your email" message regardless.

5. Rate limits and CAPTCHA

Enumeration at scale needs volume, so the limits matter. Supabase's rate limits page lists a per IP limit on the signup, recover, resend, magic link and OTP endpoints that defaults to 30 requests per 5 minutes, and a per user window (60 seconds by default) before the same address can trigger another email. In our self hosted test the signup endpoint shares the OTP rate limit, and the 60 second window is what produced the 429 over_email_send_rate_limit on the reset repeat and on a second signup for the same unconfirmed email.

Those are the brakes an attacker hits. Make them count:

  • Turn on CAPTCHA protection (hCaptcha or Cloudflare Turnstile) for the signup, reset and OTP forms. In the Auth source, the signup, recover, resend, magiclink and otp routes all run the CAPTCHA check; sign in with password also runs it. See Supabase Auth security settings for where these live.
  • Keep the default per IP and per user rate limits, or tighten them. Do not raise them to make testing easier and forget to put them back.
  • Remember CAPTCHA and IP limits are evaded with proxies and solver services, so they slow enumeration rather than stop it. The consistent message and no public email table are what remove the signal.

6. Confirm email off breaks RLS that trusts the email

This is the part people miss. With Confirm email off, signUp returns a session immediately and the user's JWT carries an email claim and user_metadata.email_verified: true, for an address the user never proved they control.

We built a typical team invites table whose RLS matches the signed in user's email:

-- The tempting but unsafe policy
create policy "invitee reads by jwt email" on public.invites
  for select to authenticated
  using (invited_email = (select auth.jwt() ->> 'email'));

With Confirm email off, an attacker signed up as victim@example.test (an address they do not own), got a session whose JWT email was victim@example.test, and read the invite row, including its secret note, that was meant only for the real victim. Supabase's RLS docs warn that auth.jwt() is "not guaranteed to be up to date" and that user_metadata "can be modified by authenticated users," so neither is a trustworthy identity. The email claim is only as trustworthy as your Confirm email setting, and even with it on, an old token keeps its old email after a change (Supabase email change security).

The fix: match on the user id, or read the confirmed email server side

Match rows to auth.uid(), which is the user's id and cannot be spoofed:

-- Store the invited user's id once they accept, and match on it.
create policy "invitee reads own invites" on public.invites
  for select to authenticated
  using (user_id = (select auth.uid()));

If you must match by email (for invites sent before the user exists), read the confirmed email from auth.users in a security definer function, so a pending or spoofed claim cannot be used:

create or replace function private.confirmed_email()
returns text
language sql
security definer
set search_path = ''
stable
as $$
  select email
  from auth.users
  where id = (select auth.uid())
    and email_confirmed_at is not null
$$;

revoke all on function private.confirmed_email() from public, anon, authenticated;
grant execute on function private.confirmed_email() to authenticated;

create policy "invitee reads by confirmed email" on public.invites
  for select to authenticated
  using (invited_email = private.confirmed_email());

And keep Confirm email on. See security definer functions for how to scope these safely, and the Supabase RLS checklist for the rest.

What we tested and what we did not

Tested (scripts and outputs kept with our notes): signUp for a new email, an existing confirmed email and the upper case variant with Confirm email on; the fake user object fields and the empty identities array; that the real account's password was unaffected; the raw HTTP responses; a repeat signup for an unconfirmed email inside and outside the 60 second window; signUp with Confirm email off for new and existing emails (user_already_exists) and the immediate session; signup timing for existing versus new (20 samples each); resetPasswordForEmail first and repeat for confirmed and unknown; signInWithOtp with the default and with shouldCreateUser: false (otp_disabled); resend type signup; signInWithPassword for an unknown email and a wrong password; the squatting case where an unconfirmed signup is reused when the real owner later signs up; and the RLS bypass where a Confirm email off account signed up as another person's email and read their row.

Not tested: a hosted Supabase project, the dashboard, Supabase's built in email service and its hosted defaults, real email clients or scanners, CAPTCHA provider behavior, phone and SMS signups, SSO and OAuth-only users, and production network timing.

Error codes you will see

Code What it means on signup or a related call
user_already_exists Confirm email off: the email already has an account
over_email_send_rate_limit The same user asked for another email inside the window (60 s by default)
over_request_rate_limit Per IP limit on the endpoint was hit
otp_disabled signInWithOtp with shouldCreateUser: false and an unknown email
invalid_credentials Sign in failed. Same message for unknown email and wrong password
captcha_failed CAPTCHA is on and the token was missing or invalid

The full list is in Supabase's Auth error codes.

Checklist

  • Confirm email is on
  • The signup form never reads data.user.identities or shows the raw error
  • Signup shows the same "check your email" result for new, existing and errored emails
  • No public profiles or users table, and no email-lookup endpoint, that anon can read
  • CAPTCHA (hCaptcha or Turnstile) is on for signup, reset and OTP forms
  • The signup and OTP rate limits are at or below the defaults
  • Password reset treats over_email_send_rate_limit as success and pads its timing
  • signInWithOtp forms that set shouldCreateUser: false show a consistent message
  • No RLS policy matches on auth.jwt() ->> 'email', auth.email() or user_metadata; use auth.uid() or a confirmed-email helper

Copy-paste prompt for your AI coding tool

Audit the signup and account enumeration surface in this Supabase app. Do not print or ask for any keys. Find every call to supabase.auth.signUp, signInWithOtp, resetPasswordForEmail and resend, and every RLS policy. Flag: any UI that reads data.user.identities or data.user.identities.length to tell the user an email is taken; any signup, reset or OTP form that shows error.message or branches its screen on whether the email exists; any public profiles or users table readable by anon, or an endpoint that looks an email up to say "account found"; Confirm email turned off (GOTRUE_MAILER_AUTOCONFIRM true or email confirmations disabled in config); missing CAPTCHA on signup, reset and OTP forms; signInWithOtp with shouldCreateUser false that leaks otp_disabled without a consistent message; and RLS policies that compare against auth.jwt() ->> 'email', auth.email() or user_metadata instead of auth.uid(). Propose: a Server Action signup that returns the same message for new, existing and errored emails and never exposes identities; CAPTCHA wired into the signup, reset and OTP forms; and RLS rewritten to match on auth.uid() or a security definer helper that returns the confirmed email from auth.users. Show the diff before applying it.

More prompts: security review prompt for AI coding tools.

Check what your app exposes with LeakCheck

LeakCheck scans a live app URL or a public GitHub repo, read only. It looks for exposed keys such as a Supabase service role key in your bundle, which would let anyone list every user through the admin API and skip every check in this guide. It does not sign in, submit your signup form or read your Auth settings, so the checks above are still yours to do.

Sources

Questions

What does Supabase signUp return for an email that already has an account?

With Confirm email on (the default), it returns a success with a fake user object, not an error. In our test the fake user had a brand new random id, an empty identities array, role set to an empty string, no email_confirmed_at, and whatever user_metadata the caller passed. No confirmation email was sent and the real account was untouched. With Confirm email off it returns 422 user_already_exists, 'User already registered'. So the app that checks data.user.identities.length or watches for a 422 can tell which emails are registered.

Why is data.user.identities empty after signUp?

An empty identities array is the tell that the email already belongs to a confirmed account. For a genuinely new signup Supabase returns identities with one entry. For an existing email it returns an obfuscated fake user with identities set to an empty array so the response looks the same shape. Supabase's own docs suggest checking identities to detect this, but if you surface that check in the UI as 'this email is taken' you have built an enumeration oracle.

Is it safe to turn off Confirm email in Supabase?

No, not for a public app. With Confirm email off, signUp immediately returns a session and sets email_confirmed_at, so anyone can sign up as an address they do not own and get a JWT whose email claim is that address. In our test an account signed up under someone else's email then read rows from a table whose RLS matched auth.jwt() ->> 'email'. It also turns signUp into a clean user_already_exists oracle. Keep Confirm email on and never trust the JWT email as proof of ownership.

Does resetPasswordForEmail or signInWithOtp leak which emails exist?

They can. resetPasswordForEmail returns an empty success for both real and unknown emails on the first call, but a repeat within 60 seconds returns 429 over_email_send_rate_limit for real accounts only. signInWithOtp with shouldCreateUser false returns 422 otp_disabled, 'Signups not allowed for otp', for an unknown email while an existing email proceeds. Password sign in was the safe one: an unknown email and a wrong password both returned the same 400 invalid_credentials.

How do I stop user enumeration on a Supabase signup form?

Keep Confirm email on, never show data.user.identities or the raw error in the UI, show the same 'check your email to confirm' screen whether the email is new or already registered, turn on CAPTCHA (hCaptcha or Turnstile) and the signup rate limits, and never expose a public profiles or users table or an email-lookup endpoint. For RLS, match on the user id (auth.uid()), not auth.jwt() ->> 'email'.