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:

OptionSecurityFor 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:

  1. Right-click on the CognitoAuthDemo folder
  2. New File → Swift File
  3. 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:

  1. Right-click on the CognitoAuthDemo folder
  2. New File → SwiftUI View
  3. 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.

Deja una respuesta

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