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' }), withshouldCreateUser: falsekeeping 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 (requiresenable_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 = trueand offerlinkIdentity()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
updateUserphone-change + phone-OTP login +linkIdentityflows 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 = false — shouldCreateUser: 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 = truelocally; the Phone provider toggle in the prod dashboard) — phone login works: send 200,verifyOtp(sms)issues a session. handle_new_userrejects email-less signups (migration20260710150000) — a raw-APIPOST /otpwithcreate_user: truefails loudly inside the insert and leaves zero rows inauth.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 returnsotp_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