Milestone 1: Supabase auth and session handlingswift-6.4/ios-26
Lesson 2 / 7
Unit 25 · Snapgram

Milestone 1: Supabase auth and session handling

Coming from
Not covered in this lesson — showing TypeScript.
Note

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"
    )
}
Note

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() }
        }
    }
}
Tip

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.

Coming from TypeScript

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.