"Signups not allowed for otp": the real cause
Signups not allowed for otp does not mean OTP sign-in is disabled on your project. GoTrue raises it in exactly one situation: you passed shouldCreateUser: false and the address you passed has no account. That makes the error a one-request account-existence oracle — so never hand it back to the browser, and never "fix" it by building your own email-lookup endpoint.
The error reads like a project setting. Supabase's own error-code reference reinforces that, describing otp_disabled as "Sign in with OTPs (magic link, email OTP) is disabled. Check your server's configuration."
So you check the configuration. Email provider: enabled. Magic links: on. Signups: allowed. And the same call still throws for one address and succeeds for another.
The configuration was never the problem.
Why does signInWithOtp throw "Signups not allowed for otp" for some emails and not others?
Because the error is raised on the not-found branch of a user lookup, and nothing else.
Here is the whole mechanism, from internal/api/otp.go in GoTrue's current master. The handler starts with CreateUser: true and runs one guard before doing anything else:
if ok, err := a.shouldCreateUser(r, params); !ok {
return apierrors.NewUnprocessableEntityError(
apierrors.ErrorCodeOTPDisabled, "Signups not allowed for otp")
} else if err != nil {
return err
}
And shouldCreateUser returns false on one path only:
func (a *API) shouldCreateUser(r *http.Request, params *OtpParams) (bool, error) {
if !params.CreateUser {
// ... validate the address, then:
_, err = models.FindUserByEmailAndAudience(db, params.Email, aud)
if err != nil && models.IsNotFoundError(err) {
return false, nil
}
}
return true, nil
}
Read that as a truth table and it is short:
shouldCreateUser | Account exists | Result |
|---|---|---|
true (the default) | either | link sent, user created if new |
false | yes | link sent |
false | no | 422 otp_disabled — Signups not allowed for otp |
In auth-js the wire field is create_user: options?.shouldCreateUser ?? true, so if you never set the option you will never see this error. It appears only once you deliberately opt out of auto-provisioning — which is the security-minded choice, and that is what makes it worth writing about.
Two details before you debug further:
- The status is 422, not 400. The handler calls
NewUnprocessableEntityError. The widely-quotedAuthException(message: Signups not allowed for otp, statusCode: 400)comes from supabase/auth#1547, filed in June 2023; the message string is unchanged, the status is not. Branch on the codeotp_disabled, never on the number. - A malformed address takes the same exit. When
validateEmailfails,shouldCreateUserreturns(false, err)— and the caller tests!okfirst, so the validation error is discarded and you getotp_disabledanyway. If it fires for an address you are certain exists, check it for whitespace or a stray display name before assuming anything else.
The fix, depending on what you were trying to do
If you wanted "existing users only" login, it already works — the error is the mechanism doing its job. What has to change is what reaches the browser:
const { error } = await supabase.auth.signInWithOtp({
email,
options: { shouldCreateUser: false },
});
// Never: setMessage(error.message)
// Always the same string, whatever happened:
setMessage("If that address has an account, we've sent you a link.");
If you did not mean to set it at all, drop the option. The default creates the user and sends the link, which is what most magic-link flows want.
If you want invite-only signup, do not implement it with shouldCreateUser. Let Auth provision the user, then gate the session on your own membership table in a server component or route handler, with RLS keyed to that table. The person gets a valid session and lands on "this workspace is invite-only" — no oracle, and the gate lives somewhere you control.
The part that matters for security
Point that error at an address list and you have an account-existence scanner. One unauthenticated request per address, using the publishable (anon) key that already ships in your page source:
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST "https://<project>.supabase.co/auth/v1/otp" \
-H "apikey: <publishable key>" -H "Content-Type: application/json" \
-d '{"email":"someone@example.com","create_user":false}'
# 200 -> the account exists. 422 -> it does not.
This has been filed twice and is open both times. supabase/auth#1547 has carried the title "signInWithOtp(email: email, shouldCreateUser: false) leaks information about the existence of an account" since June 2023. supabase/auth#1955, opened November 2024 and still open, makes the point that matters most to anyone planning a workaround:
"Isn't it even possible to run this via say the chrome console? If so, doing something like wrapping this call in an API endpoint does not accomplish much…"
That is correct, and it is why the mitigation here is a product decision rather than a code change. /auth/v1/otp is a public endpoint on your project's hosted Auth server. Proxying it through your own route does not remove the direct path. What you can do is stop amplifying it: return a uniform response from your UI, put your own per-IP limiter in front of any auth-adjacent route, and never render error.message from an auth call. It is the same discipline as the one message behind three different sign-in failures.
The workaround that makes it much worse
Here is where this stops being Supabase's problem and becomes yours. The error is confusing, so somebody decides the clean fix is to ask the database directly — and since auth.users is not reachable through the Data API, they reach for a SECURITY DEFINER function:
-- signInWithOtp tells the browser whether the account exists,
-- so check it ourselves first and render a clean "no account" message.
create or replace function public.email_is_registered(p_email text)
returns boolean
language sql
security definer
as $$
select exists (
select 1 from auth.users where lower(email) = lower(p_email)
);
$$;
grant execute on function public.email_is_registered(text) to anon;
- Warningsupabase/migrations/20260921120000_email_is_registered.sql:3
SECURITY DEFINER function without a fixed search_path
Pin the search_path on the function: add `set search_path = ''` to the definition (or `ALTER FUNCTION <name> SET search_path = '';`) and fully qualify every object reference inside it, e.g. public.profiles instead of profiles.
Look at what that trades. You started with a 200-vs-422 tell on an endpoint Supabase is actively being asked to fix. You now ship a permanent, anonymously callable RPC that answers the same question faster and more cheaply — plus a SECURITY DEFINER function reading auth.users with an unpinned search_path, which is a privilege-escalation surface in its own right, independent of the enumeration.
It is the same trade as a hand-rolled email-exists check on the signup form, and the same trade behind every getUserByEmail replacement. The pattern never changes: a security control gets read as a bug, and removing it feels like fixing it.
A 60-second self-check
# 1. Anything in your migrations reading auth.users?
grep -rniE "from +auth\.users|join +auth\.users" supabase/migrations/
# 2. Any of those functions SECURITY DEFINER, or granted to anon?
grep -rniE "security definer|to anon" supabase/migrations/
# 3. Any UI that renders a raw auth error to the user?
grep -rn "error.message" app/ components/ --include=*.tsx
Hits on 1 and 2 in the same file are the pattern above. GuardLayer flags that function automatically — the scan output further up is the real result for exactly this migration. It has no idea the function is an enumeration oracle; the unpinned search_path on a SECURITY DEFINER function is enough to surface it. Item 3 it cannot check for you, and it is the one that costs nothing to fix.
FAQ
What does "Signups not allowed for otp" mean in Supabase?
That you called signInWithOtp with shouldCreateUser: false for an email or phone number that has no account. GoTrue returns HTTP 422 with the error code otp_disabled. It does not mean OTP sign-in is disabled on your project.
Why does the docs page tell me to check my server configuration?
Because the code otp_disabled is shared. The documented meaning covers the case where OTP sign-in really is switched off; the shouldCreateUser path reuses the same code and the same message.
Is it a 400 or a 422?
422. The handler calls NewUnprocessableEntityError. Older reports quote 400 — the string stayed the same while the status changed, so branch on the otp_disabled code instead.
How do I stop it leaking whether an account exists?
You cannot close it from your repository, because /auth/v1/otp is callable directly with your publishable key. Stop amplifying it instead: show one uniform message regardless of outcome, rate-limit your own auth routes per IP, and never return the raw AuthApiError to the client.
Can I just look the user up myself to avoid the error?
You can, and it is strictly worse. A SECURITY DEFINER function over auth.users granted to anon, or an unguarded admin route, gives an attacker a faster and more reliable oracle than the one you were trying to remove — and nobody upstream will ever fix it, because you own it.
Can GuardLayer detect this?
Not the Auth behaviour itself — that runs in hosted GoTrue, outside your repository. It does detect the workarounds: SECURITY DEFINER functions without a pinned search_path, GRANTs to anon, tables created without RLS, and the service role key escaping the server.
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.