Security of anonymous sign-ins

Anonymous users get the authenticated role, so ordinary policies apply to them too. How to tell them apart in RLS, limit abuse, clean up old anonymous users and upgrade them to real accounts.

Anonymous sign-ins are safe to use if your policies treat anonymous users differently where it matters. The catch is that an anonymous user is a real user with the authenticated role, so every policy written to authenticated applies to them as well. The only difference is the is_anonymous claim in their JWT, which is true.

The main risk: policies that assume "authenticated" means "has an account"

Say a recipe app lets guests build a shopping list without signing up, and it has this policy:

create policy "Signed-in users can publish recipes"
on public.recipes for insert
to authenticated
with check ((select auth.uid()) = author_id);

Once anonymous sign-ins are on, any visitor can call supabase.auth.signInAnonymously() and publish recipes. Add a restrictive policy so only permanent users can do it:

create policy "Only permanent users can publish"
on public.recipes as restrictive for insert
to authenticated
with check ((select (auth.jwt() ->> 'is_anonymous')::boolean) is false);

Restrictive policies are combined with and, so the original policy still decides ownership and this one adds the account requirement. Review every to authenticated policy when you turn the feature on.

Abuse and database growth

Anyone with your anon key (which is public) can create anonymous users, and each one is a row in auth.users. Auth limits anonymous sign-ins per IP address, but a determined script can still create many over time.

  • Give anonymous users as little as possible: their own rows, small payloads, nothing that sends email or calls paid APIs.

  • Check is_anonymous in Edge Functions that cost money to run.

  • Delete old anonymous users on a schedule:

    select cron.schedule(
      'purge-old-guests',
      '30 3 * * *',
      $$ delete from auth.users
         where is_anonymous and created_at < now() - interval '30 days' $$
    );
    

    Rows in your tables that reference auth.users with on delete cascade go with them.

Turning a guest into a permanent user

Keep the same user id, so their data stays attached:

// Add an email (and later a password); Auth sends a confirmation email
await supabase.auth.updateUser({ email: "[email protected]" });

// Or link an OAuth identity
await supabase.auth.linkIdentity({ provider: "google" });

linkIdentity needs Manual identity linking turned on.

On SupaHost

  • Turn the feature on under Dashboard → your project → Auth tab → Sign-in & security → Anonymous sign-ins, then Save sign-in settings.
  • Auth rate limits use the defaults and can't be changed, and there's no CAPTCHA setting on the Auth tab, so the policy and cleanup steps above are your main controls.
  • SupaHost doesn't charge per user, so anonymous users don't add fees, but their rows do take up database space.

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us