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

SwiftUI Login Screen Tutorial — Email, Apple & Google Sign-In

A step-by-step guide to building a production-ready login screen in SwiftUI. Covers email/password forms with validation, Sign in with Apple, Google Sign-In SDK integration, auth state management with @Observable, dark mode theming, and the mistakes that get your app rejected.

Ahmed GaganAhmed Gagan
14 min read

TL;DR

Build a SwiftUI login screen that supports email/password, Sign in with Apple, and Google Sign-In — all in one view. This tutorial covers form validation with real-time feedback, SecureField best practices, keyboard handling, loading states, auth state routing with @Observable, dark mode theming, and accessibility. Every code block is production-ready. If you want it all pre-built, The Swift Kit ships with complete auth screens wired to Supabase.

The login screen is the front door of your app. Every single user has to walk through it before they see anything you have built. If it looks cheap, feels broken, or makes sign-in harder than it needs to be, you lose people before they ever experience your product. And yet, most indie iOS developers treat authentication UI as an afterthought — a form with two text fields and a button, thrown together on launch day. In this tutorial, I will show you how to build a login screen that is polished, accessible, and supports the three sign-in methods that matter in 2026: email/password, Sign in with Apple, and Google.

What Makes a Great SwiftUI Login Screen?

Before we write any code, let me lay out the design principles that separate a good login screen from a forgettable one. These come from building auth flows for multiple production apps and watching real user analytics.

First impressions are permanent. The login screen is often the first SwiftUI view a user sees after your onboarding flow. If you invested time in a beautiful onboarding experience but then drop the user onto a generic login form, you break the spell. The login screen should feel like a seamless continuation of your brand.

Fewer taps wins. Every extra field, toggle, or screen in your login flow increases drop-off. Data from Branch.io's 2025 Mobile Growth Report shows that adding a single extra step to authentication reduces completion rates by 10-15%. This is why social sign-in buttons (Apple, Google) should be prominent — they reduce the entire flow to a single tap.

Trust signals matter. Users are trained to be suspicious of login screens. Include visual cues that build trust: the native SignInWithAppleButton (users recognize it instantly), a visible password toggle, clear error messages, and no unnecessary data collection. If you do not need a username, do not ask for one.

  • Visual hierarchy: Social sign-in buttons first (lowest friction), email form second, sign-up link last.
  • Consistent styling: Match your app's design tokens — colors, corner radii, spacing, typography.
  • Error handling: Show inline validation errors, not alerts. Users should never wonder what they did wrong.
  • Loading states: Disable buttons and show a spinner during authentication. Never let a user tap "Sign In" twice.
  • Keyboard handling: The form should scroll or adjust when the keyboard appears. Focus should move from email to password on return key.

How Do You Build an Email Login Form in SwiftUI?

Let us start with the foundation: a clean email and password form with real-time validation, secure text entry, loading states, and proper keyboard handling. This is the most common login method and the one you will spend the most time polishing.

Here is the complete EmailLoginView:

// Views/Auth/EmailLoginView.swift
import SwiftUI

struct EmailLoginView: View {
    @State private var email = ""
    @State private var password = ""
    @State private var isLoading = false
    @State private var errorMessage: String?
    @State private var isPasswordVisible = false
    @FocusState private var focusedField: Field?

    let onLogin: (String, String) async throws -> Void

    enum Field: Hashable {
        case email, password
    }

    // MARK: - Validation

    private var isEmailValid: Bool {
        let pattern = #"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$"#
        return email.range(of: pattern, options: .regularExpression) != nil
    }

    private var isPasswordValid: Bool {
        password.count >= 8
    }

    private var canSubmit: Bool {
        isEmailValid && isPasswordValid && !isLoading
    }

    // MARK: - Body

    var body: some View {
        VStack(spacing: 16) {
            // Email field
            VStack(alignment: .leading, spacing: 6) {
                Text("Email")
                    .font(.subheadline)
                    .fontWeight(.medium)
                    .foregroundStyle(.secondary)

                TextField("you@example.com", text: $email)
                    .textContentType(.emailAddress)
                    .keyboardType(.emailAddress)
                    .autocorrectionDisabled()
                    .textInputAutocapitalization(.never)
                    .focused($focusedField, equals: .email)
                    .submitLabel(.next)
                    .onSubmit { focusedField = .password }
                    .padding(.horizontal, 16)
                    .padding(.vertical, 14)
                    .background(.ultraThinMaterial)
                    .clipShape(RoundedRectangle(cornerRadius: 12))
                    .overlay(
                        RoundedRectangle(cornerRadius: 12)
                            .stroke(
                                !email.isEmpty && !isEmailValid
                                    ? Color.red.opacity(0.5)
                                    : Color.clear,
                                lineWidth: 1
                            )
                    )

                if !email.isEmpty && !isEmailValid {
                    Text("Enter a valid email address")
                        .font(.caption)
                        .foregroundStyle(.red)
                        .transition(.opacity)
                }
            }

            // Password field
            VStack(alignment: .leading, spacing: 6) {
                Text("Password")
                    .font(.subheadline)
                    .fontWeight(.medium)
                    .foregroundStyle(.secondary)

                HStack(spacing: 0) {
                    Group {
                        if isPasswordVisible {
                            TextField("8+ characters", text: $password)
                        } else {
                            SecureField("8+ characters", text: $password)
                        }
                    }
                    .textContentType(.password)
                    .focused($focusedField, equals: .password)
                    .submitLabel(.go)
                    .onSubmit { if canSubmit { login() } }

                    Button {
                        isPasswordVisible.toggle()
                    } label: {
                        Image(systemName: isPasswordVisible
                            ? "eye.slash.fill" : "eye.fill")
                            .foregroundStyle(.secondary)
                            .frame(width: 24, height: 24)
                    }
                    .buttonStyle(.plain)
                    .accessibilityLabel(
                        isPasswordVisible ? "Hide password" : "Show password"
                    )
                }
                .padding(.horizontal, 16)
                .padding(.vertical, 14)
                .background(.ultraThinMaterial)
                .clipShape(RoundedRectangle(cornerRadius: 12))
                .overlay(
                    RoundedRectangle(cornerRadius: 12)
                        .stroke(
                            !password.isEmpty && !isPasswordValid
                                ? Color.red.opacity(0.5)
                                : Color.clear,
                            lineWidth: 1
                        )
                )

                if !password.isEmpty && !isPasswordValid {
                    Text("Password must be at least 8 characters")
                        .font(.caption)
                        .foregroundStyle(.red)
                        .transition(.opacity)
                }
            }

            // Error message
            if let errorMessage {
                Text(errorMessage)
                    .font(.caption)
                    .foregroundStyle(.red)
                    .multilineTextAlignment(.center)
                    .padding(.top, 4)
            }

            // Sign-in button
            Button(action: login) {
                Group {
                    if isLoading {
                        ProgressView()
                            .tint(.white)
                    } else {
                        Text("Sign In")
                            .fontWeight(.semibold)
                    }
                }
                .frame(maxWidth: .infinity)
                .frame(height: 50)
            }
            .buttonStyle(.borderedProminent)
            .clipShape(RoundedRectangle(cornerRadius: 12))
            .disabled(!canSubmit)
            .padding(.top, 8)
        }
        .animation(.easeInOut(duration: 0.2), value: errorMessage)
        .animation(.easeInOut(duration: 0.2), value: isEmailValid)
        .animation(.easeInOut(duration: 0.2), value: isPasswordValid)
    }

    // MARK: - Actions

    private func login() {
        guard canSubmit else { return }
        isLoading = true
        errorMessage = nil

        Task {
            do {
                try await onLogin(email, password)
            } catch {
                errorMessage = error.localizedDescription
            }
            isLoading = false
        }
    }
}

There are several deliberate choices in this code worth calling out:

  • @FocusState for keyboard flow. When the user taps Return on the email field, focus automatically moves to the password field via .submitLabel(.next) and the .onSubmit handler. When they tap Go on the password field, the form submits. This is a small detail that makes the experience feel native and fast.
  • .textContentType hints. Setting .emailAddress and .password content types triggers AutoFill and Keychain integration. Users who saved their credentials in iCloud Keychain get a one-tap fill. Skipping these modifiers is leaving conversion on the table.
  • Real-time inline validation. The red border and error text appear as the user types, but only after they have started entering content (the !email.isEmpty guard prevents errors on the blank state). This is friendlier than validating only on submit.
  • Password visibility toggle. The eye icon lets users verify their password. The accessibilityLabel ensures VoiceOver users know what the toggle does. This one button reduces mistyped-password support tickets by a surprising amount.
  • Disabled state on the button. The submit button is disabled until both fields pass validation. Combined with the loading spinner, this prevents double-submission — a common source of auth bugs.

How Do You Add Sign in with Apple?

Sign in with Apple is mandatory if your app offers any third-party social login (App Store Review Guideline 4.8). But even beyond the requirement, it is the highest-converting auth method on iOS. Users trust the native Apple sheet, and the flow is a single biometric scan — no typing required.

I covered the complete Sign in with Apple implementation in depth in the Sign in with Apple SwiftUI tutorial, including nonce generation with CryptoKit, credential handling, the Supabase token exchange, and all the gotchas Apple does not document. Here, I will show you how the Apple button integrates into your login screen layout.

The key component is Apple's native SignInWithAppleButton from the AuthenticationServices framework:

// Views/Auth/AppleSignInButton.swift
import AuthenticationServices
import SwiftUI

struct AppleSignInButton: View {
    @Environment(\.colorScheme) private var colorScheme
    @State private var currentNonce: String?
    @State private var isLoading = false

    let onSuccess: () -> Void
    let onError: (String) -> Void

    var body: some View {
        SignInWithAppleButton(
            .continue,    // "Continue with Apple" — less committal
            onRequest: { request in
                let nonce = NonceGenerator.randomNonce()
                currentNonce = nonce
                request.requestedScopes = [.fullName, .email]
                request.nonce = NonceGenerator.sha256(nonce)
            },
            onCompletion: handleResult
        )
        .signInWithAppleButtonStyle(
            colorScheme == .dark ? .white : .black
        )
        .frame(height: 50)
        .clipShape(RoundedRectangle(cornerRadius: 12))
        .disabled(isLoading)
        .opacity(isLoading ? 0.6 : 1)
    }

    private func handleResult(
        _ result: Result<ASAuthorization, Error>
    ) {
        switch result {
        case .success(let auth):
            guard let credential = auth.credential
                    as? ASAuthorizationAppleIDCredential,
                  let tokenData = credential.identityToken,
                  let token = String(data: tokenData, encoding: .utf8),
                  let nonce = currentNonce else {
                onError("Could not retrieve Apple credentials.")
                return
            }

            // Extract name (only available on FIRST sign-in)
            let name = [
                credential.fullName?.givenName,
                credential.fullName?.familyName,
            ].compactMap { $0 }.joined(separator: " ")

            Task {
                isLoading = true
                do {
                    try await SupabaseManager.shared.client.auth
                        .signInWithIdToken(
                            credentials: .init(
                                provider: .apple,
                                idToken: token,
                                nonce: nonce
                            )
                        )

                    if !name.isEmpty {
                        try await SupabaseManager.shared.client.auth
                            .update(user: .init(
                                data: ["full_name": .string(name)]
                            ))
                    }

                    await MainActor.run { onSuccess() }
                } catch {
                    await MainActor.run {
                        onError(error.localizedDescription)
                    }
                }
                isLoading = false
            }

        case .failure(let error):
            // User cancelled — not a real error
            let nsError = error as NSError
            if nsError.code != ASAuthorizationError.canceled.rawValue {
                onError(error.localizedDescription)
            }
        }
    }
}

A few key decisions here. I use .continue instead of .signIn for the button label because "Continue with Apple" works for both new and returning users. The button style automatically flips between .white and .black based on the current color scheme — this is required by Apple's HIG. And the cancelled-error check (code 1001) prevents showing an error message when the user simply dismisses the Apple sheet.

For the full deep dive on nonce generation, credential revocation handling, the first-sign-in data gotcha, and testing on real devices, read the complete Sign in with Apple tutorial.

How Do You Add Google Sign-In?

Google Sign-In is the second most popular social auth method on iOS after Apple. It is especially important for apps that also have an Android version, since users expect their Google account to work across platforms. The integration is straightforward but has a few SwiftUI-specific quirks.

First, add the Google Sign-In SDK via Swift Package Manager:

// In Xcode: File → Add Package Dependencies
// URL: https://github.com/google/GoogleSignIn-iOS
// Version: 8.0.0 or later

Next, configure your Google Cloud project. Go to the Google Cloud Console → APIs & Services → Credentials, create an OAuth 2.0 Client ID for iOS, and enter your bundle identifier. Download the GoogleService-Info.plist and add it to your Xcode project. You also need to add a URL scheme to your Info.plist — the reversed client ID from the plist file (it looks like com.googleusercontent.apps.123456789).

Here is the SwiftUI integration:

// Views/Auth/GoogleSignInButton.swift
import GoogleSignIn
import SwiftUI

struct GoogleSignInButton: View {
    @State private var isLoading = false

    let onSuccess: () -> Void
    let onError: (String) -> Void

    var body: some View {
        Button {
            signInWithGoogle()
        } label: {
            HStack(spacing: 12) {
                // Google "G" logo
                Image("google-logo")  // Add to Assets catalog
                    .resizable()
                    .frame(width: 20, height: 20)

                Text("Continue with Google")
                    .font(.body)
                    .fontWeight(.medium)
            }
            .frame(maxWidth: .infinity)
            .frame(height: 50)
            .background(.ultraThinMaterial)
            .clipShape(RoundedRectangle(cornerRadius: 12))
            .overlay(
                RoundedRectangle(cornerRadius: 12)
                    .stroke(Color.primary.opacity(0.15), lineWidth: 1)
            )
        }
        .buttonStyle(.plain)
        .disabled(isLoading)
        .opacity(isLoading ? 0.6 : 1)
    }

    private func signInWithGoogle() {
        // Get the root view controller for the Google SDK
        guard let windowScene = UIApplication.shared
                .connectedScenes.first as? UIWindowScene,
              let rootVC = windowScene.windows.first?
                .rootViewController else {
            onError("Unable to find root view controller.")
            return
        }

        isLoading = true

        GIDSignIn.sharedInstance.signIn(
            withPresenting: rootVC
        ) { result, error in
            if let error {
                isLoading = false
                // User cancelled — code -5
                if (error as NSError).code == GIDSignInError.canceled.rawValue {
                    return
                }
                onError(error.localizedDescription)
                return
            }

            guard let idToken = result?.user.idToken?.tokenString else {
                isLoading = false
                onError("Could not retrieve Google ID token.")
                return
            }

            // Exchange the Google token with Supabase
            Task {
                do {
                    try await SupabaseManager.shared.client.auth
                        .signInWithIdToken(
                            credentials: .init(
                                provider: .google,
                                idToken: idToken
                            )
                        )
                    await MainActor.run {
                        isLoading = false
                        onSuccess()
                    }
                } catch {
                    await MainActor.run {
                        isLoading = false
                        onError(error.localizedDescription)
                    }
                }
            }
        }
    }
}

The biggest SwiftUI-specific annoyance with Google Sign-In is that the SDK still requires a UIViewController reference to present its sign-in sheet. The UIApplication.shared.connectedScenes approach shown above is the cleanest way to get the root view controller without bridging to UIKit. This is a pattern you will see in many third-party SDKs that have not fully adopted SwiftUI yet.

Also note that we exchange the Google ID token with Supabase using the same signInWithIdToken method we use for Apple — the only difference is the .google provider. Supabase validates the token against Google's public keys and creates or logs in the user. Make sure you have enabled the Google provider in your Supabase dashboard under Authentication → Providers.

How Do Authentication Methods Compare?

Not every login method is right for every app. Here is a comparison of the four most common approaches, based on my experience and industry benchmarks:

MethodConversion RateProsConsBest For
Sign in with Apple85-92%One tap, native UI, privacy relay, no password, mandatory if offering social loginName/email only on first sign-in, Apple ecosystem onlyiOS-only apps, privacy-focused apps
Google Sign-In75-85%Cross-platform, familiar to users, always returns emailRequires UIViewController hack, extra SDK dependencyCross-platform apps, apps with Android version
Email + Password40-55%Universal, no third-party dependency, full data controlHigh friction, password fatigue, requires reset flowEnterprise apps, apps requiring email verification
Magic Link55-65%No password to remember, verified email, low frictionDepends on email delivery speed, user leaves app to check emailSaaS tools, apps where email is the primary identifier

The conversion rate column reflects the percentage of users who start the sign-in flow and successfully complete it. Sign in with Apple dominates because it is a single biometric scan with zero typing. Email + password has the highest drop-off because users abandon forms — they mistype passwords, forget which email they used, or simply decide it is not worth the effort.

My recommendation for most indie iOS apps: offer Sign in with Apple as the primary option, Google as a secondary option, and email/password as a fallback. This covers nearly every user without overwhelming the screen. If you want even less friction, add Magic Link via Supabase as a passwordless alternative to the email form.

How Do You Handle Authentication State in SwiftUI?

Once a user signs in, your app needs to know about it — and react immediately. The login screen should disappear, the main app should appear, and this transition should be smooth. The best way to manage this in SwiftUI is with an @Observable auth manager that your root view watches.

Here is the architecture pattern I use in every production app, inspired by the MVVM architecture guide:

// Services/AuthManager.swift
import Observation
import Supabase

@Observable
final class AuthManager {
    var isAuthenticated = false
    var isLoading = true  // True while checking initial session
    var currentUser: User?

    private let supabase = SupabaseManager.shared.client

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

    // Check if user has an existing session on app launch
    func checkSession() async {
        isLoading = true
        do {
            let session = try await supabase.auth.session
            await MainActor.run {
                currentUser = session.user
                isAuthenticated = true
                isLoading = false
            }
        } catch {
            await MainActor.run {
                isAuthenticated = false
                isLoading = false
            }
        }
    }

    // Listen for auth state changes (sign in, sign out, token refresh)
    private func listenForAuthChanges() {
        Task {
            for await (event, session) in supabase.auth.authStateChanges {
                await MainActor.run {
                    switch event {
                    case .signedIn:
                        currentUser = session?.user
                        isAuthenticated = true
                    case .signedOut:
                        currentUser = nil
                        isAuthenticated = false
                    default:
                        break
                    }
                }
            }
        }
    }

    // Email sign-in
    func signIn(email: String, password: String) async throws {
        try await supabase.auth.signIn(
            email: email, password: password
        )
    }

    // Sign out
    func signOut() async throws {
        try await supabase.auth.signOut()
    }
}

The @Observable macro (available since iOS 17) makes this dead simple. Any SwiftUI view that reads authManager.isAuthenticated automatically re-renders when that value changes. No @Published, no ObservableObject, no objectWillChange — the compiler handles it all.

Now wire it into your root view to switch between the login screen and the main app:

// App/ContentView.swift
import SwiftUI

struct ContentView: View {
    @State private var authManager = AuthManager()

    var body: some View {
        Group {
            if authManager.isLoading {
                // Splash screen while checking session
                ProgressView()
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .background(Color(.systemBackground))
            } else if authManager.isAuthenticated {
                MainTabView()
                    .environment(authManager)
            } else {
                LoginScreen()
                    .environment(authManager)
            }
        }
        .animation(.easeInOut(duration: 0.3), value: authManager.isAuthenticated)
        .animation(.easeInOut(duration: 0.3), value: authManager.isLoading)
    }
}

The isLoading state is critical. Without it, users who are already signed in would see the login screen flash briefly on every app launch while the session check completes. The splash screen prevents that flicker. The .animation modifier gives you a smooth crossfade between states instead of a jarring cut.

Now here is the combined login screen that brings together all three auth methods:

// Views/Auth/LoginScreen.swift
import SwiftUI

struct LoginScreen: View {
    @Environment(AuthManager.self) private var authManager
    @State private var errorMessage: String?

    var body: some View {
        ScrollView {
            VStack(spacing: 24) {
                // Logo and welcome text
                VStack(spacing: 8) {
                    Image("app-logo")
                        .resizable()
                        .frame(width: 64, height: 64)
                        .clipShape(RoundedRectangle(cornerRadius: 16))

                    Text("Welcome Back")
                        .font(.title2)
                        .fontWeight(.bold)

                    Text("Sign in to continue")
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                }
                .padding(.top, 40)

                // Social sign-in buttons (highest conversion first)
                VStack(spacing: 12) {
                    AppleSignInButton(
                        onSuccess: { /* AuthManager handles state */ },
                        onError: { errorMessage = $0 }
                    )

                    GoogleSignInButton(
                        onSuccess: { /* AuthManager handles state */ },
                        onError: { errorMessage = $0 }
                    )
                }

                // Divider
                HStack {
                    Rectangle()
                        .fill(Color.primary.opacity(0.1))
                        .frame(height: 1)
                    Text("or")
                        .font(.caption)
                        .foregroundStyle(.tertiary)
                    Rectangle()
                        .fill(Color.primary.opacity(0.1))
                        .frame(height: 1)
                }

                // Email login form
                EmailLoginView { email, password in
                    try await authManager.signIn(
                        email: email, password: password
                    )
                }

                // Error from social sign-in
                if let errorMessage {
                    Text(errorMessage)
                        .font(.caption)
                        .foregroundStyle(.red)
                        .multilineTextAlignment(.center)
                }

                // Sign-up link
                HStack(spacing: 4) {
                    Text("Don't have an account?")
                        .foregroundStyle(.secondary)
                    Button("Sign Up") {
                        // Navigate to sign-up screen
                    }
                    .fontWeight(.semibold)
                }
                .font(.subheadline)
                .padding(.top, 8)
            }
            .padding(.horizontal, 24)
            .padding(.bottom, 40)
        }
        .scrollDismissesKeyboard(.interactively)
    }
}

Notice the layout order: social buttons first (highest conversion), then the "or" divider, then the email form (lowest conversion). This visual hierarchy nudges users toward the easiest path. The .scrollDismissesKeyboard(.interactively) modifier lets users dismiss the keyboard by scrolling down — a small touch that makes the email form feel polished.

How Do You Handle Login Screen Accessibility and Dark Mode?

A login screen that does not work in dark mode or with VoiceOver is a login screen that excludes users. Here are the concrete steps to get both right.

Dark Mode

If you followed the patterns above, you are already in good shape. SwiftUI's semantic colors (.primary, .secondary, .tertiary) and materials (.ultraThinMaterial) automatically adapt to the current color scheme. The SignInWithAppleButton flips its style based on colorScheme. The key mistakes to avoid:

  • Do not hardcode colors. If you write Color.white for text, it will be invisible on a white background in light mode. Use .primary or your design tokens instead.
  • Test both modes during development. Add .preferredColorScheme(.dark) to your preview to catch issues early. Better yet, create two previews — one for each mode.
  • Watch your overlays and borders. A border that looks subtle in dark mode (Color.white.opacity(0.1)) may look too heavy in light mode. Use Color.primary.opacity(0.1) to scale with the scheme.
// Preview with both color schemes
#Preview("Light Mode") {
    LoginScreen()
        .environment(AuthManager())
        .preferredColorScheme(.light)
}

#Preview("Dark Mode") {
    LoginScreen()
        .environment(AuthManager())
        .preferredColorScheme(.dark)
}

Accessibility

VoiceOver users interact with your login screen differently. Here is what you need to check:

  • Every interactive element must have a label. The native SignInWithAppleButton handles this automatically. For the Google button and email form, ensure custom controls have .accessibilityLabel modifiers.
  • Error messages must be announced. When validation fails, VoiceOver should read the error immediately. Use .accessibilityAddTraits(.isStaticText) on error labels and consider posting an accessibility notification.
  • Support Dynamic Type. Your login form should remain usable at the largest accessibility text sizes. Use @ScaledMetric for custom spacing and test with the Accessibility Inspector in Xcode.
  • The password visibility toggle must be labeled. We already added .accessibilityLabel("Show password" / "Hide password") in the email form above. Without this, VoiceOver users have no idea what the eye icon does.
// Announce errors to VoiceOver
if let errorMessage {
    Text(errorMessage)
        .font(.caption)
        .foregroundStyle(.red)
        .accessibilityAddTraits(.isStaticText)
        .accessibilityLabel("Error: \(errorMessage)")
}

// Scale custom spacing with Dynamic Type
@ScaledMetric private var buttonHeight: CGFloat = 50
@ScaledMetric private var iconSize: CGFloat = 20

For a complete guide on making your SwiftUI app accessible, including VoiceOver navigation patterns, rotor support, and testing workflows, see the SwiftUI accessibility guide.

What Are the Most Common Login Screen Mistakes?

After reviewing dozens of indie iOS apps and building auth flows for my own projects, these are the anti-patterns I see repeatedly:

1. No Loading State on the Sign-In Button

If the button stays active during authentication, users will tap it multiple times. This sends duplicate auth requests, which can cause race conditions, duplicate user accounts, or cryptic error messages. Always disable the button and show a spinner.

2. Using Alerts Instead of Inline Errors

An alert interrupts the user and forces them to dismiss it before they can fix the problem. Inline error messages let the user see what went wrong while simultaneously fixing it. Use alerts only for critical, unrecoverable errors — not for "invalid email format."

3. Forgetting the Forgot Password Flow

If you offer email/password login, you must offer password reset. Users forget passwords constantly. Without a reset flow, they are locked out of your app permanently. Supabase provides supabase.auth.resetPasswordForEmail() — it takes one line to send a reset email.

4. Not Handling the Keyboard

On smaller iPhones (SE, Mini), the keyboard covers most of the screen. If your sign-in button is below the fold when the keyboard is up, users cannot tap it. Wrapping your form in a ScrollView with .scrollDismissesKeyboard(.interactively) solves this — as shown in the login screen code above.

5. Skipping the Session Check on Launch

If a user signed in yesterday and opens the app today, they expect to be signed in. Failing to check for an existing session forces users to re-authenticate on every launch. The AuthManager.checkSession() pattern above handles this.

6. Not Offering Sign in with Apple

If your app has Google login but not Apple login, Apple will reject your app. This is Guideline 4.8, and it is enforced strictly. Always include Sign in with Apple if you offer any social login option. See the Sign in with Apple tutorial for the full implementation.

7. Ignoring Dark Mode

Over 80% of iOS users use dark mode at least part of the time. A login screen that is only designed for light mode looks broken when the system is dark. Use semantic colors and test both modes — no excuses.

Skip the Plumbing — Get Pre-Built Auth Screens

This tutorial covered a lot of ground: email forms with validation, Sign in with Apple, Google Sign-In SDK integration, auth state management, dark mode theming, accessibility, and the anti-patterns that trip up most developers. Implementing all of this correctly — including edge cases like credential revocation, token refresh, session persistence, and the first-sign-in data gotcha — is easily 20-30 hours of work.

The Swift Kit ships with every auth screen shown in this tutorial, fully wired to Supabase. The email login form, Sign in with Apple (with nonce generation and credential revocation handling), Google Sign-In, the AuthManager with session persistence, and the root view routing are all pre-built, tested, and ready to customize. You add your Supabase credentials and API keys, and authentication works out of the box.

Beyond auth, you get onboarding templates, a RevenueCat-powered paywall, an AI chat interface, push notifications, analytics, and a design token system that themes the entire app from a single config file. It is everything in this tutorial — and the other 15 tutorials on this blog — pre-assembled into one production-ready SwiftUI project.

Get The Swift Kit and stop rebuilding login screens from scratch.

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