Anonymous sign-ins
Give visitors a real Supabase session without an account on SupaHost, tell anonymous users apart in RLS, convert them to permanent users later, and clean up the ones who never return.
An anonymous sign-in creates a user with no email, password or provider. The visitor gets a normal session and JWT, so everything that works for signed-in users (RLS, Storage policies, Realtime authorization) works for them too. Typical uses: a shopping cart before checkout, a draft that a visitor writes before signing up, or a trial of a team chat app before inviting colleagues.
Anonymous users are different from the anon key. A request made with only the anon key has the Postgres role anon and no user id. An anonymous user has the role authenticated and a real auth.uid().
Turn it on
Anonymous sign-ins are off by default. Open Dashboard → your project → Auth tab → Sign-in & security, switch on Anonymous sign-ins, and click Save sign-in settings. It takes effect within a minute. Until then, calls fail with the error code anonymous_provider_disabled.
Sign a visitor in
const { data, error } = await supabase.auth.signInAnonymously({
options: { data: { referrer: 'pricing-page' } },
})
console.log(data.user?.id, data.user?.is_anonymous) // "4f1c…", true
Call it once, when the visitor first does something that needs saving, not on every page load. The client library keeps the session in storage, so returning visitors on the same browser keep their user. Check first:
const { data: { session } } = await supabase.auth.getSession()
if (!session) await supabase.auth.signInAnonymously()
Telling anonymous users apart in policies
Their JWT has is_anonymous: true. Because they're authenticated, any policy written to authenticated already includes them. Decide per table whether that's what you want.
Let anonymous users keep a cart but not place orders:
-- Anyone signed in, anonymous or not, manages their own cart
create policy "Own cart items"
on public.cart_items for all
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
-- Only permanent users can create orders
create policy "Permanent users place orders"
on public.orders for insert
to authenticated
with check (
(select auth.uid()) = user_id
and (select (auth.jwt() ->> 'is_anonymous')::boolean) is false
);
Add the same is_anonymous check to Storage policies if anonymous users shouldn't upload files. See Storage access control.
Turning an anonymous user into a permanent one
The point of anonymous sign-ins is usually to keep the visitor's data when they sign up. Don't call signUp, which creates a different user. Add an email or provider to the existing anonymous user instead, so the user id (and every row pointing at it) stays the same.
With an email and password:
// 1. Add the email. Auth sends the "Change email" message to confirm it.
await supabase.auth.updateUser(
{ email: '[email protected]' },
{ emailRedirectTo: 'https://shop.example.com/account' }
)
// 2. After the address is confirmed, set a password.
await supabase.auth.updateUser({ password: 'a-long-passphrase' })
With an OAuth provider: this uses identity linking, so Manual identity linking must be on in the same card.
await supabase.auth.linkIdentity({
provider: 'google',
options: { redirectTo: 'https://shop.example.com/account' },
})
After either step, is_anonymous becomes false on the user's next token.
When the person already has an account
If the email or Google account already belongs to another user, the conversion fails: you can't merge two users automatically. Sign the visitor in to their existing account and move the anonymous user's rows yourself, from a server or an Edge Function using the service_role key, for example:
// Server only: `admin` is a client created with SUPABASE_SERVICE_ROLE_KEY
await admin
.from('cart_items')
.update({ user_id: existingUserId })
.eq('user_id', anonymousUserId)
await admin.auth.admin.deleteUser(anonymousUserId)
Check on the server that the request really comes from both users (for example, have the client send the anonymous session's access token before signing in, and verify it with admin.auth.getUser(token)). See Managing users.
Cleaning up
Visitors who never come back leave anonymous users behind. Remove old ones on a schedule with pg_cron, which is built into every SupaHost project:
select cron.schedule(
'purge-anonymous-users',
'30 3 * * *', -- daily at 03:30 UTC
$$
delete from auth.users
where is_anonymous is true
and created_at < now() - interval '30 days';
$$
);
Rows that reference auth.users with on delete cascade go with them. Jobs appear in the project's Cron tab. See Scheduled jobs with pg_cron.
Abuse
Each anonymous sign-in creates a row in your database, so a script can create many users quickly. Auth applies its default per-IP rate limit to anonymous sign-ins, but SupaHost has no CAPTCHA setting to add on top. Reduce the risk by:
- Calling
signInAnonymouslyonly after a real action (adding an item, starting a draft). - Keeping what anonymous users can write small, using the policy checks above.
- Purging old anonymous users with the cron job.
- Protecting the action that triggers sign-in with your own bot check. See Rate limits and abuse protection.
More background: Security of anonymous sign-ins.