Skip to main content
This boilerplate provides a complete authentication solution using Supabase Auth with support for:
  • Apple Sign In - Native iOS authentication
  • Google Sign In - OAuth via Google
  • Email/Password - Traditional email authentication
All authentication methods are unified through Supabase, providing a consistent user experience and automatic session management.

Features

Unified Sign In Experience

  • Single Sign In Screen: All authentication options on one screen
  • Social OAuth: Apple and Google sign in via Supabase Auth
  • Email Authentication: Sign up and sign in with email/password
  • Password Reset: Forgot password flow via email

Session Persistence

Users stay logged in automatically - No need to sign in every time The app implements robust session persistence:
  1. Keychain Storage: Auth tokens are securely stored in iOS Keychain
  2. Automatic Session Loading: When the app launches, sessions are restored from Keychain
  3. Token Validation: Expired sessions are automatically cleared
  4. Automatic Refresh: Tokens are refreshed 60 seconds before expiry
  5. Retry Logic: Failed refresh attempts retry up to 3 times with backoff

Session Lifecycle

App Launch

Load Session from Keychain

Session Valid? ──No──→ Show Sign In Screen
    ↓ Yes
Navigate to Main Screen

Schedule Token Refresh (60s before expiry)

Auto-refresh token

Continue using app

Setup

1. Configure Supabase

Enable Social Providers

  1. Go to Supabase Dashboard
  2. Select your project
  3. Navigate to AuthenticationProviders
Enable Apple:
  1. Click Apple
  2. Toggle Enable Sign in with Apple
  3. Configure:
    • Services ID: Your app’s Bundle ID
    • Team ID: Your Apple Developer Team ID
    • Key ID: From Apple Developer Portal
    • Private Key: Download from Apple Developer Portal
  4. Click Save
Enable Google:
  1. Click Google
  2. Toggle Enable Sign in with Google
  3. Configure:
  4. Add authorized redirect URIs:
    https://YOUR_PROJECT_REF.supabase.co/auth/v1/callback
    
  5. Click Save

2. Configure iOS App

Update Secrets Configuration:
# Config/Secrets.xcconfig
SUPABASE_URL = https://YOUR_PROJECT_REF.supabase.co
SUPABASE_ANON_KEY = YOUR_PUBLIC_ANON_KEY

# For Google Sign In (optional)
GOOGLE_CLIENT_ID = YOUR_GOOGLE_CLIENT_ID
Add Google Sign In SDK (Optional): If you want to enable Google Sign In, add the GoogleSignIn package to your Package.swift:
dependencies: [
    .package(url: "https://github.com/google/GoogleSignIn-iOS", from: "7.0.0")
]

3. Setup CompositionRoot

The boilerplate comes with auth pre-configured, but here’s how it works:
// CompositionRoot.swift

// Create auth client with social providers
let authClient = SessionManager(
    httpClient: httpClient,
    keychain: keychain,
    apple: AppleSignInCoordinator(),
    google: GoogleSignInCoordinator(clientID: googleClientID), // Optional
    config: authConfig
)

Usage

Sign In Flow

The SignInView provides a premium, minimal authentication experience:
SignInView(authClient: authClient)
Design Philosophy:
  • Clean, Apple-like aesthetic with generous whitespace
  • Native Apple button (HIG compliant) as primary CTA
  • Visual hierarchy: Apple → Google → “Use email instead” link
  • Premium feel with continuous corner radii and subtle borders
  • Adapts to light/dark mode automatically
Features:
  • Apple Sign In: Native button (HIG compliant) - primary option
  • Google Sign In: Secondary button with subtle border
  • Email Sign In: Shown via “Use email instead” link (opens sheet)
  • Create Account: Accessible from email login sheet
  • Forgot Password: Accessible from email login sheet
Theme Integration: All auth screens (Sign In, Sign Up, Forgot Password) adapt to the active theme:
  • Links and accents use DSColors.accentPrimary (warm pink in Aurora, neutral in others)
  • Text colors use DSColors.textPrimary and DSColors.textSecondary
  • Backgrounds and surfaces adapt to theme palette
  • Icons inherit theme accent color
  • No hardcoded system blue - everything theme-aware
Customization: To adjust the visual design, edit SwiftAIBoilerplatePro/AppShell/SignInView.swift: Spacing & Rhythm:
  • Top space: Spacer().frame(height: 100) - Adjust for vertical positioning
  • Between hero and buttons: 64pt - Control breathing room
  • Bottom space: 80pt then 48pt - Legal section padding
Typography:
  • App title: 28pt semibold rounded - Adjust size/weight
  • Tagline: 15pt regular, 0.3 tracking, 70% opacity - Refine elegance
  • Legal text: 12pt, 55% opacity - Keep whisper-quiet
Colors & Effects:
  • Avatar blur: blur(radius: 20) on gradient circle
  • Background: Subtle top-to-bottom gradient
  • Google button: surface.opacity(0.6) with 0.5pt border
  • Link colors: Uses accentPrimary.opacity(0.7) for cohesion
Animations:
  • Hero fade-in: .easeOut(duration: 0.6) on appear
  • Buttons slide: .delay(0.15) for staggered entrance
  • Error message: .spring(response: 0.35) for smooth entry
Container Width:
  • Max width: 380pt for button container (narrower = more refined)

Check Authentication State

// Check current user
let user = await authClient.currentUser()

if let user = user {
    print("Signed in as: \(user.email ?? "unknown")")
} else {
    print("Not signed in")
}

// Observe auth state changes
for await state in authClient.authStates() {
    switch state {
    case .authenticated(let user):
        print("User signed in: \(user.id)")
    case .unauthenticated:
        print("User signed out")
    case .refreshing:
        print("Refreshing token...")
    }
}

Sign Out

try await authClient.signOut()
This will:
  1. Revoke the access token on the server
  2. Clear tokens from Keychain
  3. Clear in-memory session
  4. Navigate to sign-in screen

How It Works

Apple Sign In Flow

  1. User taps “Continue with Apple”
  2. System shows Apple Sign In sheet
  3. User authenticates with Face ID/Touch ID
  4. App receives Apple ID token + nonce
  5. Token is exchanged with Supabase for session
  6. Session stored in Keychain
  7. User navigated to main screen
// Internal flow
let (idToken, nonce) = try await apple.requestIDToken(...)
let session = try await api.exchangeAppleIDToken(idToken: idToken, nonce: nonce)
try await persistSession(session)

Google Sign In Flow

  1. User taps “Continue with Google”
  2. Google Sign In SDK presents OAuth flow
  3. User selects Google account
  4. App receives Google ID token
  5. Token is exchanged with Supabase for session
  6. Session stored in Keychain
  7. User navigated to main screen
// Internal flow
let idToken = try await google.requestIDToken()
let session = try await api.exchangeGoogleIDToken(idToken: idToken)
try await persistSession(session)

Email Sign In Flow

  1. User enters email and password
  2. Credentials sent to Supabase
  3. Supabase validates and returns session
  4. Session stored in Keychain
  5. User navigated to main screen
// Internal flow
let session = try await api.signInWithEmail(email: email, password: password)
try await persistSession(session)

Session Persistence Details

How Sessions Are Stored

Sessions are stored in iOS Keychain with the following keys:
- auth_access_token: JWT access token
- auth_refresh_token: Long-lived refresh token
- auth_expires_at: Token expiration timestamp
- auth_user_json: User profile data
Security:
  • Uses kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
  • Data is encrypted by iOS
  • Survives app updates
  • Deleted on app uninstall

Automatic Session Loading

On app launch (loadInitialSession()):
  1. Load from Keychain: Attempts to load stored session
  2. Validate Expiry: Checks if session is expired
  3. Clear if Invalid: Removes expired sessions
  4. Refresh if Needed: Proactively refreshes expiring tokens
  5. Emit State: Notifies observers of auth state
// SessionManager.swift
private func loadInitialSession() async {
    if let session = try loadSession() {
        if Date.now > session.expiresAt {
            // Session expired, clear it
            try? clearSession()
            stateContinuation?.yield(.unauthenticated)
        } else {
            // Session valid, restore it
            currentSession = session
            stateContinuation?.yield(.authenticated(session.user))
            
            // Schedule proactive refresh
            scheduleRefresh(for: session)
        }
    }
}

Token Refresh Strategy

The app uses proactive refresh to ensure tokens are always valid:
  1. Schedule Refresh: 60 seconds before token expiry
  2. Automatic Execution: Refresh happens in background
  3. Retry Logic: Up to 3 attempts with exponential backoff
  4. Token Rotation: Handles Supabase refresh token rotation
  5. Cancellation-Aware: Properly handles task cancellation
// Refresh scheduled 60 seconds before expiry
let refreshTime = session.expiresAt.addingTimeInterval(-60)
let delay = max(0, refreshTime.timeIntervalSinceNow)

refreshTask = Task {
    try await sleeper.sleep(for: delay)
    if !Task.isCancelled {
        try await refreshIfNeeded()
    }
}
The LaunchRouter automatically handles navigation:
// LaunchRouter observes auth state
for await state in authClient.authStates() {
    switch state {
    case .authenticated:
        destination = .main  // Show main app
    case .unauthenticated:
        destination = .signIn  // Show sign in
    case .refreshing:
        // Keep current screen, just refreshing token
    }
}
This means:
  • ✅ Users automatically navigate to main screen when signed in
  • ✅ Users automatically navigate to sign in when signed out
  • ✅ No manual navigation needed
  • ✅ Works for all auth methods (Apple, Google, Email)

Best Practices

Always Check Auth State

// Don't store user state yourself
// ❌ Bad
@State private var isSignedIn = false

// ✅ Good - observe auth state
for await state in authClient.authStates() {
    // React to state changes
}

Handle Sign Out Gracefully

func signOut() {
    Task {
        do {
            try await authClient.signOut()
            // Navigation handled automatically by LaunchRouter
        } catch {
            // Show error to user
            errorMessage = "Failed to sign out: \(error.localizedDescription)"
        }
    }
}

Use Session Tokens for API Calls

The Networking package automatically includes auth tokens:
// Auth tokens are automatically attached by AuthInterceptor
let request = HTTPRequest(path: "/api/protected", method: .get)
let response = try await httpClient.send(request)
// Headers include: Authorization: Bearer <access_token>

Troubleshooting

Users Have to Sign In Every Time

Check:
  1. Keychain is accessible (not disabled in Xcode capabilities)
  2. loadInitialSession() is being called
  3. No errors in SessionManager initialization
  4. Check logs for “Loaded session is expired, clearing”

Google Sign In Not Working

  1. Verify GOOGLE_CLIENT_ID is set in Secrets.xcconfig
  2. Check Google Cloud Console configuration
  3. Ensure redirect URIs match exactly
  4. Verify GoogleSignIn SDK is installed

Apple Sign In Fails

  1. Check Apple Developer Portal configuration
  2. Verify Team ID, Key ID, and Private Key are correct
  3. Ensure “Sign in with Apple” capability is enabled
  4. Check bundle ID matches Services ID

Token Refresh Fails

Check logs for:
  • Network connectivity issues
  • Supabase service status
  • Invalid refresh token (forces re-authentication)
  • Too many concurrent refresh attempts

Security Considerations

  1. Tokens in Keychain: Never store tokens in UserDefaults or files
  2. HTTPS Only: All auth requests use HTTPS
  3. Token Expiry: Access tokens expire (default: 1 hour)
  4. Refresh Rotation: Supabase rotates refresh tokens for security
  5. Server-Side Validation: Always validate tokens server-side

Next Steps

I