# 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.

Source: https://supahost.app/kb/anonymous-sign-in-security · Updated 2026-11-11

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:

```sql
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:

```sql
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:

  ```sql
  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:

```ts
// Add an email (and later a password); Auth sends a confirmation email
await supabase.auth.updateUser({ email: "cook@recipes.example" });

// 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.

## Related

- [Anonymous sign-ins](/docs/auth/anonymous)
- [Row Level Security](/docs/database/row-level-security)
- [Linking identities](/docs/auth/identity-linking)
- [Auth rate limits and abuse protection](/docs/auth/rate-limits)
