HomeGuidesSupabase anonymous sign-ins: RLS and is_anonymous, tested
Guide
Supabase anonymous sign-ins: RLS and is_anonymous, tested
Anonymous Supabase users get the authenticated role, so to authenticated policies let them in. We tested is_anonymous checks, conversion, limits and cleanup.
Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.
Anonymous sign-ins are a nice way to let people try your app before they sign up: a cart, a draft, a demo workspace, with no email form. Supabase makes it one line, supabase.auth.signInAnonymously(). The catch is what that user looks like to your database. It is not the anon role. It is a real user with the authenticated role, so every policy written as "signed in users can..." now includes anyone who opens your site.
This guide covers what an anonymous user can reach, the right way to write an is_anonymous check (and two ways that look right but fail), what happens to the claim when the user adds an email, the rate limit and CAPTCHA, and how to clean up old anonymous users without breaking your data. For the wider policy review see the Supabase RLS checklist.
Tested on October 28, 2026. We ran the open source Supabase Auth server from source (commit c3348e8 on master, October 21, 2026) with its 76 migrations on PostgreSQL 17.11, PostgREST 16.5 for the Data API, @supabase/supabase-js 2.118.0, and a local mail catcher for verification emails. We did not test on a hosted Supabase project, which runs its own Auth version, gateway and rate limit setup. Every claim below is either something we reproduced or a quote from the docs and source linked at the end. VibeLocked is independent and not affiliated with Supabase.
The short version
- Anonymous users get the
authenticatedrole.to authenticated using (true)andauth.uid() is not nullboth let them in. - Anyone with your publishable key can create one. So once anonymous sign-ins are on, "signed in" is not a gate at all.
- Block them with a restrictive policy on
is_anonymous. The same check as a normal permissive policy did nothing in our test. - Write
is false, notis not true. A token with no claim passedis not true. - The claim stays
truein the old token after the user converts. Refresh the session after verification, or readauth.usersinstead. - Turn on CAPTCHA and plan cleanup. The rate limit is per IP, and Supabase does not delete old anonymous users for you.
What we tested
One Postgres database with the Auth schema, a set of app tables with the kinds of policies AI tools write, and three kinds of callers: the bare publishable key with no session, an anonymous user from signInAnonymously(), and a permanent user who signed in with a password. Scripts called the Data API through supabase-js the way a browser would. For the conversion tests we turned email confirmation on, captured the verification email locally, and verified it with verifyOtp. Then we repeated the conversion with confirmation off.
What an anonymous user is
Supabase's anonymous sign-ins docs say it plainly: "Like permanent users, the authenticated Postgres role will be used when using the Data APIs to access your project." And they warn: "Review your existing RLS policies before enabling anonymous sign-ins."
This is what the access token looked like for our anonymous user:
| Claim | Value |
|---|---|
role |
authenticated |
aud |
authenticated |
is_anonymous |
true |
amr |
[{ "method": "anonymous" }] |
email |
empty string |
| Lifetime | 3600 seconds (we set the usual 1 hour) |
The user object had is_anonymous: true, no email and no identities. Nothing in role tells it apart from a real customer. Only is_anonymous does, and the JWT claims reference lists it as a required claim in tokens Supabase Auth issues.
What anonymous users could reach
Same tables, three callers:
| Table and policy | Publishable key only | Anonymous user | Permanent user |
|---|---|---|---|
reports: for select to authenticated using (true) |
0 rows | all 2 rows | all 2 rows |
posts: for select using (auth.uid() is not null) |
0 rows | both posts, including the other user's | both posts |
posts: insert with auth.uid() = user_id |
401, 42501 | 201 created | 201 created |
news_feed: restrictive insert, is_anonymous is false |
401, 42501 | 403, 42501 | 201 created |
feed_permissive: the same check as a permissive policy |
401, 42501 | 201 created | 201 created |
orders: restrictive insert, is_anonymous is false |
401, 42501 | 403, 42501 | 201 created |
carts: own rows only |
401, 42501 | 201, sees only its own cart | 201, sees only its own cart |
The first two rows are the problem. A table of internal reports that "only logged in users" could read was readable by anyone who called signInAnonymously() with the key that ships in your frontend. No email, no password, no click on a link. If your app has to authenticated using (true) anywhere, read Supabase RLS using (true) policies next, because anonymous sign-ins make each one public.
The carts row is the case anonymous sign-ins are built for: a policy based on auth.uid() = user_id works the same for anonymous and permanent users, and each one only sees their own rows.
Write the is_anonymous check as a restrictive policy
Supabase's example for "only permanent users can post" is a restrictive policy:
create policy "Only permanent users can post to the news feed"
on news_feed as restrictive for insert
to authenticated
with check ((select (auth.jwt()->>'is_anonymous')::boolean) is false);
The word restrictive is the part that matters. Postgres ORs permissive policies together. The CREATE POLICY docs say "a record is only accessible if at least one of the permissive policies passes, in addition to all the restrictive policies." In our feed_permissive table we wrote the exact same is false check as a normal policy, next to the usual "users insert own rows" policy. The anonymous user's insert went through with 201, because the own rows policy passed and that was enough.
The flip side, from the same Postgres page: "If only restrictive policies exist, then no records will be accessible." So keep your normal permissive policy (own rows, team membership) and add the restrictive one on top. In our news_feed table that pair gave the anonymous user 403 with new row violates row-level security policy "Only permanent users can post to the news feed" and the permanent user 201.
For reads, the same pattern uses using instead of with check (we tested the insert version above):
create policy "Permanent users only"
on public.reports as restrictive for select
to authenticated
using ((select (auth.jwt()->>'is_anonymous')::boolean) is false);
is false, not is not true
We signed a token by hand with role: authenticated and a real user id but no is_anonymous claim, then tried three versions of the check:
| Check in the policy | Real anonymous token | Permanent token | Token with no claim |
|---|---|---|---|
(...)::boolean is false |
denied | allowed | denied |
(...)::boolean is not true |
denied | allowed | allowed |
coalesce((...)::boolean, false) = false |
denied | allowed | allowed |
Supabase's JWT reference lists the claim as always present in its tokens, so with only Supabase tokens all three behave the same. But a missing claim reads as null, and is not true and the coalesce version both treat null as "permanent." is false fails closed. Use it.
Read the user table when the claim can be stale
The claim is a snapshot from when the token was issued. If you need the current answer, a small function can read auth.users.is_anonymous instead:
create or replace function public.is_permanent_user()
returns boolean
language sql stable security definer
set search_path = ''
as $$
select coalesce(
(select not u.is_anonymous from auth.users u where u.id = (select auth.uid())),
false)
$$;
revoke execute on function public.is_permanent_user() from anon, public;
It is security definer because the authenticated role cannot read auth.users. Keep search_path set and return only a boolean. The security definer functions guide covers why. The next section shows when this matters.
What happens when an anonymous user adds an email
Converting means linking an identity to the same user, so their carts and drafts stay theirs. The docs show updateUser({ email }), then a password once the email is verified, or linkIdentity() for OAuth. The docs say converting requires manual linking to be enabled in your project; we had it on. With email confirmation on, this is what we saw:
- Before adding an email, a password is refused.
updateUser({ password })returned 422validation_failed: "Updating password of an anonymous user without an email or phone is not allowed". - An email that already has an account is refused. 422
email_exists. Supabase's docs show how to sign in to the existing account and move the anonymous user's rows over yourself. updateUser({ email })does not convert the user. The response hadis_anonymous: true, an emptyemail, and the address innew_email. A verification email was sent. Setting a password still failed with the same 422.- Verifying converts the same user. After
verifyOtpwith the token from the email, the user row hadis_anonymous: false, the email confirmed, and the same user id. The anonymous user's cart row was still there and still theirs. - The old access token did not change. It still said
is_anonymous: true. With it, the restrictivenews_feedinsert was still denied, while theis_permanent_user()table check allowed it. In our scriptverifyOtpreturned a new session to the client that called it, withis_anonymous: false. But any other copy of the old token, such as a tab that was already open when the user confirmed from their phone, keeps sayingtrueuntil it refreshes. AfterrefreshSession()the new token saidis_anonymous: falseand the insert worked. - After that, a password and normal sign in worked, with the same user id.
So a converted user can be told "you need an account" for up to one token lifetime. Call refreshSession() when your app sees the email confirmed, or use the table check for the actions people try right after signing up. Stale claims in the other direction are a different problem, covered in revoking access for a removed team member.
With Confirm email turned off
We restarted the Auth server with email confirmation off (GOTRUE_MAILER_AUTOCONFIRM=true in the open source server). Then updateUser({ email }) converted the anonymous user immediately: is_anonymous: false, the email marked confirmed, no email sent. The user then set a password and signed in with that address. When a second person later tried to sign up with the same address, they got user_already_exists.
That is the same risk as normal sign up with confirmation off: anyone can claim an address they do not own. The Auth source says as much in user.go: "anonymous users can add an email with automatic confirmation, which is similar to signing up". If you use anonymous sign-ins, keep Confirm email on, and see the Supabase Auth security settings guide for the rest of that page.
Other things we saw
- User metadata is the user's to write.
signInAnonymously({ options: { data: { role: 'admin', plan: 'pro' } } })put both values straight into the token'suser_metadata, andupdateUser({ data })added more. Sendingapp_metadatato the same endpoint got 403not_admin. Never read a role or plan fromuser_metadata; the client side auth guide has the safe patterns. - No MFA for anonymous users.
mfa.enrollreturned 403 "Anonymous user not allowed to perform these actions". In the source, the factors routes userequireNotAnonymous. - Signing out loses the account. After
signOut()the refresh token was rejected (refresh_token_not_found), so that anonymous user can never sign back in. Their access token still read thereportstable through the Data API right after sign out, while the Auth server's own/userendpoint rejected it withsession_not_found. - Static rendering. Supabase's docs warn: "The Supabase team has received reports of user metadata being cached across unique anonymous users as a result of Next.js static page rendering." Render pages that show user data dynamically.
Abuse: rate limits and CAPTCHA
Every call to signInAnonymously() adds a row to auth.users. The docs say "bad actors can abuse the endpoint to increase your database size drastically" and that "It is strongly recommended to enable invisible CAPTCHA or Cloudflare Turnstile". Supabase's rate limits page gives the default: anonymous sign-ins are limited by IP address, "Defaults to 30 requests per hour".
In our test, with the server set to rate limit by a forwarded IP header, 30 anonymous sign-ins from one address succeeded and the 31st got 429 over_request_rate_limit. The next request with a different IP address got 200. So the limit slows one machine down. It does not stop a bot that rotates addresses. (Self hosters, note: our server applied no rate limit at all until GOTRUE_RATE_LIMIT_HEADER was set. With it unset, 40 anonymous sign-ins in a row from one machine all succeeded. The check is in middleware.go.)
CAPTCHA is the real fix. In the Auth source the /signup route, which anonymous sign-ins use, runs the CAPTCHA check first, and the signInAnonymously reference takes a captchaToken option. We did not test CAPTCHA, because it needs a real hCaptcha or Turnstile account. Setup is in Supabase's CAPTCHA guide and in our login brute force guide.
const { data, error } = await supabase.auth.signInAnonymously({
options: { captchaToken },
})
Clean up old anonymous users
The docs say "Automatic cleanup of anonymous users is currently not available" and suggest:
delete from auth.users
where is_anonymous is true and created_at < now() - interval '30 days';
We aged 74 anonymous users by 40 days and ran it. It failed, and deleted nobody:
ERROR: update or delete on table "users" violates foreign key constraint "orders_user_id_fkey" on table "orders"
One of the 74 had a row in orders, a table whose foreign key to auth.users had no ON DELETE action. One delete statement is all or nothing, so one referenced user blocked all 74. A version that skips users who still have rows there worked:
delete from auth.users u
where u.is_anonymous is true
and u.created_at < now() - interval '30 days'
and not exists (select 1 from public.orders o where o.user_id = u.id);
That deleted 73 users. Their sessions and refresh tokens went with them, and so did their rows in carts and posts, which had on delete cascade. Before you schedule this, decide for each table that points at auth.users whether old anonymous rows should be deleted, kept, or blocked from deletion. The ON DELETE CASCADE guide has a query that lists every cascading foreign key, and the deleted rows guide covers backups if you get it wrong. An anonymous user with a paid order probably should not be anonymous at all: the orders restrictive policy above stops that from happening.
Find the policies anonymous users pass
The Security Advisor shows lint 0012 "Anonymous sign-ins enabled" at INFO level. Its docs say "existing RLS policies may allow unintended access" and that affected policies "include those that are associated to the authenticated or public roles". It does not tell you which of yours those are. This query does a first pass. Run it in the SQL editor; it only reads:
select p.tablename, p.policyname, p.cmd, p.roles,
exists (
select 1 from pg_policies r
where r.schemaname = p.schemaname and r.tablename = p.tablename
and r.permissive = 'RESTRICTIVE'
and (r.cmd = p.cmd or r.cmd = 'ALL')
and coalesce(r.qual, '') || coalesce(r.with_check, '') ilike '%is_anonymous%'
) as has_restrictive_anon_check
from pg_policies p
where p.schemaname = 'public'
and p.permissive = 'PERMISSIVE'
and p.roles && array['authenticated', 'public']::name[]
and coalesce(p.qual, '') || coalesce(p.with_check, '') not ilike '%is_anonymous%'
order by has_restrictive_anon_check, p.tablename, p.cmd;
On our test database it listed reports and both posts policies with false, and news_feed and orders inserts with true. Every false row is a policy anonymous users pass. Decide for each one: fine (their own cart), or add a restrictive check. Two limits: a check hidden inside your own function, like is_permanent_user(), shows up as false too, and the query only looks at the public schema.
Common AI generated mistakes
- Treating
to authenticatedas "has an account." With anonymous sign-ins on, it means "opened the app." - Adding the
is_anonymouscheck as a second permissive policy. It is ORed away. It must beas restrictive. is not trueorcoalesce(..., false)instead ofis false.- Checking
user_metadatafor an "is guest" flag the client set itself. - Reading
session.user.is_anonymousin the browser to hide a button, with no policy behind it. The button is not the lock. - Keeping the old session after conversion and showing "create an account" to someone who just did.
- Turning on anonymous sign-ins with Confirm email off, so a guest can claim any unregistered address.
- No CAPTCHA, no cleanup job, and a foreign key that blocks the cleanup when it finally runs.
Checklist
- Every policy
to authenticatedor with notoclause reviewed with anonymous users in mind - Tables that need a real account have a restrictive
is_anonymous is falsepolicy - Restrictive policies sit next to a permissive one, or nobody gets access
- Checks written as
is false, neveris not true - After email verification or
linkIdentity(), the app callsrefreshSession() - Confirm email is on
- CAPTCHA or Turnstile passes a
captchaTokentosignInAnonymously() - No role, plan or guest flag read from
user_metadata - A cleanup job for old anonymous users, tested against every foreign key to
auth.users - Pages that show user data are rendered dynamically
Copy-paste prompt for your AI coding tool
My Supabase project uses anonymous sign-ins (signInAnonymously). Anonymous users get the authenticated role. Read every SQL migration and list each RLS policy that applies to authenticated or public, with its table, command, and whether an anonymous user would pass it. For tables that should need a real account, write one new migration that adds a restrictive policy using (select (auth.jwt()->>'is_anonymous')::boolean) is false, keeping the existing permissive policies. Use is false, never is not true. Then find where the app converts an anonymous user (updateUser with email, verifyOtp, linkIdentity) and make sure it calls refreshSession afterwards. Finally write a cleanup SQL statement for anonymous users older than 30 days that skips users still referenced by tables without on delete cascade. Show me everything before running it, and do not print or ask me for any keys.
More prompts are in the security review prompt guide. To keep these checks from coming back, add an anonymous user to your RLS tests in CI.
Check what your live app exposes with LeakCheck
LeakCheck scans any live app URL or public GitHub repo, read only, wherever it is hosted. On a live app that uses Supabase, it finds the key your app ships, checks whether that key can read your tables, and reads the public Auth settings to flag sign up with email confirmation turned off. On a public repo it looks for committed keys, including a service role key.
Its limits, honestly: LeakCheck never signs up or signs in, anonymously or otherwise, so it tests what the bare key can read, not what an anonymous user can read. It does not report whether anonymous sign-ins are turned on, and it cannot see your policy definitions. The query and the tests above, run against your own project, are how you check that.
Sources
- Supabase: Anonymous sign-ins (authenticated role, is_anonymous, restrictive policy, abuse, cleanup)
- Supabase JavaScript reference: signInAnonymously (captchaToken, custom user metadata)
- Supabase: Rate limits (anonymous sign-ins 30 per hour per IP)
- Supabase: Enable CAPTCHA protection
- Supabase: JWT claims reference (is_anonymous, amr anonymous)
- Supabase: Identity linking (manual linking, linkIdentity)
- Supabase: Row Level Security (anonymous user vs the anon key)
- Supabase JavaScript reference: verifyOtp
- Supabase JavaScript reference: refreshSession
- splinter docs: 0012_auth_allow_anonymous_sign_ins
- PostgreSQL: CREATE POLICY (permissive and restrictive policies)
- Supabase Auth source: anonymous.go (SignupAnonymously)
- Supabase Auth source: user.go (anonymous password rule, email autoconfirm)
- Supabase Auth source: api.go (signup route, CAPTCHA, anonymous rate limit, requireNotAnonymous)
- Supabase Auth source: middleware.go (rate limit header)
Questions
Do Supabase anonymous users use the anon role?
No. A user created with signInAnonymously() gets the authenticated Postgres role, the same as a user who signed up with email or Google. The anon role is only used for requests that carry the publishable or anon key and no user session. In our test an anonymous user read every row of a table whose policy said to authenticated using (true), while the bare anon key got 0 rows.
How do I block anonymous users in a Supabase RLS policy?
Add a restrictive policy that checks the JWT claim: as restrictive, to authenticated, with (select (auth.jwt()->>'is_anonymous')::boolean) is false. It must be restrictive. In our test the same check written as a normal permissive policy did nothing, because Postgres ORs permissive policies and the existing own rows policy still let the anonymous user insert.
Should I write is false or is not true for is_anonymous?
Use is false. In our test a token with no is_anonymous claim at all was blocked by is false but allowed by is not true and by coalesce(..., false) = false. Supabase Auth tokens always carry the claim, but tokens from other issuers or hand written test tokens may not, and is false fails closed.
When does is_anonymous become false after an anonymous user adds an email?
Only after the email is verified. In our test updateUser({ email }) left is_anonymous true and only set new_email. After verifyOtp the user row had is_anonymous false and the same user id, but the old access token still said is_anonymous true until the session was refreshed. With Confirm email turned off, the conversion happened at once and no email was sent.
Does Supabase delete anonymous users automatically?
No. Supabase's docs say automatic cleanup of anonymous users is not available and give a SQL delete on auth.users instead. In our test that delete failed for every user, with a foreign key error, because one old anonymous user still had a row in a table whose foreign key had no ON DELETE action. Skip referenced users or decide what those rows should do first.
Is the anonymous sign-in rate limit enough to stop abuse?
Not on its own. Supabase's default is 30 anonymous sign-ins per hour per IP address. In our test the 31st request from one IP got 429, and a request from a second IP went straight through. Every success is a new row in auth.users, so Supabase recommends CAPTCHA or Turnstile on anonymous sign-ins as well.