Milestone 1: Supabase auth and session handling
Unit 25 · Project 5, Milestone 1. This milestone stands up the Supabase client and real email authentication with a persisted session. Milestone 2 builds the Postgres feed on top of the authenticated user.
Authentication is where a backend app earns trust, and it's also where a lot of apps quietly get it
wrong — storing tokens by hand, forgetting to restore the session on launch, leaking secrets. The
supabase-swift client handles the hard parts (token refresh, secure storage) if you let it. Your
job is to wrap it behind a protocol, drive it from a @MainActor view model, and observe the
session so the rest of the app can react to who's signed in.
The client
Add supabase-swift via Swift Package Manager, then create one shared client configured with your
project URL and the anon (public) key. The anon key is safe to ship — it grants only what your
RLS policies allow (which is why Milestone 2's policies matter so much). The service-role key is
not; it never goes in the app.
import Supabase enum Backend { static let client = SupabaseClient( supabaseURL: URL(string: "https://YOUR-PROJECT.supabase.co")!, supabaseKey: "YOUR-ANON-KEY" ) }
Anon key yes, service key never. The anon key is designed to be public and is constrained by RLS. The service-role key bypasses RLS entirely — it belongs on a server, never in a mobile app and never in a git repo. If an AI-proposed snippet pastes a service key into the client, reject it in review.
An auth service behind a protocol
Same discipline as Reeler's FilmAPIClient: define the capability as a protocol so the view model
can be tested against a fake, and so swapping backends later is a seam, not a rewrite.
import Supabase struct AppUser: Identifiable, Sendable, Equatable { let id: UUID let email: String } protocol AuthService: Sendable { var currentUser: AppUser? { get async } func signUp(email: String, password: String) async throws -> AppUser func signIn(email: String, password: String) async throws -> AppUser func signOut() async throws } struct SupabaseAuthService: AuthService { let client: SupabaseClient var currentUser: AppUser? { get async { guard let session = try? await client.auth.session else { return nil } return AppUser(id: session.user.id, email: session.user.email ?? "") } } func signUp(email: String, password: String) async throws -> AppUser { let response = try await client.auth.signUp(email: email, password: password) return AppUser(id: response.user.id, email: response.user.email ?? "") } func signIn(email: String, password: String) async throws -> AppUser { let session = try await client.auth.signIn(email: email, password: password) return AppUser(id: session.user.id, email: session.user.email ?? "") } func signOut() async throws { try await client.auth.signOut() } }
The client persists the session in the Keychain and refreshes tokens on its own. You never store a
token by hand — asking for client.auth.session returns a valid one or throws if there's none.
The auth view model
Drive sign-in from a @MainActor @Observable view model with the same state discipline as before.
import Observation @Observable @MainActor final class AuthViewModel { enum Status: Sendable { case unknown, signedOut, signedIn(AppUser), working } private(set) var status: Status = .unknown var email = "" var password = "" private let auth: AuthService init(auth: AuthService) { self.auth = auth } func restore() async { if let user = await auth.currentUser { status = .signedIn(user) } else { status = .signedOut } } func signIn() async { status = .working do { let user = try await auth.signIn(email: email, password: password) status = .signedIn(user) } catch { status = .signedOut // surface an error message in a real build } } func signOut() async { try? await auth.signOut() status = .signedOut } }
restore() runs once at launch to turn .unknown into a real answer. The app's root switches on
status: .unknown shows a splash, .signedOut shows the sign-in screen, .signedIn shows the
feed.
@main struct SnapgramApp: App { @State private var auth = AuthViewModel(auth: SupabaseAuthService(client: Backend.client)) var body: some Scene { WindowGroup { Group { switch auth.status { case .unknown: ProgressView() case .signedOut, .working: SignInScreen(model: auth) case .signedIn: FeedScreen() // built next milestone } } .task { await auth.restore() } } } }
Restore on launch, always. The single most common auth bug is forgetting restore() — the
user signed in yesterday, the session is valid in the Keychain, but the app boots straight to the
sign-in screen because nothing asked. Reading the session at launch and branching on it is what
makes "stay signed in" work.
On the web with supabase-js you'd call supabase.auth.getSession() on load and subscribe with
onAuthStateChange. The Swift client mirrors this: client.auth.session is getSession, and the
.task { await auth.restore() } at the root is your "on load." The mental model transfers directly
— same auth service, different client.
Checkpoint
Your app should now: sign a new user up, sign an existing user in, persist the session across a relaunch (kill and reopen — you stay signed in), and sign out back to the sign-in screen. Confirm a new user appears in the Supabase dashboard's Authentication tab.
Stretch: add a SignUpScreen and surface auth errors (wrong password, duplicate email) as a
message on the view model instead of silently returning to .signedOut.
Knowledge check
Q: Why is the anon key safe to ship but the service-role key is not? The anon key is constrained by Row-Level Security — it can only do what your policies allow. The service-role key bypasses RLS entirely, so it can read and write any row; it belongs on a server, never in a shipped app.
Q: Why call restore() in a .task at the app root?
To read the persisted session at launch and decide whether the user is already signed in. Without
it the app can't distinguish "no session" from "haven't checked yet," and a returning user with a
valid session gets dumped on the sign-in screen.