Seguridad en iOS: Cómo almacenar Tokens y Keys de forma segura usando el Keychain en Swift (Sin librerías)

En el desarrollo de aplicaciones móviles, la seguridad no es una característica opcional; es una necesidad crítica. Cuando desarrollamos para iOS, uno de los errores más comunes y peligrosos es almacenar información sensible —como tokens de autenticación (JWT), contraseñas de usuarios o claves de API— en lugares inadecuados.

Muchos desarrolladores recurren a librerías de terceros (como Cocoapods o Swift Packages) para simplificar el uso del Keychain de iOS. Sin embargo, depender de dependencias externas para la seguridad base de tu app introduce riesgos de cadena de suministro y aumenta el tamaño del binario.

En este artículo, aprenderás cómo implementar una solución robusta y segura utilizando el Keychain nativo de iOS en Swift, completamente desde cero y sin librerías externas.


¿Por qué UserDefaults NO es seguro para guardar Tokens y Keys?

Es muy común que los desarrolladores principiantes utilicen UserDefaults para guardar tokens de sesión debido a su extrema sencillez. Esto es un grave error de seguridad.

  • Almacenamiento en texto plano: UserDefaults guarda la información en un archivo .plist dentro del sandbox de la aplicación. Este archivo no está encriptado.
  • Fácil acceso: Si un dispositivo sufre un jailbreak, o si alguien accede a las copias de seguridad del dispositivo sin cifrar, cualquiera puede leer, modificar o extraer los tokens almacenados en UserDefaults en cuestión de segundos.

UserDefaults debe usarse únicamente para guardar preferencias del usuario que no sean sensibles (como el modo oscuro o el idioma de la app). Para datos confidenciales, la única opción correcta es el Keychain Services API.


¿Qué es el Keychain de iOS y cómo funciona?

El Keychain (Llavero) es un contenedor de almacenamiento seguro e independiente del sandbox de la aplicación. Está gestionado directamente por el sistema operativo iOS y cuenta con las siguientes ventajas:

  1. Cifrado de hardware: Los datos se encriptan utilizando claves protegidas por el hardware del dispositivo (Secure Enclave).
  2. Persistencia tras desinstalación: A diferencia de UserDefaults, los datos en el Keychain persisten incluso si el usuario desinstala y vuelve a instalar la aplicación.
  3. Acceso controlado: Solo la aplicación que guardó los datos (o aquellas del mismo grupo de desarrollo autorizadas) puede acceder a ellos.

Implementación paso a paso en Swift (Sin librerías)

Para trabajar con el Keychain de forma nativa, utilizaremos el framework Security de Apple. Crearemos una clase helper llamada KeychainManager que gestionará las operaciones CRUD (Crear, Leer, Actualizar y Eliminar) de forma genérica y limpia.

1. Definición de Errores y la Clase Base

Primero, definiremos los posibles errores que pueden ocurrir al interactuar con el Keychain para tener un control de flujo robusto.

import Foundation
import Security

enum KeychainError: Error {
    case duplicateItem
    case unknown(OSStatus)
    case invalidData
    case itemNotFound
}

2. Guardar datos en el Keychain (Create / Update)

Para guardar datos, necesitamos construir un diccionario de consulta (query) que especifique qué estamos guardando y bajo qué clave (account).

Si intentamos guardar un elemento que ya existe, el sistema devolverá un error de duplicado. Por ello, nuestra función primero intentará guardar y, si el ítem ya existe, lo actualizará.

class KeychainManager {

    static let shared = KeychainManager()
    private init() {}

    func save(key: String, data: Data) throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecValueData as String: data,
            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
        ]

        // Intentar añadir el nuevo ítem
        let status = SecItemAdd(query as CFDictionary, nil)

        // Si ya existe, actualizamos el valor existente
        if status == errSecDuplicateItem {
            try update(key: key, data: data)
        } else if status != errSecSuccess {
            throw KeychainError.unknown(status)
        }
    }

    private func update(key: String, data: Data) throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key
        ]

        let attributes: [String: Any] = [
            kSecValueData as String: data
        ]

        let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)

        if status != errSecSuccess {
            throw KeychainError.unknown(status)
        }
    }
}

3. Leer datos del Keychain (Read)

Para recuperar la información, debemos pedirle al Keychain que nos devuelva los datos asociados a una clave específica. Es crucial indicar kSecReturnData: true para que nos retorne el valor real en formato Data.

extension KeychainManager {

    func read(key: String) throws -> Data {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]

        var dataTypeRef: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)

        guard status != errSecItemNotFound else {
            throw KeychainError.itemNotFound
        }

        guard status == errSecSuccess else {
            throw KeychainError.unknown(status)
        }

        guard let data = dataTypeRef as? Data else {
            throw KeychainError.invalidData
        }

        return data
    }
}

4. Eliminar datos del Keychain (Delete)

Cuando el usuario cierra sesión, es fundamental limpiar los tokens de seguridad del dispositivo.

extension KeychainManager {

    func delete(key: String) throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key
        ]

        let status = SecItemDelete(query as CFDictionary)

        guard status == errSecSuccess || status == errSecItemNotFound else {
            throw KeychainError.unknown(status)
        }
    }
}

Métodos de conveniencia para Strings (Tokens y Keys)

Dado que la mayoría de los tokens de API y JWT son cadenas de texto (String), podemos añadir una capa de abstracción sobre nuestro KeychainManager para facilitar su uso diario.

extension KeychainManager {

    func saveToken(_ token: String, forKey key: String) throws {
        guard let data = token.data(using: .utf8) else {
            throw KeychainError.invalidData
        }
        try save(key: key, data: data)
    }

    func getToken(forKey key: String) -> String? {
        guard let data = try? read(key: key) else { return nil }
        return String(data: data, encoding: .utf8)
    }
}

Ejemplo de uso en tu código:

// 1. Guardar un Token JWT tras el Login
do {
    try KeychainManager.shared.saveToken("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", forKey: "user_session_token")
    print("Token guardado con éxito.")
} catch {
    print("Error al guardar: \(error)")
}

// 2. Recuperar el Token para una petición HTTP
if let token = KeychainManager.shared.getToken(forKey: "user_session_token") {
    print("Token recuperado: \(token)")
} else {
    print("No se encontró ningún token.")
}

// 3. Eliminar el Token al hacer Logout
try? KeychainManager.shared.delete(key: "user_session_token")

Buenas prácticas de seguridad al usar Keychain

Para garantizar el máximo nivel de seguridad en tu aplicación iOS, sigue estas recomendaciones:

  • Elige el nivel de accesibilidad correcto (kSecAttrAccessible): En nuestro ejemplo usamos kSecAttrAccessibleAfterFirstUnlock, lo que significa que los datos solo están disponibles una vez que el usuario desbloquea el dispositivo por primera vez tras encenderlo. Evita usar opciones que permitan el acceso con el dispositivo bloqueado a menos que sea estrictamente necesario (como tareas en segundo plano).
  • Usa Access Groups para compartir datos: Si tienes varias apps del mismo desarrollador (o una extensión de app, como un widget) y necesitas compartir el token de inicio de sesión, configura los Keychain Sharing Entitlements en Xcode.
  • No confíes ciegamente en el simulador: El comportamiento del Keychain en el simulador de Xcode puede diferir ligeramente del dispositivo real (por ejemplo, a veces no persiste los datos tras borrar la app en el simulador, pero sí en el dispositivo físico). Prueba siempre tu flujo de credenciales en un iPhone o iPad real.

Conclusión

Garantizar la seguridad de los datos de tus usuarios no requiere sobrecargar tu proyecto con dependencias de terceros que pueden quedar obsoletas o vulnerables. Como hemos visto, implementar un sistema de almacenamiento seguro utilizando el Keychain de iOS en Swift de forma nativa es directo, limpio y altamente eficiente.

Al utilizar SecItemAdd, SecItemCopyMatching y SecItemDelete, tienes el control total sobre cómo se guardan y protegen las credenciales en el hardware del dispositivo, asegurando que tu aplicación cumpla con los estándares más altos de la industria móvil.

Deja una respuesta