Disable public signups; mint users server-side with app_metadata

Anyone with the publishable key can call signUp() and create an orphan with no profile and no claims. Turn signups off and create users with the admin API.

RLS & Auth

· Chapter

17

·

3

min read

The answer. supabase.auth.signUp() is a public endpoint: whoever holds the publishable key can create an auth user, trigger the built-in confirmation email, and end up with an account that has no row in your profiles table, no tenant claim and no permissions. Those orphans are harmless to data but they are noise, and they are the first thing an abuser will generate. If your product decides who may join (an invite, a domain rule, a paid seat), switch signups off (enable_signup = false locally; "Allow new users to sign up" in the hosted dashboard) and create users from your server with the admin API: validate, create the auth user with app_metadata already filled, insert the profile and membership rows, and if any later step fails delete the auth user so the cascade removes what was written. Activation is then a signup-type OTP link sent by your own mailer.

The pattern.

const admin = createClient(URL, SECRET_KEY, { auth: { persistSession: false } });
const { data: created, error } = await admin.auth.admin.createUser({
  email, email_confirm: false, app_metadata: { tenants: [tenant] },
});
if (error) return error.message.includes("already been registered") ? conflict() : fail();
try {
  const { data: profile } = await admin.from("profiles").insert({ user_id: created.user.id, full_name }).select().single();
  await admin.from("memberships").insert({ tenant, profile_id: profile!.id, role: "member" }).throwOnError();
} catch (e) {
  await admin.auth.admin.deleteUser(created.user.id);       // cascades to the half-written profile
  throw e;
}
const { data: link } = await admin.auth.admin.generateLink({ type: "signup", email });
await sendActivation(email, link.properties.hashed_token);  // verifyOtp({ type: "signup", token_hash })

Watch out.

  • Turning off a provider (email, phone) turns off sign-in with it too; only the global signup switch keeps login working.
  • OAuth providers need signups enabled, so an OAuth-capable app still gets orphans; sweep them with a scheduled job instead of pretending they cannot exist.
  • insert() returns nothing unless you chain .select(); a missing .select().single() is why profile is undefined.

Related: magic-links-need-an-allow-listed-redirect-and-a-callback · oauth-callback-exchange-code-then-complete-setup-idempotently · multi-tenancy-tenant-id-on-every-row-and-in-the-jwt