Session Persistence with AWS Cognito in iOS

In the previous article, we implemented secure token storage in Keychain and sign-out functionality. However, there’s a problem: when the user closes and reopens the app, they always have to log in again, even if the tokens are still saved in Keychain. In this article, we’ll implement session persistence so the app automatically verifies if there’s an active session on startup.

What are we building?

In this article, we’ll implement:

  • Token verification on startup – The app checks if there are tokens saved in Keychain
  • Smart navigation – If there are tokens → HomeView, if not → SignInView
  • Smooth experience – User doesn’t have to log in every time they open the app
  • HomeView update – To make sign out work correctly with persistence

In this article, we’ll implement:

Token verification on startup – The app checks if there are tokens saved in Keychain

Smart navigation – If there are tokens → HomeView, if not → SignInView

Smooth experience – User doesn’t have to log in every time they open the app

HomeView update – To make sign out work correctly with persistence

The current problem

Current state (Article 4):

User opens app → ContentView → SignInView (always)

Even though tokens are saved in Keychain, the app always starts at SignInView. The user has to: 1. Open the app 2. Enter email and password 3. Log in again 4. Navigate to HomeView

This is annoying because the tokens are already saved.

What we want (Article 5):

User opens app → ContentView checks tokens →    Are there saved tokens?        YES → HomeView (persistent session)        NO → SignInView (request login)

The user only logs in ONCE, and the session is maintained until:

  • Tokens expire (60 minutes for Access/ID, 30 days for Refresh)
  • User manually signs out
  • User deletes the app

Prerequisites

Have completed Article 4 (Secure token storage)

Have KeychainManager implemented

Have HomeView with sign out button

Understand navigation flow with NavigationStack

Step 1: Update ContentView to verify tokens on startup

We’ll modify ContentView to check if there are saved tokens when the app opens.

ContentView.swift:

import SwiftUI
struct ContentView: View {    @State private var isLoggedIn: Bool = false    @State private var isCheckingSession: Bool = true    var body: some View {        Group {            if isCheckingSession {                // Loading screen while checking tokens                VStack {                    ProgressView()                        .scaleEffect(1.5)                    Text("Checking session...")                        .padding(.top, 20)                        .foregroundColor(.gray)                }            } else {                // Show view based on session state                if isLoggedIn {                    NavigationStack {                        HomeView(isLoggedIn: $isLoggedIn)                    }                } else {                    NavigationStack {                        SignInView(isLoggedIn: $isLoggedIn)                    }                }            }        }        .onAppear {            checkSession()        }    }    
// MARK: - Check Session    private func checkSession() {        // Check if there are tokens saved in Keychain        let hasTokens = KeychainManager.shared.hasTokens()        // Simulate a small delay to show loading screen        // (in production this would be the time to validate tokens)        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {            isLoggedIn = hasTokens            isCheckingSession = false            if hasTokens {                print("✅ Session found - Navigating to HomeView")            } else {                print("⚠️ No session - Showing SignInView")            }        }    }}

What changed here?

Two new @State variables:

isLoggedIn: Bool - Indicates if the user has an active session – Indicates if the user has an active session

isCheckingSession: Bool - Indicates if we’re checking tokens (to show loading) – Indicates if we’re checking tokens (to show loading)

Three possible UI states:

isCheckingSession = true → Shows ProgressView with “Checking session…”

isLoggedIn = true → Shows HomeView inside NavigationStack

isLoggedIn = false → Shows SignInView inside NavigationStack

Shared @Binding:

We pass $isLoggedIn to both HomeView and SignInView

When SignInView logs in successfully → isLoggedIn = true → ContentView shows HomeView

When HomeView signs out → isLoggedIn = false → ContentView shows SignInView

We don’t need dismiss() or NavigationLink because ContentView observes the binding

checkSession() on startup:

Executes in .onAppear

Calls KeychainManager.shared.hasTokens() to verify if there are tokens

0.5 second delay to show loading screen (better UX)

Updates isLoggedIn and isCheckingSession

Smart navigation:

If hasTokens = true → goes directly to HomeView (persistent session)

If hasTokens = false → shows SignInView (request login)

Why separate NavigationStacks?

We use two different NavigationStacks (one for HomeView, another for SignInView) instead of a single NavigationStack because:

  • When the isLoggedIn binding changes, SwiftUI completely rebuilds the Group
  • This resets the navigation stack and ensures there’s no accumulated “backstack”
  • User cannot go “back” from HomeView to SignInView (because they’re separate stacks)

Step 2: Update SignInView to use @Binding

Now we modify SignInView to update the shared state instead of navigating manually.

Changes in SignInView.swift:

struct SignInView: View {    @Binding var isLoggedIn: Bool  
    // ✅ NEW - Receives binding from ContentView    @State private var email: String = ""    @State private var password: String = ""    @State private var isLoading: Bool = false    @State private var message: String = ""    @State private var showAlert: Bool = false    @State private var navigateToRegister: Bool = false    
    // ❌ REMOVED: @State private var navigateToHome: Bool = false    private let authService = CognitoAuthService()    var body: some View {        VStack(spacing: 20) {            // ... (UI same as before) ...        }        .navigationDestination(isPresented: $navigateToRegister) {            RegisterView()        }        
    // ❌ REMOVED: .navigationDestination(isPresented: $navigateToHome)    }    
// MARK: - Sign In Function    private func signIn() {        guard !email.isEmpty, !password.isEmpty else {            message = "Please complete all fields"            showAlert = true            return        }        isLoading = true        message = "Signing in..."        Task {            do {                let tokens = try await authService.signIn(email: email, password: password)                // Save tokens to Keychain                let saved = KeychainManager.shared.saveTokens(tokens)                await MainActor.run {                    isLoading = false                    if saved {                        message = "✅ Sign in successful"                        print("✅ Tokens saved to Keychain")                        // Clear fields                        email = ""                        password = ""                        
    // ✅ CHANGED: Update binding instead of navigating                        isLoggedIn = true  // ContentView detects change and shows HomeView                    } else {                        message = "Error saving tokens"                        showAlert = true                    }                }            } catch {                await MainActor.run {                    isLoading = false                    message = error.localizedDescription                    showAlert = true                }            }        }    }}
#Preview {    SignInView(isLoggedIn: .constant(false))  
    // ✅ Provide binding in Preview}

What changed here?

Added @Binding:

@Binding var isLoggedIn: Bool

SignInView no longer handles navigation by itself

Receives the binding from ContentView

When it updates isLoggedIn = true, ContentView automatically shows HomeView

Removed local navigation:

Removed @State private var navigateToHome: Bool

Removed .navigationDestination(isPresented: $navigateToHome) { HomeView() }

No longer need to handle navigation because ContentView does it for us

Updated signIn():

Before: navigateToHome = true → navigated manually

Now: isLoggedIn = true → updates the binding

ContentView observes the change and shows HomeView automatically

Updated Preview:

SignInView(isLoggedIn: .constant(false))

We use .constant(false) because Preview doesn’t need a real binding

Step 3: Update HomeView to use @Binding

Finally, we update HomeView to also use the shared binding.

Changes in HomeView.swift:

import SwiftUI
struct HomeView: View {    @Binding var isLoggedIn: Bool  
    // ✅ NEW - Receives binding from ContentView    
    // ❌ REMOVED: @Environment(\.dismiss) private var dismiss    @State private var showingSignOut = false    var body: some View {        VStack(spacing: 30) {            Spacer()            // Welcome icon            Image(systemName: "checkmark.circle.fill")                .font(.system(size: 80))                .foregroundColor(.green)            // Welcome message            Text("Welcome!")                .font(.largeTitle)                .fontWeight(.bold)            Text("You have successfully signed in")                .font(.body)                .foregroundColor(.gray)            Text("Your tokens are securely saved in Keychain")                .font(.caption)                .foregroundColor(.gray)                .multilineTextAlignment(.center)                .padding(.horizontal, 40)            Spacer()            // Sign out button            Button(action: {                showingSignOut = true            }) {                Label("Sign Out", systemImage: "arrow.right.square")                    .fontWeight(.semibold)                    .foregroundColor(.white)                    .frame(maxWidth: .infinity)                    .padding()                    .background(Color.red)                    .cornerRadius(10)            }            .padding(.horizontal, 30)            .padding(.bottom, 50)        }        .navigationBarBackButtonHidden(true)        .alert("Sign Out", isPresented: $showingSignOut) {            Button("Cancel", role: .cancel) { }            Button("Sign Out", role: .destructive) {                signOut()            }        } message: {            Text("Are you sure you want to sign out?")        }    }    
// MARK: - Sign Out    private func signOut() {        // Delete tokens from Keychain        let success = KeychainManager.shared.deleteTokens()        if success {            print("✅ Tokens deleted. Session closed.")            
    // ✅ CHANGED: Update binding instead of dismiss()            isLoggedIn = false  // ContentView detects change and shows SignInView        } else {            print("❌ Error deleting tokens")        }    }}
#Preview {    HomeView(isLoggedIn: .constant(true))  
    // ✅ Provide binding in Preview}

What changed here?

Added @Binding:

@Binding var isLoggedIn: Bool

HomeView no longer uses @Environment(\.dismiss) to go back

Receives the binding from ContentView

When it updates isLoggedIn = false, ContentView automatically shows SignInView

Removed @Environment(.dismiss):

No longer need dismiss() because we’re not navigating within a NavigationStack

ContentView handles the view change by observing the binding

Updated signOut():

Before: dismiss() → went back in the navigation stack

Now: isLoggedIn = false → updates the binding

ContentView observes the change and shows SignInView automatically

Updated Preview:

HomeView(isLoggedIn: .constant(true))

We use .constant(true) because we want to see HomeView in the Preview

Step 4: Test the complete flow

Now let’s test the entire session persistence flow:

Test 1: First time (no tokens)

Open the app for the first time

You’ll see “Checking session…” for 0.5 seconds

Then SignInView appears

Console: ⚠️ No session – Showing SignInView

Log in

Enter email and password

Press “Sign In”

Console: ✅ Tokens saved to Keychain

The app automatically shows HomeView

What happened internally? SignInView updated isLoggedIn = true → ContentView observed the change → showed HomeView

Test 2: Sign out

Sign out from HomeView

Press “Sign Out”

Confirm in the alert

Console: ✅ Tokens deleted. Session closed.

The app automatically returns to SignInView

What happened internally? HomeView updated isLoggedIn = false → ContentView observed the change → showed SignInView

Test 3: Session persistence (the main goal)

Log in again

Enter email and password

Press “Sign In”

Arrive at HomeView

Console: ✅ Tokens saved to Keychain

Close the app (CMD+Q or stop in Xcode)

Reopen the app

You’ll see “Checking session…” for 0.5 seconds

The app goes DIRECTLY to HomeView (without asking for login)

Console: ✅ Session found – Navigating to HomeView

IT WORKED! The session persisted

Sign out and reopen

Press “Sign Out”

Close the app (CMD+Q)

Reopen

Now it DOES ask for login (because you deleted the tokens)

Console: ⚠️ No session – Showing SignInView

What makes the session persist?

Keychain: Tokens are saved in Keychain, which persists between app executions

checkSession(): On opening, ContentView checks if there are tokens

hasTokens(): KeychainManager checks if there are Access, ID and Refresh tokens saved

Automatic navigation: If there are tokens → HomeView, if not → SignInView

When is the session lost?

The session is lost in these cases: 1. User signs out: Presses “Sign Out” → deleteTokens() removes tokens from Keychain 2. Tokens expire: Access/ID (60 min), Refresh (30 days) – In future articles we’ll implement automatic refresh 3. User uninstalls app: Keychain is cleared on uninstall 4. User changes device: Keychain is local to the device

Conclusion

In this article, we implemented session persistence for our AWS Cognito app. Now the user only needs to log in ONCE, and the session is maintained even if they close and reopen the app.

What we achieved:

  • ✅ Token verification on startup – ContentView checks Keychain in .onAppear
  • ✅ Smart navigation – If there are tokens → HomeView, if not → SignInView
  • ✅ Shared state with @Binding – ContentView manages the state, SignInView and HomeView update it
  • ✅ Smooth experience – No more login on every app opening
  • ✅ Correct sign out handling – Deletes tokens and returns to SignInView automatically

Complete flow:

App opens    → ContentView.onAppear()    → checkSession()    → KeychainManager.hasTokens()    → Are there tokens?        YES → isLoggedIn = true → HomeView        NO → isLoggedIn = false → SignInViewUser logs in    → SignInView.signIn()    → KeychainManager.saveTokens()    → isLoggedIn = true    → ContentView observes change → HomeViewUser signs out    → HomeView.signOut()    → KeychainManager.deleteTokens()    → isLoggedIn = false    → ContentView observes change → SignInView

What’s next?

In the next article, we’ll implement: – Automatic Refresh Token – Renew Access/ID tokens before they expire – Expiration handling – Detect when tokens expire and force re-login – Token validation – Verify that tokens are valid (not just that they exist)

Final reflection

This article completes the basic authentication flow: 1. Articles 1-2: Cognito setup and user registration 2. Article 3: Login and token retrieval 3. Article 4: Secure storage in Keychain 4. Article 5 (this one): Session persistence

Now we have a functional app where: – Users register once – Log in once – Session persists between executions – Can sign out whenever they want

In the following articles, we’ll add more advanced features like automatic refresh, token validation, and network error handling.

Follow me on LinkedIn where I share content about iOS development, AWS, and mobile best practices. You can also find more articles on Medium and explore code on GitHub. Questions and feedback are always welcome!

About validations

Disclaimer: This article is for educational purposes. In production, you should: – Validate tokens before trusting them (verify signature, expiration, etc.) – Implement automatic refresh before they expire – Handle network error cases (what if there’s no internet when opening the app?) – Add biometrics (Face ID/Touch ID) as an additional security layer – Implement logging and analytics to monitor sessions – Consider inactivity session expiration policies

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *