Anonymous Sign-Ins for Self-Hosted Supabase: Complete Setup Guide

Configure anonymous authentication for self-hosted Supabase with GoTrue environment variables, RLS policies, and CAPTCHA protection.

Cover Image for Anonymous Sign-Ins for Self-Hosted Supabase: Complete Setup Guide

Anonymous sign-ins let users explore your application without creating an account. They get a real authenticated session with a unique user ID, but without providing an email, password, or any personally identifiable information. When they're ready to commit, they can link a real identity to their anonymous account—preserving all their data.

On Supabase Cloud, enabling anonymous sign-ins takes one toggle in the dashboard. On self-hosted Supabase, the dashboard toggle doesn't exist. You'll configure it through environment variables in your docker-compose.yml, just like OAuth providers.

This guide covers everything: enabling the feature, writing RLS policies that distinguish anonymous from permanent users, protecting against abuse with CAPTCHA, and converting anonymous users to full accounts.

Why Anonymous Sign-Ins Matter

Anonymous authentication solves a common product problem: friction at the door. Users want to try your app before committing their email. Anonymous sign-ins let them:

  • Try before signing up: Users can explore features, save preferences, and test workflows without creating an account
  • Build shopping carts: E-commerce apps can let users add items before checkout
  • Play demo modes: Games and productivity apps can offer meaningful trials
  • Preserve data on conversion: When users finally sign up, their anonymous user ID stays the same—all associated data comes with them

The key insight is that anonymous users are real users. They have unique IDs in auth.users, they use the authenticated Postgres role, and your existing RLS policies apply to them. The only difference is a JWT claim: is_anonymous: true.

Understanding Anonymous vs. Anon Key Access

Before configuring anonymous sign-ins, it's important to understand the difference between:

Anonymous users (what this guide covers):

  • Created via signInAnonymously()
  • Get a real user record in auth.users
  • Use the authenticated Postgres role
  • Have JWT with is_anonymous: true claim
  • Can be converted to permanent users

Anon API key access (default public access):

  • Uses the public anon key
  • No user record created
  • Uses the anon Postgres role
  • Cannot be converted to a user
  • Used for public data access

Anonymous sign-ins give you authenticated sessions for users who haven't identified themselves—a middle ground between public access and full registration.

Enabling Anonymous Sign-Ins in Self-Hosted Supabase

The configuration requires one environment variable in your auth (GoTrue) service.

Step 1: Update Your .env File

Add the following variable to your .env file:

ENABLE_ANONYMOUS_USERS=true

Step 2: Configure docker-compose.yml

In your docker-compose.yml, ensure the auth service includes:

auth:
  container_name: supabase-auth
  image: supabase/gotrue:latest
  environment:
    # Existing configuration...
    GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS}

If you're not using .env file references, you can set it directly:

auth:
  environment:
    GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: "true"

Step 3: Restart Your Services

Apply the configuration:

docker compose down
docker compose up -d

Verify the setting by checking auth logs:

docker compose logs auth | grep -i anonymous

For a deeper dive into all GoTrue configuration options, see our environment variables guide.

Using Anonymous Sign-Ins in Your Application

With the feature enabled, authenticate anonymous users with the Supabase client:

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  'https://your-supabase-domain.com',
  'your-anon-key'
)

// Create an anonymous session
const { data, error } = await supabase.auth.signInAnonymously()

if (error) {
  console.error('Anonymous sign-in failed:', error.message)
} else {
  console.log('Anonymous user ID:', data.user.id)
  console.log('Is anonymous:', data.user.is_anonymous)
}

The returned user object includes is_anonymous: true. From this point, the user has a valid session and can interact with your database through RLS-protected queries.

Writing RLS Policies for Anonymous Users

Here's where things get interesting. Anonymous users use the authenticated role, which means your existing RLS policies apply to them. If you've written policies like:

create policy "Authenticated users can read posts"
on posts for select
to authenticated
using (true);

Anonymous users can read posts too. That's often fine—but you might want to restrict certain actions to permanent users only.

Checking the is_anonymous Claim

Use the JWT claim to distinguish anonymous users:

-- Check if current user is anonymous
select (auth.jwt()->>'is_anonymous')::boolean;

Example: Restrict Writes to Permanent Users

Allow anonymous users to read but not write:

-- Anyone authenticated can read
create policy "All users can view products"
on products for select
to authenticated
using (true);

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

Example: Allow Anonymous Users Limited Access

Let anonymous users create data they own, but restrict public actions:

-- Anonymous users can save items to their own cart
create policy "Users can manage their cart"
on cart_items for all
to authenticated
using (user_id = auth.uid())
with check (user_id = auth.uid());

-- Only permanent users can post public comments
create policy "Only permanent users can comment"
on comments for insert
to authenticated
with check (
  (auth.jwt()->>'is_anonymous')::boolean is false
);

Important: Use Restrictive Policies

When combining anonymous user checks with other conditions, use restrictive policies to ensure the check is always enforced:

-- RESTRICTIVE ensures this check combines with AND, not OR
create policy "Block anonymous from publishing"
on articles as restrictive
for insert
to authenticated
with check (
  (auth.jwt()->>'is_anonymous')::boolean is false
);

-- Permissive policy for additional conditions
create policy "Users can publish their own articles"
on articles
for insert
to authenticated
with check (author_id = auth.uid());

RLS policies are permissive by default (combined with OR). A restrictive policy ensures the anonymous check is always applied. For more on RLS patterns, see our Row Level Security guide.

Protecting Against Abuse with CAPTCHA

Anonymous sign-ins have a vulnerability: bots can create unlimited anonymous users, bloating your database. Supabase enforces a default rate limit of 30 requests per hour per IP, but determined attackers can work around IP limits.

The solution is CAPTCHA. Supabase supports hCaptcha and Cloudflare Turnstile.

Configuring CAPTCHA for Self-Hosted

Add these environment variables to your .env:

# CAPTCHA Configuration
SECURITY_CAPTCHA_ENABLED=true
SECURITY_CAPTCHA_PROVIDER=turnstile  # or hcaptcha
SECURITY_CAPTCHA_SECRET=your-captcha-secret-key
SECURITY_CAPTCHA_TIMEOUT=10s

Update your docker-compose.yml auth service:

auth:
  environment:
    GOTRUE_SECURITY_CAPTCHA_ENABLED: ${SECURITY_CAPTCHA_ENABLED}
    GOTRUE_SECURITY_CAPTCHA_PROVIDER: ${SECURITY_CAPTCHA_PROVIDER}
    GOTRUE_SECURITY_CAPTCHA_SECRET: ${SECURITY_CAPTCHA_SECRET}
    GOTRUE_SECURITY_CAPTCHA_TIMEOUT: ${SECURITY_CAPTCHA_TIMEOUT}

Client-Side CAPTCHA Integration

When CAPTCHA is enabled, you must include the token with anonymous sign-ins:

// Using Cloudflare Turnstile
const turnstileToken = await getTurnstileToken()

const { data, error } = await supabase.auth.signInAnonymously({
  options: {
    captchaToken: turnstileToken
  }
})

For Turnstile, you'll need the site key (public) on the frontend and the secret key (private) in your GoTrue configuration.

Invisible CAPTCHA

Both hCaptcha and Turnstile support invisible mode—users never see a challenge unless they're suspicious. This provides protection without friction:

// Turnstile invisible widget
<div 
  class="cf-turnstile" 
  data-sitekey="your-site-key"
  data-callback="onTurnstileSuccess"
  data-size="invisible"
></div>

For applications where anonymous sign-ins are a core feature, CAPTCHA protection is essential.

Converting Anonymous Users to Permanent Accounts

The real power of anonymous sign-ins is conversion. When users are ready to create a full account, they link an identity to their existing user:

Enable Manual Linking

First, enable manual linking in your auth configuration:

auth:
  environment:
    GOTRUE_SECURITY_MANUAL_LINKING_ENABLED: "true"
const { data, error } = await supabase.auth.updateUser({
  email: '[email protected]',
  password: 'securepassword123'
})

if (!error) {
  // User is now permanent
  // is_anonymous becomes false
  // User ID remains the same
}
const { data, error } = await supabase.auth.linkIdentity({
  provider: 'google'
})

After linking, the user's is_anonymous claim becomes false, but their user ID stays the same. All data associated with that user ID is preserved—shopping carts, preferences, draft content, everything.

Handle Email Verification

When linking an email identity, the user must verify their email. Handle this in your UI:

const { data, error } = await supabase.auth.updateUser({
  email: '[email protected]'
})

if (!error) {
  // Email verification sent
  // Show message to check inbox
}

Security Considerations

Anonymous sign-ins don't decrease your project's security, but they require careful RLS policies.

Audit Your Existing Policies

Before enabling anonymous sign-ins, review every RLS policy that grants access to authenticated users. Ask: "Should anonymous users have this access?"

Implement Rate Limits

Beyond CAPTCHA, consider application-level rate limits:

-- Example: Limit anonymous users to 10 cart items
create policy "Anonymous cart item limit"
on cart_items for insert
to authenticated
with check (
  (auth.jwt()->>'is_anonymous')::boolean is false
  OR
  (select count(*) from cart_items where user_id = auth.uid()) < 10
);

Monitor Anonymous User Growth

Track anonymous user creation to detect abuse:

-- Count anonymous users created in last 24 hours
select count(*) 
from auth.users 
where is_anonymous = true 
and created_at > now() - interval '24 hours';

Set up alerts if this number grows unexpectedly.

Consider Expiration

Anonymous sessions can accumulate. Consider a cleanup job for stale anonymous users who never convert:

-- Delete anonymous users with no activity for 30 days
delete from auth.users
where is_anonymous = true
and last_sign_in_at < now() - interval '30 days';

Run this carefully—ensure you're not deleting users with valuable associated data.

Managing Anonymous Sign-Ins with Supascale

Editing environment variables and restarting containers works, but Supascale provides a cleaner approach for managing authentication settings across your self-hosted instances.

With Supascale's auth configuration UI, you can:

  • Toggle anonymous sign-ins without editing YAML
  • Configure CAPTCHA through a visual interface
  • Monitor user growth including anonymous vs. permanent breakdowns
  • Manage multiple projects with different auth configurations

For teams running multiple self-hosted Supabase instances, this eliminates configuration drift and reduces operational overhead. Check our pricing page to see how Supascale fits your deployment needs.

Troubleshooting

signInAnonymously Returns Error

Check that GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED is set to "true" (string, not boolean) and that you've restarted the auth container.

RLS Policies Block Anonymous Users

Remember that anonymous users use the authenticated role. If policies check for specific user metadata or identities, anonymous users will fail those checks.

CAPTCHA Token Invalid

Verify your CAPTCHA secret key is correctly set in the environment variables. The site key goes in your frontend; the secret key goes in GoTrue configuration.

Users Can't Convert to Permanent

Ensure GOTRUE_SECURITY_MANUAL_LINKING_ENABLED is set to "true". Without this, identity linking is disabled.

Summary

Anonymous sign-ins for self-hosted Supabase require:

  1. Enable the feature: Set GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: "true"
  2. Review RLS policies: Anonymous users are authenticated—ensure your policies handle the is_anonymous claim appropriately
  3. Add CAPTCHA protection: Prevent bot abuse with hCaptcha or Turnstile
  4. Enable identity linking: Let users convert to permanent accounts
  5. Monitor and clean up: Track anonymous user growth and expire stale sessions

Anonymous authentication is powerful for reducing signup friction while maintaining a real user model. The configuration is straightforward once you understand GoTrue's environment variable patterns.

Further Reading