← All posts
·9 min read·GuardLayer

"Signups not allowed for this instance" in Supabase

SupabaseAuthRLSAuth HooksSecurity

Signups not allowed for this instance (HTTP 422, code signup_disabled) means new-user sign-ups are off for your Supabase project, via "Allow new users to sign up" in the dashboard or enable_signup under [auth] in supabase/config.toml. Existing users still sign in and invites still work; for a real invite-only app, keep it off and scope RLS to members, not to every authenticated account.

If you turned sign-ups off on purpose, the error is the gate working. The question is what that gate covers.

What does "Signups not allowed for this instance" mean?

It means Supabase Auth refused to create a new user because sign-ups are disabled project-wide. It's the first check in the Signup handler of GoTrue, Supabase's Auth server:

if config.DisableSignup {
	return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeSignupDisabled, "Signups not allowed for this instance")
}

The same check and string sit on every self-service path that creates an account:

  • signUp() with an email or phone.
  • OAuth and signInWithIdToken() (and SAML SSO and Web3 sign-in, which share the same branch), when the identity would create a new user. Only that branch is blocked; an identity Auth links to an existing account still signs in. signInWithIdToken() gets the 422 back directly. An OAuth redirect lands on your redirect URL with error=access_denied and error_code=signup_disabled in the query string and the fragment, so check error_code in your /auth/callback route.
  • signInAnonymously(). With the Anonymous provider off you get Anonymous sign-ins are disabled first. Turn it on and you get this string, because an anonymous user is a new user.
  • signInWithOtp() and magic links for an address with no confirmed account. More on that below.

What it doesn't touch: sign-in for existing users, inviteUserByEmail, admin createUser and email verification; GoTrue's invite, verify and admin handlers and its password grant never read the setting. Its README says it in one line: "When signup is disabled the only way to create new users is through invites."

Two switches, two errors

supabase/config.toml has two keys with the same name that matter here (a third, under [auth.sms], does for phone what the email one does for email):

[auth]
enable_signup = false   # no self-service sign-ups: 422 "Signups not allowed for this instance"

[auth.email]
enable_signup = true    # false disables the Email provider: 400 "Email signups are disabled"

The CLI maps [auth.email] enable_signup to the Email provider itself, so setting it to false also blocks existing users' email sign-in, with Email logins are disabled. For "no new accounts", use the top-level key. Both default to true, so a local stack started from an untouched file has open sign-ups.

Hiding the sign-up button is not invite-only

The risky version of an internal tool skips the switch, deletes the sign-up page and writes policies for "our users":

-- The sign-up page is gone from the UI, so only invited staff
-- can get an account. Every signed-in user is one of us.
create table public.internal_docs (
  id uuid primary key default gen_random_uuid(),
  title text not null,
  body text,
  created_at timestamptz not null default now()
);

alter table public.internal_docs enable row level security;

create policy "staff read"
  on public.internal_docs
  for select
  to authenticated
  using (true);
guardlayer scan · supabase/migrations/20261024120000_internal_docs.sqlLive engine output
Passed with warnings
92/100 · A
  • Warningsupabase/migrations/20261024120000_internal_docs.sql:12

    Overly permissive RLS policy

    Scope the policy to the requesting user, e.g. USING (auth.uid() = user_id). Reserve (true) for genuinely public, read-only data.

The UI was never the gate. /auth/v1/signup and /auth/v1/authorize?provider=google are reachable without your UI, so with sign-ups on, a stranger registers with their own email or, if you enabled it, their Google account. Every self-registered account has the authenticated role, so to authenticated using (true) means anyone, and anonymous users are authenticated too. It's the USING (true) trap with one extra step.

GuardLayer reports that policy as supabase/policy-using-true (warning), because the table name looks like per-owner data. What it can't see is whether sign-ups are actually on: no rule checks enable_signup in supabase/config.toml, and the dashboard setting isn't in your repo. The warning stays after you disable sign-ups. Fix the policy anyway, because the switch is one click from undone.

How do I make Supabase sign-up invite-only?

Turn sign-ups off, create accounts only from a server route that checks who's asking, and scope rows to a membership table.

  1. Switch off "Allow new users to sign up" under Authentication → Sign In / Providers, and set enable_signup = false under [auth] locally.
  2. Invite from the server. inviteUserByEmail needs the secret key, because the admin API refuses publishable keys with User not allowed. Keep it in a route handler that checks the caller first:
// app/api/invites/route.ts
import { createClient as createAdminClient } from "@supabase/supabase-js";
import { createClient } from "@/lib/supabase/server";

export async function POST(req: Request) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) return new Response(null, { status: 401 });

  const { data: inviter } = await supabase
    .from("staff").select("user_id").eq("user_id", user.id).maybeSingle();
  if (!inviter) return new Response(null, { status: 403 });

  const { email } = await req.json();
  if (typeof email !== "string") return new Response(null, { status: 400 });

  const admin = createAdminClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SECRET_KEY!);
  const { data, error } = await admin.auth.admin.inviteUserByEmail(email);
  if (error) return Response.json({ error: error.message }, { status: 400 });

  await admin.from("staff").insert({ user_id: data.user.id });
  return Response.json({ invited: email });
}
  1. Scope rows to members, so the policy holds even if sign-ups get switched back on:
create table public.staff (
  user_id uuid primary key references auth.users (id) on delete cascade
);
alter table public.staff enable row level security;
grant select on public.staff to authenticated;
grant insert on public.staff to service_role;

create policy "see own membership" on public.staff
  for select to authenticated
  using (user_id = (select auth.uid()));

drop policy "staff read" on public.internal_docs;
create policy "staff read" on public.internal_docs
  for select to authenticated
  using (exists (
    select 1 from public.staff s where s.user_id = (select auth.uid())
  ));

That migration scans clean in GuardLayer. The first migration keeps its warning, though: the scanner checks each file on its own and doesn't match a later drop policy to the policy it drops.

To keep self-serve sign-up for your own domain, a before_user_created hook can refuse the account before it exists. GoTrue runs it on email, phone, OAuth, anonymous and invite sign-ups. Two cautions. A domain check trusts an address nobody has proved yet, so keep Confirm email on: with it off, Supabase "implicitly confirms the user's email", and anyone can register as anything@yourcompany.com and sign straight in. And the docs' SQL "Allow by Domain" example ends with -- No match, allow by default, so a domain on neither of its lists gets an account. An allowlist has to end by returning the error.

Why does my before_user_created hook return "Invalid payload sent to hook"?

Because your HTTP hook rejected the sign-up with status 400, and GoTrue doesn't read the body of a 400. In hookshttp.go, only a 200 or 202 response is parsed for an error object. A 400 becomes a 500 Invalid payload sent to hook (code unexpected_failure), and statuses without their own branch, such as 403, become Unexpected status code returned from hook: 403.

The sign-up is still refused. You lose your message, and a 500 reads like a broken hook, which tempts you to switch the hook off.

The docs disagree with each other. The before-user-created page says an error object with a 4xx status "blocks the request and propagates the error message to the client", and its domain-check Edge Function example rejects with status: 400. The auth hooks page's error handling section matches the code: 400 and 403 are "Treated as Internal Server Errors". supabase/auth#2235, opened in September 2025, is still open. In February 2026, contributor cstockton confirmed the status-based behavior there and linked #2380, merged that day: "Responses will only be read when the status code is 200 or 202."

So return the error object with status 200 and an application/json Content-Type, and put the status you want in http_code. Verify the webhook signature first, as with any HTTP hook:

if (!allowedDomains.includes(domain)) {
  return Response.json(
    { error: { http_code: 403, message: "Use your company email address." } },
    { status: 200 },
  );
}

http_code defaults to 500 if you leave it out. Never leave out message: GoTrue treats an error object with an empty message as no error, and the sign-up goes through. A Postgres-function hook returns the same jsonb object and has no status code to get wrong.

Disabling sign-ups turns magic links into an account check

POST /signup is uniform once sign-ups are off: the setting is checked before any lookup, so every caller gets the same 422. Magic links and OTP are not. GoTrue's magic-link handler treats an unknown or unconfirmed email as a new user and calls Signup, which now returns signup_disabled. A confirmed account gets a 200 and an email. SMS OTP does the same with phone numbers. So signInWithOtp() tells anyone whether an account exists.

It's the same oracle as shouldCreateUser: false, under a different code. You can't close it from your repo, because /auth/v1/otp is public, but you can stop amplifying it: catch signup_disabled and show "If that address has an account, we've sent a link" either way. Also turn on CAPTCHA protection: GoTrue checks it on /otp, /magiclink and /signup itself, so it also slows down callers who skip your UI.

Quick self-check

# 1. enable_signup under [auth] (missing means true)
grep -n "enable_signup" supabase/config.toml

# 2. Policies that trust every signed-in account
grep -rni -B4 "using (true)" supabase/migrations/ | grep -i "to authenticated"

# 3. Functions answering with a 4xx (in a hook, only a bad signature should)
grep -rnE "status: *4[0-9]{2}" supabase/functions/

config.toml governs your local stack unless you push it, so check the hosted project's dashboard setting too.

FAQ

What does "Signups not allowed for this instance" mean? Sign-ups are disabled for the project, so Supabase Auth refused to create a new user. It returns HTTP 422 with the code signup_disabled. Existing users can still sign in.

How do I fix "Signups not allowed for this instance"? If you want public sign-up, turn "Allow new users to sign up" back on under Authentication → Sign In / Providers, or set enable_signup = true under [auth] locally. If you turned it off on purpose, catch signup_disabled and tell the user the app is invite-only.

Why do I get it when signing in with Google? The Google identity would have created a new user, and that's the branch the setting blocks. Anyone who has signed in with Google before already has that identity on file, so they still get in.

Does disabling sign-ups stop inviteUserByEmail? No. Invites, admin createUser and sign-in for existing users never check the setting. Invites need the secret key, so send them from the server.

Is hiding the sign-up form enough for an invite-only app? No. The Auth endpoints work without your UI. Turn sign-ups off and scope RLS to a membership table, not to authenticated.

Can GuardLayer tell whether sign-ups are enabled? No. No rule checks enable_signup in supabase/config.toml, and the dashboard setting isn't in your repo. It flags the policies that turn open sign-ups into a leak, such as to authenticated using (true) on a table of per-owner data.

Catch this before it ships — free

Scan your files for this and 35 other Next.js + Supabase issues, with the exact fix inline. Connect GitHub and GuardLayer checks every push too — free on public repos.

No signup, no card. Raw files aren’t kept; only the report is saved.

Keep reading

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