Secure Token Storage and Sign Out in iOS with AWS Cognito
In the previous article, we implemented sign-in with AWS Cognito and were able to see the tokens it returns (Access Token, ID Token, Refresh Token). However, these tokens are lost when you close the app. In this article, we’ll implement secure token storage using Keychain, create a welcome screen, and add sign-out functionality.
What are we building?
In this article, we’ll implement:
- KeychainManager – Class to save and read data securely in Keychain
- Save tokens – After login, save the 3 tokens in Keychain
- HomeView – Welcome screen displayed after successful login
- Sign out – Button that deletes tokens and returns user to login screen
Why Keychain?
In iOS there are several ways to save data:
| Option | Security | For tokens? |
|---|---|---|
| UserDefaults | ❌ No encryption | ❌ NEVER |
| Files | ❌ No encryption | ❌ NEVER |
| Core Data | ⚠️ Optional encryption | ⚠️ Not recommended |
| Keychain | ✅ Hardware encryption | ✅ YES |
Keychain is the only secure option for tokens because:
- Data is encrypted by the Secure Enclave (security chip)
- The operating system manages encryption automatically
- Data persists between app installations (optional)
- It’s Apple’s standard for sensitive credentials
NEVER save tokens in:
- UserDefaults – anyone with device access can read them
- Unencrypted files – same as UserDefaults
- Global variables – lost when closing the app
Prerequisites
Have completed Article 3 (Sign In)
Have a user that can login
Understand what AWS Cognito tokens are
Step 1: Create KeychainManager
We’ll create a class that allows us to save and read data from Keychain in a simple way.
1.1 Create the file
In Xcode:
- Right-click on the CognitoAuthDemo folder
- New File → Swift File
- Name: KeychainManager
1.2 Complete KeychainManager code
import Foundation
import Security
class KeychainManager {
static let shared = KeychainManager()
private init() {}
// Guardar en Keychain
/// Guarda un valor String en el Keychain
@discardableResult
func save(key: String, value: String) -> Bool {
// Convertir el string a Data
guard let data = value.data(using: .utf8) else {
print("❌ Error: No se pudo convertir el valor a Data")
return false
}
// Eliminar cualquier valor previo con esta key
delete(key: key)
// Crear query para guardar en Keychain
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
// Intentar guardar
let status = SecItemAdd(query as CFDictionary, nil)
if status == errSecSuccess {
print("✅ Guardado en Keychain: \(key)")
return true
} else {
print("❌ Error al guardar en Keychain: \(status)")
return false
}
}
// Leer del Keychain
/// Lee un valor String del Keychain
func read(key: String) -> String? {
// Crear query para buscar en Keychain
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess,
let data = result as? Data,
let value = String(data: data, encoding: .utf8) {
print("✅ Leído del Keychain: \(key)")
return value
} else {
print("⚠️ No se encontró en Keychain: \(key)")
return nil
}
}
// Eliminar del Keychain
/// Elimina un valor del Keychain
@discardableResult
func delete(key: String) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
let status = SecItemDelete(query as CFDictionary)
if status == errSecSuccess {
print("✅ Eliminado del Keychain: \(key)")
return true
} else if status == errSecItemNotFound {
return true
} else {
print("❌ Error al eliminar del Keychain: \(status)")
return false
}
}
// Eliminar todos los datos
/// Elimina TODOS los datos guardados por esta app en Keychain
@discardableResult
func deleteAll() -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword
]
let status = SecItemDelete(query as CFDictionary)
if status == errSecSuccess || status == errSecItemNotFound {
print("✅ Keychain limpiado completamente")
return true
} else {
print("❌ Error al limpiar Keychain: \(status)")
return false
}
}
}
// Keys para los tokens
extension KeychainManager {
enum TokenKey {
static let accessToken = "cognito.accessToken"
static let idToken = "cognito.idToken"
static let refreshToken = "cognito.refreshToken"
}
// Métodos convenientes para tokens
/// Guarda los tres tokens de Cognito en Keychain
func saveTokens(_ tokens: AuthTokens) -> Bool {
let accessSaved = save(key: TokenKey.accessToken, value: tokens.accessToken)
let idSaved = save(key: TokenKey.idToken, value: tokens.idToken)
let refreshSaved = save(key: TokenKey.refreshToken, value: tokens.refreshToken)
return accessSaved && idSaved && refreshSaved
}
/// Lee los tres tokens de Cognito del Keychain
func readTokens() -> AuthTokens? {
guard let accessToken = read(key: TokenKey.accessToken),
let idToken = read(key: TokenKey.idToken),
let refreshToken = read(key: TokenKey.refreshToken) else {
print("⚠️ No se encontraron tokens guardados")
return nil
}
return AuthTokens(
accessToken: accessToken,
idToken: idToken,
refreshToken: refreshToken
)
}
/// Elimina todos los tokens de Cognito del Keychain
func deleteTokens() -> Bool {
let accessDeleted = delete(key: TokenKey.accessToken)
let idDeleted = delete(key: TokenKey.idToken)
let refreshDeleted = delete(key: TokenKey.refreshToken)
return accessDeleted && idDeleted && refreshDeleted
}
/// Verifica si hay tokens guardados
func hasTokens() -> Bool {
return read(key: TokenKey.accessToken) != nil
}
}
1.3 How does it work?
Singleton Pattern:
static let shared = KeychainManager()
private init() {}
Only one instance of KeychainManager exists in the entire app
Accessed with KeychainManager.shared
Prevents creating multiple instances accessing Keychain
Basic methods:
save(key:value:) – Save data
Converts String to Data
Deletes any previous value with the same key (avoids duplicates)
Creates a “query” with necessary parameters
Uses SecItemAdd to save to Keychain
Returns true if successful
read(key:) – Read data
Creates a query to search
Uses SecItemCopyMatching to read
Converts Data back to String
Returns nil if not found
delete(key:) – Delete data
Uses SecItemDelete to delete
Returns true if successful (or if it didn’t exist)
deleteAll() – Delete all data
Deletes ALL data from this app in Keychain
⚠️ Use with caution
Important query parameters:
kSecClass: Data type (we use kSecClassGenericPassword for tokens)
kSecAttrAccount: The “key” that identifies the data
kSecValueData: The value to save (as Data)
kSecAttrAccessible: When the data is accessible
kSecAttrAccessibleWhenUnlockedThisDeviceOnly:
- Data is only accessible when the device is unlocked
- Does NOT sync with iCloud
- Does NOT transfer to other devices
- The most secure option for tokens
Extension for tokens:
To facilitate working with Cognito tokens, we add specific methods:
saveTokens(_:) – Saves all 3 tokens at once
readTokens() – Reads all 3 tokens and returns AuthTokens?
deleteTokens() – Deletes all 3 tokens
hasTokens() – Checks if there are tokens (useful to know if there’s a session)
Token keys:
enum TokenKey {
static let accessToken = "cognito.accessToken"
static let idToken = "cognito.idToken"
static let refreshToken = "cognito.refreshToken"
}
We use an enum to avoid typos
Prefix “cognito.” to identify they’re from Cognito
Clear and descriptive names
Step 2: Create HomeView (welcome screen)
Now we’ll create a simple screen that will be displayed after successful login.
2.1 Create the file
In Xcode:
- Right-click on the CognitoAuthDemo folder
- New File → SwiftUI View
- Name: HomeView
2.2 HomeView code
import SwiftUI
struct HomeView: View {
@Environment(\.dismiss) private var dismiss
@State private var showingSignOut = false
var body: some View {
VStack(spacing: 30) {
Spacer()
// Icono de bienvenida
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 80))
.foregroundColor(.green)
// Mensaje de bienvenida
Text("¡Bienvenido!")
.font(.largeTitle)
.fontWeight(.bold)
Text("Has iniciado sesión exitosamente")
.font(.body)
.foregroundColor(.gray)
Text("Tus tokens están guardados de forma segura en Keychain")
.font(.caption)
.foregroundColor(.gray)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
Spacer()
// Botón de cerrar sesión
Button(action: {
showingSignOut = true
}) {
Label("Cerrar Sesión", 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("Cerrar Sesión", isPresented: $showingSignOut) {
Button("Cancelar", role: .cancel) { }
Button("Cerrar Sesión", role: .destructive) {
signOut()
}
} message: {
Text("¿Estás seguro que deseas cerrar sesión?")
}
}
//Cerrar Sesión
private func signOut() {
// Eliminar tokens del Keychain
let success = KeychainManager.shared.deleteTokens()
if success {
print("✅ Tokens eliminados. Sesión cerrada.")
// Volver a SignInView
dismiss()
} else {
print("❌ Error al eliminar tokens")
}
}
}
2.3 What does this code do?
@Environment(.dismiss):
- Allows closing the current view and going back in the navigation stack
- When the user signs out, we use dismiss() to return to SignInView
navigationBarBackButtonHidden(true): – Hides the “back” button from navigation – User CANNOT return to HomeView without logging in again – Can only exit using “Sign Out”
Confirmation alert: – Asks “Are you sure you want to sign out?” – Two options: Cancel or Sign Out – Prevents user from signing out accidentally
signOut() function: 1. Calls KeychainManager.shared.deleteTokens() 2. Deletes the 3 tokens from Keychain 3. Uses dismiss() to return to SignInView
Step 3: Update SignInView to save tokens and navigate to HomeView
Now we’ll modify SignInView to save tokens in Keychain after successful login.
3.1 Add navigation state
In SignInView, we replace the authTokens state with navigateToHome:
struct SignInView: View {
@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
@State private var navigateToHome: Bool = false // Nuevo
private let authService = CognitoAuthService()
What did we remove? – authTokens: AuthTokens? – We no longer display tokens on screen
What did we add? – navigateToHome: Bool – To navigate to HomeView
3.2 Remove token visualization
We remove the section that displayed tokens on screen (no longer needed).
3.3 Update the signIn() function
We update the function to save tokens in Keychain:
// Función de Inicio de Sesión
private func signIn() {
// Validaciones básicas
guard !email.isEmpty, !password.isEmpty else {
message = "Por favor completa todos los campos"
showAlert = true
return
}
// Iniciar sesión con AWS Cognito
isLoading = true
message = "Iniciando sesión..."
Task {
do {
let tokens = try await authService.signIn(email: email, password: password)
// Guardar tokens en Keychain
let saved = KeychainManager.shared.saveTokens(tokens)
await MainActor.run {
isLoading = false
if saved {
message = "✅ Inicio de sesión exitoso"
print("✅ Tokens guardados en Keychain")
// Limpiar campos
email = ""
password = ""
// Navegar a HomeView
navigateToHome = true
} else {
message = "Error al guardar tokens"
showAlert = true
}
}
} catch {
await MainActor.run {
isLoading = false
message = error.localizedDescription
showAlert = true
}
}
}
}
What changed?
Save tokens: After receiving tokens from Cognito, we save them in Keychain
Clear fields: If saved successfully, we clear email and password
Navigate to HomeView: Activate navigateToHome = true
No visualization: We NO longer save tokens in the view state
3.4 Add navigationDestination for HomeView
At the end of the view, we add the destination:
.navigationDestination(isPresented: $navigateToRegister) {
RegisterView()
}
.navigationDestination(isPresented: $navigateToHome) { // Nuevo
HomeView()
}
Step 4: Test the complete flow
Now we’ll test the entire secure storage and sign-out flow.
4.1 Test sign in and token storage
Run the app in the simulator
Sign in:
- Enter email of a confirmed user
- Enter password
- Click «Sign In»
Check in Xcode console:
✅ Sign in successful✅ Saved to Keychain: cognito.accessToken✅ Saved to Keychain: cognito.idToken✅ Saved to Keychain: cognito.refreshToken✅ Tokens saved to Keychain
Automatic navigation:
The app navigates to HomeView
You’ll see the “Welcome!” message
There’s NO “back” button in navigation
4.2 Test sign out
In HomeView, click “Sign Out”
Confirmation alert appears:
“Are you sure you want to sign out?”
Click “Sign Out”
Check in console:
✅ Deleted from Keychain: cognito.accessToken✅ Deleted from Keychain: cognito.idToken✅ Deleted from Keychain: cognito.refreshToken✅ Tokens deleted. Session closed.
Navigate back:
The app returns to SignInView
Fields are empty
Tokens were deleted from Keychain
4.3 Verify tokens were saved correctly
To verify that tokens were really saved to Keychain:
Sign in
Close the app (swipe up in simulator)
Open the app again
Expected result: – For now, the app shows SignInView (because we haven’t implemented session persistence yet) – But the tokens ARE saved in Keychain – In Article 5 we’ll implement checking tokens when opening the app
To verify they’re saved, you can temporarily add this code in viewDidLoad or in SignInView:
// TEMPORAL - solo para verificar
if let tokens = KeychainManager.shared.readTokens() {
print("✅ Tokens encontrados en Keychain:")
print("Access: \(tokens.accessToken.prefix(20))...")
} else {
print("⚠️ No hay tokens guardados")
}
4.4 What did we achieve?
- ✅ Secure storage: Tokens are saved in encrypted Keychain
- ✅ Functional navigation: After login, navigates to HomeView
- ✅ Sign out: Button deletes tokens and returns to login
- ✅ Security: We use kSecAttrAccessibleWhenUnlockedThisDeviceOnly
- ✅ No back button: User cannot return to HomeView without logging in
What we DON’T have yet:
- What we DON’T have yet:
- ❌ Session persistence (check tokens when opening the app)
- ❌ Automatic token refresh when they expire
- ❌ “Loading” screen when verifying tokens
We’ll implement these topics in Article 5.
Conclusion
In this article, we implemented secure token storage and sign-out functionality:
- ✅ KeychainManager: Reusable class to save/read data securely
- ✅ Secure storage: Tokens are saved in hardware-encrypted Keychain
- ✅ HomeView: Welcome screen after login
- ✅ Save tokens automatically: After login, tokens are saved without user intervention
- ✅ Sign out: Button that deletes tokens and returns to login screen
- ✅ Security: We use kSecAttrAccessibleWhenUnlockedThisDeviceOnly for maximum security
- ✅ Smart navigation: User cannot return to HomeView without logging in
Complete flow implemented:
SignInView → Successful login → Save tokens to Keychain →HomeView → Sign out → Delete tokens → Return to SignInView
Series progress:
- Article 1: User registration ✅
- Article 2: Account confirmation ✅
- Article 3: Sign in and tokens ✅
- Article 4: Secure storage and sign out ✅
- Article 5: Session persistence (next)
In the next article, we’ll implement session persistence: when the user opens the app, we’ll check if there are saved tokens and take them directly to HomeView without asking them to log in again.
Final reflection
I invite you to follow me on LinkedIn where I share content related to software development, best practices, and AWS technologies. You can also find me on Medium and explore my projects on GitHub. If you have questions or suggestions, don’t hesitate to connect — I’m always open to exchanging ideas and learning alongside the community.
About validations
This article series focuses on understanding how AWS Cognito works in its purest form, which is why I’ve intentionally kept validations to a minimum. In a real production project, you should consider:
Keychain security:
- Use Keychain Access Groups to share tokens between apps
- Implement Face ID / Touch ID to access sensitive tokens
- Consider kSecAttrAccessibleAfterFirstUnlock for apps running in background
- Add Keychain access logs for auditing
- Implement jailbreak detection
Session management:
- Automatic session timeout due to inactivity
- Forced sign-out on other devices
- Notify user when session expires
- Rate limiting on login attempts
- Temporary lockout after N failed attempts
Additional validations:
- Verify token integrity before using them
- Validate JWT signature of tokens
- Check token expiration before each use
- Implement proactive automatic refresh (before they expire)
- Secure backup and restoration of credentials
Best practices:
- Do NOT save passwords in Keychain (only tokens)
- Clear tokens from memory after using them
- Always use HTTPS for communication with AWS
- Implement certificate pinning
- Penetration testing on storage
- Monitor anomalous accesses
The goal is for you to first understand how secure storage works in iOS, and then you can apply security best practices according to your project’s needs.