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