BUILT INTO EVERY GOODSPEED APP
Anonymous Auth with Upgrade Path
signInAnonymously returns a real Supabase user in one round-trip, so people use the app before any signup. On register, upgradeAnonymousAccount promotes the same UUID and a server-side Edge Function re-points the user's rows across an allowlisted set of tables, recorded idempotently in anonymous_migrations.
- Tier: Common
- Status: Config-toggled
- Config: features.anonymousAuth.enabled
WHY IT MATTERS
The surest way to lose a new user is a signup wall before they have seen anything worth signing up for. Anonymous auth removes it. signInAnonymously() in services/auth.ts returns a real Supabase user backed by a UUID and a valid JWT in one round-trip, not a fake guest state, so the user can create data, set preferences, and use the app immediately under standard RLS. Because the anonymous user is a normal authenticated principal, policies written with auth.uid() apply unchanged, so you maintain one access-control path rather than a separate guest code path that drifts out of sync with the real one.
When the user decides to register, upgradeAnonymousAccount() promotes the same record in place: email and password go through supabase.auth.updateUser, OAuth goes through linkIdentity, and the UUID stays stable so nothing they created is orphaned. It then calls the migrate_anonymous_data Edge Function, which runs a security-definer RPC that re-points rows from the anonymous id to the permanent id across the tables you list in gasConfig.features.anonymousAuth.tables, intersected with a hardcoded server-side allowlist so an arbitrary table cannot be touched. Every attempt is written to the anonymous_migrations table with a status and a per-table row count, and the handler checks that ledger first, so a retried or duplicated upgrade returns the existing result instead of migrating twice. An email that already has an account returns a conflict rather than throwing, so your UI can route the user to sign in instead.
HOW IT IS WIRED
Real code from the GAS template
The code below is drawn from services/auth.ts in the gas-template repository. This is the code your generated app gets, not pseudocode, not a description of intent.
// services/auth.ts (anonymous -> permanent upgrade, condensed)
export async function upgradeAnonymousAccount(c: UpgradeCredentials): Promise<UpgradeResult> {
const { data, error } = await supabase.auth.getUser();
if (error || !data.user) throw new ServiceError('not_authenticated', 401, 'No user');
if (!data.user.is_anonymous) throw new ServiceError('not_anonymous', 400, 'Not anonymous');
const anonUserId = data.user.id;
return retryWithBackoff(async () => {
// Promote the SAME user: email/password via updateUser (shown), OAuth via linkIdentity.
// The UUID is preserved; an existing email returns { migrated: 0, conflictWith }.
const { data: updated } = await supabase.auth.updateUser({
email: c.email,
password: c.password,
});
const permanentUserId = updated.user!.id;
// Re-point rows anon -> permanent across the allowlisted tables, server-side.
const tables = gasConfig.features.anonymousAuth?.tables ?? [];
const { table_rowcounts } = await callEdge<MigrateResponse>('migrate_anonymous_data', {
anonUserId,
permanentUserId,
tables,
});
const migrated = Object.values(table_rowcounts ?? {}).reduce((s, n) => s + n, 0);
return { migrated, perTableRowcounts: table_rowcounts };
}, { shouldRetry: isTransientNon4xxError });
}Source: goodspeed-apps/gas-template → services/auth.ts
HONEST LIMITS
When Anonymous Auth with Upgrade Path is the wrong choice
Anonymous continuity stops at the device boundary. An anonymous session is a device-local JWT with its own UUID, so a user who starts on a phone and then opens the app on a tablet before upgrading has two unrelated anonymous accounts, and there is no built-in way to merge or transfer the first device's data to the second. If cross-device continuity before signup matters for your product, you need an explicit account or a hand-off code earlier in the flow; the anonymous path assumes a single device until the user upgrades. The feature also does not clean up after abandoned anonymous accounts. The anonymous_migrations table records upgrade attempts only, and nothing in the template expires or deletes anonymous users who never register, so a long-lived app accumulates auth.users rows with is_anonymous true over time. Build a scheduled cleanup that deletes stale anonymous accounts on whatever retention policy fits your data rules, rather than assuming the template prunes them for you.
Tier: Common · Config-toggled
Evaluate your use case
Check whether anonymous auth with upgrade path aligns with your target audience, platform constraints, and regulatory environment before enabling it.
Audit the config
The `features.anonymousAuth.enabled` flag controls this feature. Set it to false in gas.config.ts to disable the feature entirely with no residual code paths.
Seek alternatives
If the built-in implementation does not fit, the generated codebase is standard React Native + Expo code. Any library in the Expo ecosystem can replace the default.
APPS USING THIS FEATURE
Every generated Goodspeed app includes anonymous auth with upgrade path. Browse the ideas catalog to see apps across all categories that ship with this feature wired in.
CAPABILITIES
Anonymous Auth with Upgrade Path capability breakdown
Concrete dimensions of what the built-in anonymous auth with upgrade path implementation covers. These reflect the actual template code, not a marketing summary.
| Item | Description | Strength |
|---|---|---|
| Instant session | signInAnonymously() returns a UUID-backed Supabase user with a valid JWT in one round-trip, a real authenticated principal rather than a client-only guest flag. | One call |
| Zero-signup onboarding | Users create data and set preferences before they ever see a signup screen, since the anonymous session works under the same RLS as a registered user. | No wall |
| In-place upgrade | upgradeAnonymousAccount promotes the same record via updateUser (email) or linkIdentity (OAuth), keeping the UUID stable so existing rows stay attached to the user. | Same UUID |
| Allowlisted migration | A security-definer RPC re-points rows from the anon id to the permanent id only across tables in both your config list and a hardcoded server allowlist; an unlisted table is rejected. | Server-gated |
| Idempotent ledger | anonymous_migrations records each attempt with status and per-table counts; the handler returns an existing completed record instead of migrating a second time on retry. | No double-apply |
COMMON QUESTIONS
Is all of a user's data preserved when they upgrade?
Only the rows in tables that are both listed in gasConfig.features.anonymousAuth.tables and present in the server-side allowlist are re-assigned to the permanent user. Rows in tables you did not list keep the old anonymous UUID and become unreachable from the new account, so be deliberate about which tables to include. The upgrade returns a per-table row count in perTableRowcounts, so your UI can confirm exactly what moved and surface a problem if a table you expected shows zero.
What happens if the user upgrades to an email that already has an account?
upgradeAnonymousAccount detects the Supabase conflict (email_exists, user_already_exists, or an HTTP 422 from updateUser) and returns { migrated: 0, conflictWith: email } without throwing. No migration runs. The useAnonymousMigration hook surfaces this as a conflict so your screen can tell the user that account already exists and route them to sign in. Their anonymous data stays attached to the anonymous user, so you can decide whether to offer a manual merge after they authenticate with the existing account.
Could a retry or network blip migrate the same user twice?
No. The migrate_anonymous_data handler checks the anonymous_migrations table for an existing completed record matching the anonymous and permanent user pair before it inserts a new one. If a completed record exists, it returns those table_rowcounts immediately without calling the RPC again. That idempotency gate means a retried request after a dropped connection re-reads the prior result rather than re-running the row re-assignment, so duplicate writes do not happen.
What RLS applies to anonymous users?
Anonymous users carry the authenticated role with a valid JWT, so RLS policies that use auth.uid() apply to them exactly as they do for registered users. There is no separate anonymous code path in your policies. The anonymous_migrations table itself restricts reads to the permanent user via a policy keyed on permanent_user_id = auth.uid() and allows writes only from service_role. You are responsible for making sure your own tables' policies let an anonymous UUID insert and read what it should, since the template does not add anonymous-specific policies to your data tables.
GET IT BUILT INTO YOUR APP