← All posts
·6 min read·GuardLayer

Supabase anonymous sign-ins: enable them, fix "disabled"

SupabaseAuthRLSErrors

AuthApiError: Anonymous sign-ins are disabled (HTTP 422, code anonymous_provider_disabled) has two causes. If you called signInAnonymously(), the Anonymous provider is off: enable it under Authentication → Sign In / Providers, or set enable_anonymous_sign_ins = true in supabase/config.toml locally. If you hit it on a normal sign-up form, your signUp() sent an empty email, and Supabase treated it as an anonymous sign-in: fix the form, not the setting. Before you enable the provider, check your RLS: anonymous users get the authenticated role, so every policy scoped to authenticated starts admitting them.

Both causes end at the same check in GoTrue, Supabase's Auth server: a POST /signup with no email and no phone takes the anonymous sign-in path, and that path returns this 422 while the provider is disabled. So the fix is either a toggle or a form bug. The part worth your attention is what the toggle does to policies you wrote months ago.

Once anonymous sign-ins are on, the Supabase Security Advisor lists them as lint 0012. For the other warnings on that page, see every Security Advisor warning explained.

How do I enable anonymous sign-ins in Supabase?

Hosted project: Dashboard → Authentication → Sign In / Providers → enable Anonymous sign-ins. It takes effect immediately; no redeploy.

Local development: the dashboard toggle doesn't apply. Add it to supabase/config.toml and restart:

[auth]
enable_anonymous_sign_ins = true
supabase stop && supabase start

If you're on an older CLI, that key may not exist yet — supabase --version, then upgrade before assuming the config is broken.

Then the call works:

const { data, error } = await supabase.auth.signInAnonymously();
// data.user.is_anonymous === true

If you weren't trying to sign in anonymously

A surprising number of these come from a sign-up that lost its credentials. signUp() with an empty or undefined email gets interpreted as an anonymous sign-in attempt, and GoTrue returns this exact 422. So if you see it on a normal registration form, the bug isn't the provider setting — it's that email arrived empty:

// email is "" → GoTrue treats this as an anonymous sign-in
await supabase.auth.signUp({ email, password });

Log the values before the call. Enabling the anonymous provider to make that error disappear will "fix" it by silently creating anonymous accounts instead of real ones.

The part nobody warns you about: anonymous users are authenticated

This is the real reason this setting deserves a security review. An anonymous user is a genuine row in auth.users with a real JWT — and Postgres treats them as a first-class logged-in user:

Anonymous users use the authenticated role. To distinguish between anonymous users and permanent users, your policies need to check the is_anonymous field of the user's JWT.

— Supabase docs, Anonymous Sign-Ins

So to authenticated no longer means "someone who created an account." It means "anyone who called signInAnonymously()" — which is anyone at all, with no email, no password, and no friction beyond Supabase's default limit of 30 anonymous sign-ins per hour per IP address.

Here's a policy that was defensible the day it was written:

create table public.documents (
  id uuid primary key default gen_random_uuid(),
  owner_id uuid not null references auth.users,
  body text
);

alter table public.documents enable row level security;

-- "Signed-in users only" — safe until anonymous sign-ins are enabled.
create policy "authenticated users can read documents"
  on public.documents
  for select
  to authenticated
  using (true);
guardlayer scan · supabase/migrations/20260803140000_documents_policy.sqlLive engine output
Passed with warnings
92/100 · A
  • Warningsupabase/migrations/20260803140000_documents_policy.sql:10

    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.

USING (true) inside a to authenticated policy is a bet that the authenticated role is a meaningful trust boundary. Flip the anonymous toggle and that bet is lost: two SDK calls — signInAnonymously(), then select * from documents — read the whole table. It's the USING (true) trap with a delayed fuse.

Fixing the policies

The durable fix is to scope by ownership rather than by role, which you want regardless of this setting:

drop policy "authenticated users can read documents" on public.documents;

create policy "users read their own documents"
  on public.documents
  for select
  to authenticated
  using ((select auth.uid()) = owner_id);

Where a table genuinely should be readable by any signed-in permanent user, exclude anonymous sessions explicitly. Supabase's documented pattern uses a restrictive policy, because permissive policies combine with OR and a single permissive true elsewhere would undo the check:

create policy "permanent users only"
  on public.documents
  as restrictive
  for select
  to authenticated
  using ((select (auth.jwt() ->> 'is_anonymous'))::boolean is false);

Restrictive policies combine with AND, so this one narrows every permissive policy on the table. It also can't stand alone — you still need a permissive policy granting the access it then restricts.

Note the is false rather than = false: a missing claim yields NULL, and NULL = false is NULL, which fails closed but also blocks legitimate users on tokens issued before the claim existed. is false is the form in Supabase's own example.

Should you enable it at all?

Anonymous sign-ins are a real feature with a real use case — letting someone try a product before registering, then upgrading the same user record with updateUser() so their work carries over. That's better UX than a fake "guest" flag you maintain yourself.

The costs are worth pricing in:

  • Every call creates a permanent row in auth.users. Bots will find the endpoint. Supabase recommends turning on CAPTCHA protection for this reason, and you'll want a cleanup job for stale anonymous users.
  • Your authenticated role is now public. Audit every policy targeting it, not just the one you're thinking of.
  • Anonymous users can't be emailed or recovered. Lose the local session and the data is orphaned.

If you enabled the provider only to silence the 422 on a broken sign-up form, turn it back off and fix the form.

A 60-second self-check

# 1. Policies that trust the authenticated role blanket-wide
grep -rn -B3 "using (true)" supabase/migrations/ | grep -i "to authenticated" -A3

# 2. Do any policies account for anonymous sessions?
grep -rn "is_anonymous" supabase/migrations/

# 3. Is the provider on locally?
grep -n "enable_anonymous_sign_ins" supabase/config.toml

If command 1 returns policies and command 2 returns nothing while the provider is enabled, those tables are readable by anyone willing to make one extra API call. Verify it the honest way — from the client SDK, not the SQL editor, which bypasses RLS and will show you a false pass.

GuardLayer's supabase/policy-using-true rule flags USING (true) policies on every push, which is the shape that turns this feature flag into a data leak.

FAQ

Is the anonymous key the same as an anonymous user? No, and the naming is genuinely confusing. The anon key is your project's public API key and maps to the anon Postgres role. An anonymous user is a real auth.users row that gets the authenticated role.

How do I tell anonymous users apart in an RLS policy? Check the is_anonymous claim: (auth.jwt() ->> 'is_anonymous')::boolean is false for permanent users only.

Can an anonymous user become a permanent one? Yes. Call updateUser() with an email or link an OAuth identity — the same user id is kept, so their data carries over.

Why do I get this error on a normal email sign-up? Your email is empty when it reaches signUp(), so GoTrue reads it as an anonymous attempt. Fix the form; don't enable the provider.

Do anonymous users count toward my MAU billing? Check your plan's current MAU definition before opening the endpoint up — Supabase counts users with active sessions, and anonymous users have real sessions. What's certain is that each one is a permanent row in auth.users, so add CAPTCHA protection and a cleanup job for stale accounts.

See which of your Supabase tables anyone can read, free

Connect your Supabase project read-only. GuardLayer checks RLS, policies, grants and functions as they are right now, and gives you the SQL to fix each problem. It never reads your rows and can't change anything.

Check my Supabase project

Prefer to check code instead? Scan your repo, no signup.

Keep reading