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

Source: https://supahost.app/docs/auth/anonymous · Updated 2026-11-11

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

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

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

```sql
-- 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](/docs/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:**

```ts
// 1. Add the email. Auth sends the "Change email" message to confirm it.
await supabase.auth.updateUser(
  { email: 'pat@example.com' },
  { 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.

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

```ts
// 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](/docs/auth/user-management).

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

```sql
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](/docs/cron-queues/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 `signInAnonymously` only 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](/docs/auth/rate-limits).

More background: [Security of anonymous sign-ins](/kb/anonymous-sign-in-security).

## Reference

- [`signInAnonymously` in the supabase-js reference](https://supabase.com/docs/reference/javascript/auth-signinanonymously)
- [Linking identities](/docs/auth/identity-linking)
- [Row Level Security](/docs/database/row-level-security)
