hackymacky

Notes

← Blog

Supabase Auth posture: open signup, anonymous users, and public profiles

8 Oct 2026 · 1287 words

/auth/v1/settings is readable with the project URL plus the public (anon/publishable) key. How to read disable_signup, anonymous sign-in, and autoconfirm, what to change in the Dashboard and SQL, and how profile tables leak.

Supabase Auth publishes a settings document at GET /auth/v1/settings. Anyone with your project URL plus the public (anon / publishable) key can read a handful of booleans: whether signup is disabled, whether anonymous users are on, whether email or phone autoconfirm is on. You do not need the secret key.

Those flags are not vulnerabilities by themselves. Open signup is how a consumer app works. They become a problem when the rest of the project was built like an invite-only tool — RLS that only checks auth.uid(), a profiles table readable by anon, autoconfirm so a bot gets a confirmed session with a throwaway inbox.

This post walks the public settings, the Dashboard labels that map to them, the SQL that distinguishes anonymous JWTs from real users, and the identity tables that turn a publishable key into a user directory.

Read the public settings first

bash

SUPABASE_URL="https://YOUR_PROJECT.supabase.co"
ANON_KEY="eyJ…or-sb_publishable"

curl -sS \
  "$SUPABASE_URL/auth/v1/settings" \
  -H "apikey: $ANON_KEY" \
  -H "Authorization: Bearer $ANON_KEY"

You are looking for fields like:

  • disable_signup — false means anyone can register
  • mailer_autoconfirm — true means a signup is confirmed without clicking a mail
  • phone_autoconfirm / sms_autoconfirm — same idea for phone
  • external.anonymous_users (sometimes anonymous) — anonymous sign-in is enabled

Exact JSON shape has moved as Auth has grown. If a key is missing, do not guess; open Authentication in the Dashboard and read the control. The curl is a probe, not the UI.

A second endpoint you should never see succeed with the publishable key:

bash

curl -sS -D - \
  "$SUPABASE_URL/auth/v1/admin/users" \
  -H "apikey: $ANON_KEY" \
  -H "Authorization: Bearer $ANON_KEY"

401 / 403 is correct. A list-shaped 200 means the key you thought was publishable is being treated as service_role, or Auth admin is otherwise exposed. Rotate the secret and the publishable key. Search the client bundle for service_role / sb_secret_ — the bundle post is the runbook.

Open signup

Dashboard path: Authentication → Providers → Email → Allow new users to sign up.

The hosted Auth docs describe it as: users can sign up; if you disable it, only existing users can sign in. Locally, supabase/config.toml has the same switch:

toml

[auth]
enable_signup = false

That maps to GoTrue's DISABLE_SIGNUP. Leave [auth.email] enable_signup = true if invited users still need the email provider. The provider flag and the global signup flag are easy to mix up; the global one is the invite-only switch.

When is open signup wrong? Internal tools, "we share this preview with the team," admin panels that used Supabase Auth because it was already there. A public register form issues an authenticated JWT. Every RLS policy you wrote for signed-in users now applies to whoever found the URL.

When is it fine? A product that is supposed to take signups. Then the work is rate limits, CAPTCHA / Turnstile, leaked-password protection, and RLS that does not treat "has a uid" as "is a trusted customer."

If you disable public signup, use invites or a server-side admin createUser flow. Do not hide the register button in CSS and leave the API on.

Email autoconfirm

Dashboard: Authentication → Providers → Email → Confirm email.

Official general-configuration text: with Confirm Email off, Auth assumes the address does not need to be verified and implicitly confirms it. That is mailer_autoconfirm.

Local config:

toml

[auth.email]
enable_confirmations = true
secure_password_change = true

enable_confirmations = true is the one you want in production for email/password. Magic-link projects already prove inbox control on sign-in; still do not leave autoconfirm on "so the first test user works" on a shared project.

Autoconfirm plus open signup means a bot that can POST /auth/v1/signup walks out with a confirmed authenticated session. Your to authenticated policies apply. If those policies are "any signed-in user can read orders," the bot reads orders.

Phone / SMS has the same knob. If you do not use phone auth, leave it off. If you do, require OTP and keep autoconfirm disabled.

Anonymous sign-in is not the anon key

This is the mix-up that shows up in every other review.

The anon / publishable key is an application key. Signed-out requests use the anon Postgres role. No auth.users row is created. That is RLS.

Anonymous sign-in is signInAnonymously(). It creates a row in auth.users. The JWT uses the authenticated role and includes "is_anonymous": true. Official docs: review RLS before you enable it; auth.uid() is not enough; check is_anonymous.

Dashboard: Authentication → Providers → Anonymous.

toml

[auth]
enable_anonymous_sign_ins = false

Enable it for throwaway carts and demos. Disable it if you did not build those flows on purpose. AI scaffolds sometimes turn it on because the feature exists.

If you keep it, add restrictive policies for writes. Permissive policies OR together; a lone "own row" policy still passes for an anonymous uid.

sql

create policy "deny_anonymous_insert"
on public.orders
as restrictive
for insert
to authenticated
with check (
  (select (auth.jwt() ->> 'is_anonymous')::boolean) is not true
);

create policy "orders_insert_own"
on public.orders
for insert
to authenticated
with check ( (select auth.uid()) = user_id );

A restrictive policy alone is not enough — Postgres still needs a permissive policy that returns true. You want both: the usual own-row check, and a restrictive "not anonymous" check that always runs.

Anonymous users occupy auth.users. Auth documents an IP rate limit and recommends CAPTCHA / Turnstile. Cleanup is not automatic; the documented delete is a SQL statement on auth.users where is_anonymous is true and created_at is old. Run that on a schedule if you enable the provider.

Public profile tables

The settings document does not mention your profiles table. The Data API does.

Scaffolds create public.profiles with id, email, display_name, sometimes phone. A first-pass policy of for select using (true) made the avatar show on a public page. It also made every email listable with the publishable key.

bash

curl -sS -D - \
  "$SUPABASE_URL/rest/v1/profiles?select=id,email,display_name&limit=5" \
  -H "apikey: $ANON_KEY" \
  -H "Authorization: Bearer $ANON_KEY" \
  -H "Prefer: count=exact"

Repeat for names you actually have: users, user_profiles, accounts, members, customers, contacts. A 200 with emails is a directory. Content-Range: 0-4/4000 is the size of that directory.

Fix it the same way as any other table: enable RLS, revoke anon grants you do not want, grant authenticated what the app needs, write to authenticated policies on auth.uid(). If you need a public display name, expose a view or a slim set of columns — not email, not phone, not the auth uid as a join key for anon.

sql

alter table public.profiles enable row level security;
revoke all on table public.profiles from anon;
grant select, update on table public.profiles to authenticated;

create policy "profiles_select_own"
on public.profiles
for select
to authenticated
using ( (select auth.uid()) = id );

A public card, if you truly want one:

sql

grant select (id, display_name, avatar_url) on table public.profiles to anon;

create policy "profiles_public_card"
on public.profiles
for select
to anon
using ( is_public = true );

Column-level grants help; they are not a substitute for RLS. Test with curl as anon and confirm email is not in the payload.

Recover should not tell you who exists

POST /auth/v1/recover should return success for existing and unknown addresses so an attacker cannot harvest the user list. Hosted Auth does this. Do not customize the handler to return user_not_found. Tighten rate limits under Authentication → Rate Limits.

When you probe this yourself, use an address that cannot exist and do not use a real customer email. A 200/204 for a fake address is the safe result.

Checklist you can run in fifteen minutes

  1. GET /auth/v1/settings with the publishable key. Write down signup, anonymous, autoconfirm.
  2. Dashboard: set those three to match the product you think you shipped. Save.
  3. GET /auth/v1/admin/users with the publishable key. Expect 401/403.
  4. REST GET on profiles / users / accounts with Prefer: count=exact.
  5. If anonymous sign-in is on, grep policies for auth.uid() without is_anonymous.
  6. Re-run the settings curl after saving. Do not assume the toggle persisted on the branch you think you are on.

hackymacky preview (paste a URL) finds the publishable key in JS and probes REST. Auth settings, admin-users, public identity tables, and recover enumeration run on the deep scan, after you claim the domain. Findings map to the flags above and include config.toml / SQL snippets. Mark a finding ignored or fixed on the project page; daily rescans keep the score honest if someone flips Confirm email back off.

If the site is already live, claim the hostname and look at Auth posture before you tweet the launch. The curls are enough to know whether you need to.