The answer. Anonymous sign-in gives a visitor a session before they have an identity: signInAnonymously() creates a row in auth.users with no email and returns a JWT whose role is authenticated and whose is_anonymous claim is true. That role is the catch. Every policy written to authenticated now includes guests, so before enabling the feature audit each one and add a restrictive policy where guests must not write. Conversion keeps the user id and everything they created: updateUser({ email }) starts email verification, linkIdentity({ provider }) attaches an OAuth identity. Because the endpoint is public, enable a captcha (Turnstile is supported natively) and know that an IP is limited to 30 anonymous sign-ins per hour by default.
The pattern.
create policy "guests cannot post" on public.posts as restrictive for insert to authenticated
with check (((select auth.jwt())->>'is_anonymous')::boolean is not true);
const { data: { session } } = await supabase.auth.getSession();
if (!session) await supabase.auth.signInAnonymously(); // only when there is no session at all
// later, on conversion:
await supabase.auth.updateUser({ email }); // sends the verification mail
delete from auth.users where is_anonymous is true and created_at < now() - interval '30 days'; -- pg_cron job
Watch out.
- Calling
signInAnonymously()on every page load creates a fresh user each time and orphans the previous one's rows. Check for an existing session first. - Anonymous users count toward MAU and never delete themselves; schedule the cleanup and cascade their data.
- The Security Advisor flags
to authenticatedpolicies once anonymous sign-ins are on; treat that lint as a checklist, not noise.
Related: update-policies-have-two-halves-using-and-with-check · pg-cron-inside-postgres-vs-a-worker-cron-outside · security-advisor-lints-to-fix-before-launch