NewAppLander — App landing pages in 60s$69$39
The Swift Kit logoThe Swift Kit
Tutorial

Supabase SwiftUI Tutorial: Auth, Database & Sign in with Apple

Complete tutorial for integrating Supabase with SwiftUI for authentication and database.

Ahmed GaganAhmed Gagan
8 min read

Learn how to integrate Supabase with SwiftUI for authentication (including Sign in with Apple), Postgres database access with Row Level Security, and file storage. This Supabase SwiftUI tutorial covers everything indie iOS developers need. I have used Supabase as my primary backend for 3 production apps, and this is the exact setup I follow every time.

Why Supabase for iOS Development

Supabase is not just "open-source Firebase." That comparison undersells what makes it genuinely better for many use cases, especially iOS apps. Here is why I chose it over Firebase and have not looked back:

  • PostgreSQL under the hood — You get a real relational database with joins, foreign keys, indexes, views, and full SQL support. Firestore's document model works for simple apps, but the moment you need to query "all users who subscribed in the last 30 days and have more than 5 posts," you will regret not having SQL.
  • Row Level Security (RLS) — Instead of writing security rules in a proprietary language, you write standard SQL policies. These run at the database level, which means they apply whether you access data from your iOS app, a web dashboard, or a server function. One set of rules, enforced everywhere.
  • Real-time subscriptions — Supabase Realtime lets you listen to database changes over WebSockets. Insert a row from one device, and another device sees it instantly. This is built on PostgreSQL's WAL (Write-Ahead Log), so it is reliable and performant.
  • Open source — The entire Supabase stack is open source. You can self-host it, inspect the code, and you are never locked in. If Supabase the company disappeared tomorrow, you would still have a PostgreSQL database you can take anywhere.
  • Generous free tier — Two free projects with 500 MB database, 1 GB storage, 50,000 monthly active users for auth, and 500,000 Edge Function invocations. That covers most indie apps comfortably.
  • Native Swift SDK — The supabase-swift package is first-party, well-maintained, and uses modern Swift concurrency. It is not a community wrapper — the Supabase team builds and supports it directly.

Architecture Overview: How Supabase Connects to SwiftUI

Understanding the architecture will save you hours of confusion. Here is how the pieces fit together:

  1. Supabase Project — A hosted PostgreSQL database, an Auth server, a Storage server, a Realtime server, and an auto-generated REST API (PostgREST). Each project gets a unique URL and API keys.
  2. Anon Key — A public key that identifies your project. It is safe to ship in your iOS binary. It does not grant admin access — all data access is filtered through RLS policies.
  3. Service Role Key — A secret key that bypasses RLS. Never put this in your iOS app. Use it only in server-side Edge Functions or your own backend.
  4. Swift SDK — The supabase-swift package provides a SupabaseClient that handles auth, database queries, storage, and real-time subscriptions. It manages JWT tokens, automatic refresh, and session persistence internally.
  5. Your SwiftUI App — Calls the SupabaseClient to authenticate users, read/write data, and upload files. The client handles all HTTP requests, token injection, and error parsing.

Every request from your app goes: SwiftUI View → SupabaseClient → Supabase REST API → PostgreSQL (with RLS). Auth tokens are injected automatically by the SDK. You never manually manage headers or tokens.

Step 1: Setting Up a Supabase Project

Go to supabase.com and create a new project. Choose a region close to your users (I recommend US East for North American apps, EU West for European apps). Set a strong database password — you will need it if you ever connect directly via psql or a migration tool.

Once the project is created (takes about 2 minutes), go to Settings → API and copy two values:

  • Project URL — Looks like https://abcdefghijkl.supabase.co
  • Anon public key — A long JWT string starting with eyJ...

You will paste these into your Swift code in the next step.

Step 2: Installing the Swift SDK via SPM

Open your Xcode project, go to File → Add Package Dependencies, and paste:

https://github.com/supabase/supabase-swift.git

Set the dependency rule to Up to Next Major Version from 2.0.0. Add theSupabase library to your app target. This single import gives you auth, database, storage, real-time, and functions.

Step 3: Configuring the Client

Create a singleton that the rest of your app can access. I prefer a dedicated file for this:

// SupabaseManager.swift
import Supabase

let supabase = SupabaseClient(
    supabaseURL: URL(string: "https://YOUR_PROJECT_ID.supabase.co")!,
    supabaseKey: "YOUR_ANON_KEY"
)

// If you need custom options (e.g., custom headers, session storage):
let supabaseWithOptions = SupabaseClient(
    supabaseURL: URL(string: "https://YOUR_PROJECT_ID.supabase.co")!,
    supabaseKey: "YOUR_ANON_KEY",
    options: .init(
        auth: .init(
            storage: KeychainLocalStorage(), // Custom storage
            flowType: .pkce
        )
    )
)

Important: The anon key is safe to include in your app binary. It is not a secret — it identifies your project but does not bypass security. All data access is controlled by RLS policies. The service role key, on the other hand, must never appear in client code.

Step 4: Email/Password Authentication

Supabase supports email/password auth out of the box. Here is the full implementation for sign up, sign in, and sign out:

// AuthService.swift
import Supabase

final class AuthService: ObservableObject {
    @Published var session: Session?
    @Published var isLoading = false
    @Published var errorMessage: String?

    init() {
        Task { await listenForAuthChanges() }
    }

    func signUp(email: String, password: String) async {
        isLoading = true
        errorMessage = nil
        do {
            try await supabase.auth.signUp(
                email: email,
                password: password
            )
            // User is signed up but may need email confirmation
            // depending on your Supabase auth settings
        } catch {
            errorMessage = error.localizedDescription
        }
        isLoading = false
    }

    func signIn(email: String, password: String) async {
        isLoading = true
        errorMessage = nil
        do {
            let session = try await supabase.auth.signIn(
                email: email,
                password: password
            )
            self.session = session
        } catch {
            errorMessage = error.localizedDescription
        }
        isLoading = false
    }

    func signOut() async {
        do {
            try await supabase.auth.signOut()
            session = nil
        } catch {
            errorMessage = error.localizedDescription
        }
    }

    private func listenForAuthChanges() async {
        for await (event, session) in supabase.auth.authStateChanges {
            await MainActor.run {
                self.session = session
            }
            if event == .signedIn {
                // User signed in — update UI, fetch profile, etc.
            } else if event == .signedOut {
                // User signed out — clear local data
            }
        }
    }
}

Step 5: Sign in with Apple via Supabase

This is the auth method most iOS users expect. Supabase handles the token exchange with Apple's servers, so you only need to present Apple's native sign-in button and pass the credential. Here is the full implementation with nonce generation:

// AppleSignInService.swift
import AuthenticationServices
import CryptoKit
import Supabase

final class AppleSignInService: NSObject {
    private var currentNonce: String?

    func generateNonce() -> String {
        let nonce = UUID().uuidString + UUID().uuidString
        currentNonce = nonce
        return nonce
    }

    func sha256(_ input: String) -> String {
        let data = Data(input.utf8)
        let hash = SHA256.hash(data: data)
        return hash.map { String(format: "%02x", $0) }.joined()
    }

    func handleSignIn(
        result: Result<ASAuthorization, Error>
    ) async throws -> Session {
        switch result {
        case .success(let authorization):
            guard let credential = authorization.credential
                as? ASAuthorizationAppleIDCredential,
                  let identityToken = credential.identityToken,
                  let tokenString = String(data: identityToken, encoding: .utf8)
            else {
                throw AuthError.invalidCredential
            }

            let session = try await supabase.auth.signInWithIdToken(
                credentials: .init(
                    provider: .apple,
                    idToken: tokenString,
                    nonce: currentNonce
                )
            )
            return session

        case .failure(let error):
            throw error
        }
    }
}

enum AuthError: LocalizedError {
    case invalidCredential
    var errorDescription: String? { "Invalid Apple credential." }
}

// SwiftUI View:
struct AppleSignInButton: View {
    @StateObject private var appleService = AppleSignInService()
    @State private var errorMessage: String?

    var body: some View {
        SignInWithAppleButton(.signIn) { request in
            let nonce = appleService.generateNonce()
            request.requestedScopes = [.fullName, .email]
            request.nonce = appleService.sha256(nonce)
        } onCompletion: { result in
            Task {
                do {
                    let session = try await appleService.handleSignIn(result: result)
                    // session is active, user is signed in
                } catch {
                    errorMessage = error.localizedDescription
                }
            }
        }
        .signInWithAppleButtonStyle(.white)
        .frame(height: 50)
    }
}

For a deeper dive into Sign in with Apple, check out the Sign in with Apple SwiftUI tutorial.

Step 6: Creating and Querying Tables

First, create a table in the Supabase dashboard (SQL Editor or Table Editor). Here is an example profiles table:

-- SQL to create a profiles table
CREATE TABLE profiles (
    id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
    username TEXT UNIQUE NOT NULL,
    display_name TEXT,
    avatar_url TEXT,
    bio TEXT,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Enable RLS
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;

Now create a matching Swift struct and query it:

// Profile.swift
import Foundation

struct Profile: Codable, Identifiable {
    let id: UUID
    var username: String
    var displayName: String?
    var avatarUrl: String?
    var bio: String?
    let createdAt: Date?

    enum CodingKeys: String, CodingKey {
        case id, username, bio
        case displayName = "display_name"
        case avatarUrl = "avatar_url"
        case createdAt = "created_at"
    }
}

// CRUD operations:
final class ProfileService {

    // Fetch current user's profile
    func fetchProfile(userId: UUID) async throws -> Profile {
        return try await supabase
            .from("profiles")
            .select()
            .eq("id", value: userId)
            .single()
            .execute()
            .value
    }

    // Update profile
    func updateProfile(_ profile: Profile) async throws {
        try await supabase
            .from("profiles")
            .update(profile)
            .eq("id", value: profile.id)
            .execute()
    }

    // Insert new profile (e.g., after sign up)
    func createProfile(_ profile: Profile) async throws {
        try await supabase
            .from("profiles")
            .insert(profile)
            .execute()
    }

    // Fetch all profiles (e.g., for a leaderboard)
    func fetchAllProfiles(limit: Int = 50) async throws -> [Profile] {
        return try await supabase
            .from("profiles")
            .select()
            .order("created_at", ascending: false)
            .limit(limit)
            .execute()
            .value
    }
}

Step 7: Row Level Security Explained Simply

RLS is the single most important concept in Supabase. Without it, anyone with your anon key can read and write every row in your database. RLS is your security layer. Think of it as a filter that runs on every query: "Is this user allowed to see/modify this row?"

Here are the actual SQL policies I use for a profiles table:

-- Users can read their own profile
CREATE POLICY "Users can read own profile"
ON profiles FOR SELECT
USING (auth.uid() = id);

-- Users can update their own profile
CREATE POLICY "Users can update own profile"
ON profiles FOR UPDATE
USING (auth.uid() = id)
WITH CHECK (auth.uid() = id);

-- Users can insert their own profile (on sign up)
CREATE POLICY "Users can create own profile"
ON profiles FOR INSERT
WITH CHECK (auth.uid() = id);

-- Public profiles: anyone can read (if you want public profiles)
CREATE POLICY "Public profiles are viewable by everyone"
ON profiles FOR SELECT
USING (true);

The auth.uid() function returns the currently authenticated user's ID from the JWT token. Supabase injects this automatically when you use the Swift SDK. If a user is not authenticated,auth.uid() returns null and the policy denies access.

Step 8: File Storage (Avatar Upload)

Supabase Storage works like S3 but with the same RLS-style policies. Here is how to upload and retrieve a user avatar:

// AvatarService.swift
import UIKit
import Supabase

final class AvatarService {

    func uploadAvatar(
        userId: UUID,
        image: UIImage
    ) async throws -> String {
        guard let data = image.jpegData(compressionQuality: 0.8) else {
            throw StorageError.invalidImage
        }

        let path = "\(userId.uuidString)/avatar.jpg"

        try await supabase.storage
            .from("avatars")
            .upload(
                path: path,
                file: data,
                options: .init(
                    contentType: "image/jpeg",
                    upsert: true // Overwrite if exists
                )
            )

        // Get public URL
        let publicURL = try supabase.storage
            .from("avatars")
            .getPublicURL(path: path)

        return publicURL.absoluteString
    }

    func getAvatarURL(userId: UUID) -> URL? {
        let path = "\(userId.uuidString)/avatar.jpg"
        return try? supabase.storage
            .from("avatars")
            .getPublicURL(path: path)
    }
}

enum StorageError: LocalizedError {
    case invalidImage
    var errorDescription: String? { "Could not convert image to JPEG." }
}

Before this works, create a storage bucket called "avatars" in the Supabase dashboard (Storage tab), and add a policy that lets authenticated users upload to their own folder.

Supabase Free Tier Limits

Here is what you get on the free plan. For most indie apps, this is more than enough:

ResourceFree Tier LimitPro Plan ($25/mo)
Database size500 MB8 GB (then $0.125/GB)
File storage1 GB100 GB (then $0.021/GB)
Monthly active users (auth)50,000100,000 (then $0.00325/user)
Edge Function invocations500,000/month2,000,000/month
Realtime concurrent connections200500 (then $10/1000)
API requestsUnlimitedUnlimited
Projects2Unlimited
Branching (preview environments)Not availableAvailable
Daily backupsNot available7-day retention

SQL Types to Swift Types Mapping

When you create tables in Supabase and model them in Swift, you need to know how PostgreSQL types map to Swift types. Here is the reference I keep open while coding:

PostgreSQL TypeSwift TypeNotes
text / varcharStringUse text for most strings
uuidUUIDFoundation.UUID works directly
int4 / integerInt32-bit integer
int8 / bigintInt64Use for large IDs or counts
float8 / double precisionDouble64-bit floating point
boolBoolDirect mapping
timestamptzDateAuto-decoded with ISO 8601
jsonbCodable struct or [String: Any]Nested JSON objects
text[][String]Array types
numeric / decimalDecimalUse for currency

Pro tip: Always use CodingKeys in your Swift structs to map between PostgreSQL's snake_case column names and Swift's camelCase property names. The Supabase SDK does not auto-convert these.

Error Handling Patterns

The Supabase Swift SDK throws typed errors that you should handle explicitly. Here is the pattern I use:

func fetchData() async {
    do {
        let profiles: [Profile] = try await supabase
            .from("profiles")
            .select()
            .execute()
            .value
        // Success
    } catch let error as PostgrestError {
        // Database-level error (RLS violation, constraint error, etc.)
        switch error.code {
        case "42501":
            // RLS policy violation — user does not have permission
            print("Permission denied. Check your RLS policies.")
        case "23505":
            // Unique constraint violation
            print("This record already exists.")
        default:
            print("Database error: \(error.message)")
        }
    } catch let error as AuthError {
        // Auth-level error (expired token, invalid credentials)
        print("Auth error: \(error.localizedDescription)")
    } catch {
        // Network or other error
        print("Unexpected error: \(error.localizedDescription)")
    }
}

Real-Time Subscriptions

Supabase Realtime lets your SwiftUI app react to database changes instantly. This is useful for chat apps, live dashboards, or any feature where users need to see updates from other users without pulling to refresh. Here is a basic example:

// RealtimeService.swift
import Supabase

final class RealtimeService: ObservableObject {
    @Published var messages: [Message] = []
    private var channel: RealtimeChannelV2?

    func subscribe() async {
        let channel = supabase.realtimeV2.channel("messages")

        let changes = channel.postgresChange(
            InsertAction.self,
            schema: "public",
            table: "messages"
        )

        await channel.subscribe()

        for await change in changes {
            if let message = try? change.decodeRecord(as: Message.self) {
                await MainActor.run {
                    self.messages.append(message)
                }
            }
        }
    }

    func unsubscribe() async {
        await channel?.unsubscribe()
    }
}

Migrating from Firebase to Supabase

If you are currently using Firebase and considering a switch, here is the brief migration path:

  1. Auth — Export your Firebase users (email/password) and import them into Supabase using the auth admin API. For Sign in with Apple, no migration is needed — users just sign in again and Supabase creates a new account linked to the same Apple ID.
  2. Firestore to PostgreSQL — Denormalize your Firestore documents into relational tables. This is the hardest part. Tools like firestore-to-postgres can help with the data export, but you will need to design proper table schemas with foreign keys.
  3. Storage — Download files from Firebase Storage and re-upload to Supabase Storage. Write a script that preserves the path structure.
  4. Cloud Functions to Edge Functions — Rewrite in TypeScript/Deno. The logic is usually portable; only the SDK calls change.

For a more detailed comparison, see my Supabase vs Firebase for iOS post.

Common Gotchas

These are the issues that trip up every developer the first time. Save yourself the debugging hours:

  • RLS blocking all queries — When you enable RLS on a table but do not add any policies, every query returns zero rows. It does not throw an error — it just returns empty. Always add at least a SELECT policy after enabling RLS.
  • Token refresh timing — The Supabase Swift SDK handles token refresh automatically, but if the app has been in the background for a very long time, the first request after foregrounding might fail. Handle this by catching auth errors and calling supabase.auth.refreshSession().
  • Anon key vs service role key — The anon key is for client-side code (iOS app, web browser). The service role key bypasses RLS and should only be used in server-side Edge Functions. If you accidentally ship the service role key in your app, anyone can decompile the binary and get full database access.
  • Date decoding — Supabase returns timestamps in ISO 8601 format. The Swift SDK handles this with JSONDecoder.DateDecodingStrategy.iso8601, but if you are using a custom decoder, you need to set this explicitly or dates will fail to decode.
  • Null columns and optionals — If a PostgreSQL column is nullable, the corresponding Swift property must be optional. If it is not, decoding will crash with a "key not found" error when the value is null.
  • Foreign key constraints in wrong order — If you try to insert a row that references a foreign key that does not exist yet, the insert fails. Always insert parent records before child records.

Skip the Tutorial — Use The Swift Kit

Everything I covered above — the auth service, Sign in with Apple, profile CRUD, avatar storage, RLS policies, error handling — is already built, tested, and production-ready in The Swift Kit. You get a complete Supabase integration layer with proper MVVM architecture, environment objects, and error handling wired into the UI.

Just paste your Supabase project URL and anon key into the config file, run the included SQL migration script, and you have a working backend. Check out the full feature list or see pricing to get started. Stop building backend infrastructure and start building the features your users actually care about.

Share this article

Ready to ship your iOS app faster?

The Swift Kit gives you a production-ready SwiftUI codebase with onboarding, paywalls, auth, AI integrations, and more. Stop building boilerplate. Start building your product.

Get The Swift Kit