Fix: Supabase anonymous sign-ins are disabled
AuthApiError: Anonymous sign-ins are disabled (HTTP 422) means the Anonymous provider is off for your project — turn it on under Authentication → Sign In / Providers, or set enable_anonymous_sign_ins = true in config.toml locally. Before you do, check your RLS: anonymous users get the authenticated role, so every policy scoped to authenticated starts admitting them.
The error comes from GoTrue, Supabase's Auth server, and it's literal — you called signInAnonymously() against a project where that provider is disabled. The fix is a toggle. The part worth your attention is what the toggle does to policies you wrote months ago.
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 and password 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
authenticatedrole. To distinguish between anonymous users and permanent users, your policies need to check theis_anonymousfield of the user's JWT.
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 rate-limiting friction beyond your project's.
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);
- 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
authenticatedrole 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 or password 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.
Catch this before it ships — free
GuardLayer scans every push for this and 26 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.