Authentication

Email/password, OTP verification, social sign-in, and session management.

Authentication is Integrated end to end on top of supabase-swift's Auth module. It covers email/password, email OTP, password reset, email/password changes, session management, account deletion, and native Apple + Google sign-in.

Session management

SessionController is the app's source of truth for "who is signed in". It's a @MainActor @Observable object that subscribes to the auth state stream and republishes the current user:

  • AuthRepository.sessionStream wraps supabase-swift's authStateChanges as an AsyncStream<Session?>.
  • SessionController consumes it, setting currentUser and flipping isLoading to false after the first event.
  • The Auth gate in RootView reads currentUser to decide between the auth stack and the main shell.

The session lives in the Keychain, not UserDefaults. supabase-swift's default KeychainLocalStorage owns token persistence. Never mirror auth tokens into AppPreferences / UserDefaults — the Keychain copy is authoritative and survives reinstalls per the system's Keychain policy.

Email & password flows

Every flow is a method on AuthRepositoryProtocol, returning AppResult<Void> (see Data Layer). The underlying SupabaseAuthService maps each to a supabase-swift auth call:

Flowsupabase-swift callNotes
Sign inauth.signIn(email:password:)
Sign upauth.signUp(...) + data: ["name": ...]Name is stored in user metadata
Verify sign-upauth.verifyOTP(type: .signup)6-digit email OTP
Resend codeauth.resend(type: .signup)
Forgot passwordauth.resetPasswordForEmail(...)Sends a recovery code
Complete resetverifyOTP(type: .recovery)update(password:)Two-step
Change passwordauth.update(user: .init(password:))While signed in
Change emailauth.update(user: .init(email:))Triggers confirm OTP
Confirm email changeverifyOTP(type: .emailChange)
Sign outauth.signOut()This device
Sign out othersauth.signOut(scope: .others)Keeps current device

There's also a passwordless path — sendLoginOtp / verifyLoginOtp (signInWithOTP(shouldCreateUser: false)verifyOTP(type: .magiclink)).

Retry policy

The repository codifies retry per method — this is a deliberate correctness choice, not a blanket wrapper:

  • Idempotent calls retry up to 2× (300ms apart): resendEmailOtp, resetPassword, changeEmail, signOutOtherSessions, deleteAccount.
  • Single-use / credential submissions never retry: signIn, signUp, every verify*Otp, completePasswordReset, changePassword, and the social flows. Retrying would burn a single-use OTP or trip captcha / rate-limit thresholds.

Input validation

Features/Auth/Validation/AuthValidation.swift holds pure-Swift, dependency-free validators (email, password, otp, required) returning a typed AuthValidationError. Rules: valid email regex; password ≥ 8 chars with at least one letter and one number; OTP exactly 6 digits. Being pure functions, they're unit-tested without a network or a view.

Social sign-in

The template README's "Out of scope" line is stale — both Apple and Google sign-in are fully wired in Features/Auth/Social/. Verified against code.

Both providers obtain a native ID token and hand it to Supabase via auth.signInWithIdToken(...) — no web redirect.

Apple

AppleSignInButton drives ASAuthorizationController directly (so it can render as an icon-only tile), generating a random nonce per request and sending its SHA-256 hash to Apple while forwarding the raw nonce to Supabase (OpenIDConnectCredentials(provider: .apple, idToken:, nonce:)). On first sign-in the user's name is captured into user metadata. No xcconfig key is needed — add the Sign in with Apple capability in Xcode and enable the Apple provider in Supabase. Apple is rendered first (leading) to satisfy App Store review's prominence rule.

Google

GoogleSignInButton uses the GoogleSignIn SDK (GIDSignIn.signIn(withPresenting:)) to get a Google ID token, then calls OpenIDConnectCredentials(provider: .google, idToken:). It needs GOOGLE_IOS_CLIENT_IDwithout it the tile renders dimmed and inert so the UI still composes during local development.

Setup (from the template's docs/google-sign-in-configuration.md):

Create two Google Cloud OAuth clients

A Web client (used by Supabase) and an iOS client whose Bundle ID matches com.soarstarter.SoarStarterSwift. Add https://<ref>.supabase.co/auth/v1/callback to the Web client's authorized redirect URIs.

Configure the Supabase Google provider

Under Authentication → Providers → Google: set Client IDs to a comma-separated <web-client-id>,<ios-client-id> (Web first, iOS included so the token audience is accepted), the Web client secret, and turn Skip nonce checks ON (the native flow passes no nonce).

Set the keys in Secrets.xcconfig

GOOGLE_IOS_CLIENT_ID and GOOGLE_REVERSED_CLIENT_ID (same ID, segments reversed). The project injects them into Info.plist as GIDClientID and a CFBundleURLTypes URL scheme for the OAuth callback. Rebuild after changing.

Common Google failures (audience mismatch, nonce error, no return to app) are in Troubleshooting.

Delete account

Me → Account & Security → Delete account calls the delete-account Edge Function with the user's JWT — clients can't delete their own auth.users row, only the service role can. The function, in order:

  1. Identifies the caller from the JWT (POST only, no body).
  2. Deletes the user's todos, device_tokens, and profiles rows (missing tables are skipped; subscriptions / payments cascade from auth.users).
  3. Removes the user's files under sample-uploads/<user-id>/.
  4. Calls auth.admin.deleteUser, revoking every session on every device.

Rows and files are deleted before the auth user so a mid-flight failure leaves the account intact and the call retryable. Apple requires in-app account deletion for App Store approval — see App Store Release.

On this page