Skip to content

ADR-022: Phone as a Supabase-native login + recovery factor

Status

Accepted (2026-06-19). Amended 2026-06-20 (see Amendment). Supersedes account-dedup-merge spec Decisions 1 and 5, and reverses its "phone is an attribute, not a credential / don't enable Supabase phone-auth / no manual linking" constraints. The spec's Part B (manual remediation of existing duplicates) is unchanged.

Context

The account-dedup-merge spec scoped the problem to Apple "Hide My Email" forking new accounts, and chose to verify phone ourselves via Twilio Verify, treat phone as a non-login attribute, dedup via an app-level partial unique index, and not enable Supabase phone-auth. Phases 0–2 built that: PhoneVerificationService (Twilio Verify wrapper), PhoneIdentityService (raw-SQL dedup), and /api/auth/phone/{start,check}-verification routes.

Reframing (Aaron, 2026-06-19): the dominant pain isn't only Apple relay — it's users forgetting which email they signed up with, or losing access to that email, then being locked out (and sometimes making a second account). That's an identity-recovery problem, not just a dedup problem. For that, phone-as-login and provider linking are the right patterns.

Supabase supports these natively (docs verified June 2026):

  • Phone uniqueness is enforced at GoTrue — one account per phone. A built-in dedup guard, stronger than an app-level index.
  • Attach a phone to an existing email/OAuth account: updateUser({ phone }) → SMS OTP → verifyOtp({ type: 'phone_change' }).
  • Phone-OTP login: signInWithOtp({ phone })verifyOtp({ type: 'sms' }), with shouldCreateUser: false keeping it login-only.
  • Twilio is a supported Supabase SMS provider (Supabase drives the OTP; we don't call Twilio ourselves).
  • Manual OAuth identity linking: linkIdentity() attaches Apple/Google to the logged-in user (requires enable_manual_linking = true). OAuth-only; cannot merge two pre-existing accounts.

Our supabase/config.toml today: enable_manual_linking = false, [auth.sms] enable_signup = false.

Decision

Treat verified phone as a Supabase-native login + recovery factor and the dedup key, not a self-managed attribute.

  • Enable Supabase phone auth with Twilio as the SMS provider. Attach phone to accounts via the native phone-change flow at profile completion.
  • Offer phone-OTP login (shouldCreateUser: false) as the "can't access your email?" recovery path. Keep [auth.sms] enable_signup = false — phone is a login/recovery method for existing accounts, not a signup method (you still sign up with email/OAuth).
  • Lean on GoTrue's native phone uniqueness for dedup. Drop the planned app-level partial unique index, the self-managed Twilio Verify service, and the raw-SQL dedup query.
  • Set enable_manual_linking = true and offer linkIdentity() so members can consolidate Apple/Google onto one account going forward.
  • Number recycling is out of scope (Aaron, 2026-06-19): the chance a new member inherits a prior member's number is negligible at our scale; handle ad hoc if it ever happens.
  • Existing duplicate clusters are unchanged — Supabase still cannot merge two pre-existing accounts, so they're resolved by manual, case-by-case remediation (spec Part B).

Alternatives rejected.

  • Self-managed Twilio Verify + app-level dedup index (the prior approach, Phases 0–2) — reimplements uniqueness Supabase enforces for free and gives no recovery login; it only solved the narrow dedup problem.
  • Phone as a non-login attribute — doesn't address the forgot-email / lost-email recovery pain, which is the larger problem.
  • Native account merge for existing dupes — not available in Supabase; existing clusters still need manual remediation.

Consequences

Benefits

  • Solves the real problem: a member who forgets their email or loses access to it can get back in via phone-OTP.
  • Dedup is enforced by the platform — simpler and stronger than an app index, and less bespoke code to own (drop PhoneVerificationService, PhoneIdentityService, the app index).
  • linkIdentity() lets members consolidate providers, reducing future fragmentation.

Tradeoffs

  • Phone becomes a login vector → SIM-swap / SMS interception is now an account-takeover path (it wasn't with email/OAuth-only). Mitigate with rate-limiting and the provider's fraud signals. Scale-independent; the main thing we're accepting.
  • Touches the core auth path (config.toml, the auth-sync webhook, handle_new_user, JWT) — needs careful testing against a real Supabase flow before prod.
  • Reworks committed Phase 0–2 code (see the plan's keep/discard). Accepted — nothing is merged.

Risks

  • The auth-config change is broad; verify the updateUser phone-change + phone-OTP login + linkIdentity flows end to end before shipping.
  • Apple sign-in still creates a fork (non-matching email); phone-login lets the user reach their real account, and the empty fork is cleaned via remediation / catch-and-clean. The fork itself is not prevented.

Amendment (2026-06-20): partial index as a backstop

Reinstating a thin partial unique index users(phone) WHERE phone_verified_at IS NOT NULL AND deleted_at IS NULL as defense-in-depth only. GoTrue's auth.users.phone uniqueness remains the enforcement mechanism; the index is a DB-level assertion at the layer the app actually reads (public.users). Its only job: catch a verified phone that reaches public.users out of band — a future staff "verify by phone" action, a trust-the-legacy-member backfill, or a mirror-trigger bug/drift — and turn a silent verified-duplicate into a loud write failure.

This refines, not reverses, the "drop the app-level partial unique index" decision above: the index is no longer the primary dedup path (GoTrue is), only a backstop. Two facts make it cheap: it's adoptable immediately — the existing duplicate clusters are all unverified (phone_verified_at IS NULL), so they don't violate a verified-scoped partial index — and GoTrue blocks a second account from ever verifying a taken number, so backfill can't create a violation either. The old plan's Phase 5 → Phase 6 ordering dependency ("clean prod before the index") therefore does not apply to this partial index.

Amendment (2026-07-10): the phone provider must be ON; email-required moves to handle_new_user

The decision above said "keep [auth.sms] enable_signup = falseshouldCreateUser: false keeps it login-only." That rested on a wrong model of the toggle: it maps to GOTRUE_EXTERNAL_PHONE_ENABLED, which gates the entire /otp endpoint. With it off, phone-OTP login is dead (400 phone_provider_disabled, the "Unsupported phone provider" error) — only the phone_change verify path works. Confirmed empirically against the local stack.

Corrected mechanism (all three layers verified live 2026-07-10):

  • Phone provider enabled (enable_signup = true locally; the Phone provider toggle in the prod dashboard) — phone login works: send 200, verifyOtp(sms) issues a session.
  • handle_new_user rejects email-less signups (migration 20260710150000) — a raw-API POST /otp with create_user: true fails loudly inside the insert and leaves zero rows in auth.users/public.users. Email-always-required is now enforced at the DB layer, where no client or config can bypass it.
  • Client sends shouldCreateUser: false — an unknown number returns otp_disabled ("Signups not allowed for otp"), preserving the friendly no-account UX.

This changes the activation checklist: the prod dashboard step is enable the Phone provider (with Twilio Verify + confirmations), not "keep phone signups off" — the guard owns that invariant.

See Also

  • docs/features/account-dedup-merge/spec.md — design intent (Part B remediation unchanged; Part A/Constraints/Decisions 1 & 5 being rewritten to this ADR)
  • docs/engineering/domains/users.md — identity layer, auth-sync trigger
  • Supabase: phone login, identity linking