Keychain en iOS: almacenamiento seguro para datos sensibles
Arturo Rivas Arias
🔐 Guardar datos sensibles en una aplicación iOS parece una decisión sencilla hasta que aparece la primera duda importante: ¿dónde debería vivir un token de sesión, una clave privada, una credencial temporal o un identificador que no queremos exponer? La respuesta corta suele ser Keychain, pero la respuesta buena es necesario matizarla más. El Keychain no es simplemente “un UserDefaults seguro”; es un servicio del sistema con reglas de acceso, cifrado, grupos de acceso compartido y clases protegidas que conviene entender antes de meter ahí cualquier valor.
📦 UserDefaults sigue siendo el sitio perfecto para las preferencias: temas visuales, filtros seleccionados por defecto, flags de onboarding o pequeños ajustes de interfaz. Pero no está pensado para datos sensibles. Su contenido forma parte del contenedor de la app y puede acabar en copias de seguirdad, diagnósticos o inspecciones profundas del disco con relativa facilidad. El Keychain, en cambio, está diseñado para guardar datos pequeños pero sensibles: contraseñas, tokens, claves, certificados o credenciales de acceso.
🧱 Según la documentación de seguridad de Apple, el Keychain se implementa como una base de datos SQLite gestionada por el demonio del sistema securityd. Las aplicaciones no acceden libremente a cualquier registro: el sistema decide qué elementos puede leer cada proceso en función de sus certificados, su identificador de aplicación y sus grupos de acceso. Esto es importante porque el Keychain no es solo una API de almacenamiento; también es una frontera de aislamiento entre aplicaciones.
🛡️ Internamente, los elementos de Keychain se cifran con claves AES-256-GCM. Apple distingue entre metadatos —los atributos usados para buscar elementos— y el valor secreto real, almacenado normalmente en la propiedad kSecValueData. Los metadatos se protegen con una clave pensada para permitir búsquedas eficientes, mientras que el valor secreto requiere una ruta de acceso más protegida. En dispositivos modernos, parte de esta protección se apoya en el Secure Enclave.
🎯 La idea práctica es clara: usa el Keychain para datos que comprometiesen la seguridad si se filtrasen. Un token de autenticación, una clave para firmar peticiones o una credencial de API no deberían vivir en UserDefaults, ni en un fichero JSON dentro de alguna de las carpetas, ni en una base de datos local sin cifrar.
⚙️ La API de Keychain no es especialmente cómoda porque viene de Security.framework y trabaja con diccionarios CFDictionary, constantes kSecClass, estados OSStatus y valores Data. Por eso es habitual envolverla en tipos propios. Por ejemplo, podríamos crear un pequeño almacén para guardar una licencia en local de una app de productividad. No es el típico ejemplo de login, pero representa bien el caso de “dato pequeño, sensible y persistente”.
import Foundation
import Security
enum SecureStoreError: Error {
case unexpectedData
case unhandledStatus(OSStatus)
}
struct SecureStore {
let service: String
func save(_ value: String, for account: String) throws {
let data = Data(value.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
let status = SecItemAdd(query as CFDictionary, nil)
if status == errSecDuplicateItem {
try update(value, for: account)
return
}
guard status == errSecSuccess else {
throw SecureStoreError.unhandledStatus(status)
}
}
func read(account: String) throws -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound {
return nil
}
guard status == errSecSuccess else {
throw SecureStoreError.unhandledStatus(status)
}
guard let data = result as? Data,
let value = String(data: data, encoding: .utf8) else {
throw SecureStoreError.unexpectedData
}
return value
}
func delete(account: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw SecureStoreError.unhandledStatus(status)
}
}
private func update(_ value: String, for account: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let attributes: [String: Any] = [
kSecValueData as String: Data(value.utf8)
]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
guard status == errSecSuccess else {
throw SecureStoreError.unhandledStatus(status)
}
}
}
🧩 Hay varios detalles relevantes en este wrapper. El primero es el uso de kSecClassGenericPassword, que es una clase muy habitual para guardar secretos genéricos asociados a un service y un account. El service permite agrupar elementos por dominio lógico de la app, mientras que account identifica el registro concreto. No tienen por qué representar literalmente un usuario y una contraseña; pueden ser cualquier par dato-identificador estable que nos ayude a localizar el secreto.
🔁 El segundo detalle es cómo tratamos errSecDuplicateItem. Keychain no sobrescribe por defecto un elemento existente al llamar a SecItemAdd. Si el registro ya existe, hay que usar SecItemUpdate. Este comportamiento puede parecer incómodo al principio, pero obliga a separar explícitamente tres operaciones distintas: crear, actualizar y eliminar. En código de alta seguridad, esa claridad explícita suele ser una ventaja.
🚪 El tercer detalle, y probablemente el más importante, es kSecAttrAccessible. Esta clave define cuándo está disponible el elemento. kSecAttrAccessibleWhenUnlocked permite leerlo solo cuando el dispositivo está desbloqueado. kSecAttrAccessibleAfterFirstUnlock permite acceder después del primer desbloqueo tras reiniciar el dispositivo, lo que puede ser útil para tareas en segundo plano. Y las variantes ThisDeviceOnly evitan que el elemento migre a otro dispositivo mediante copias de seguridad o sincronización.
📱 En una app real, la elección de la clase de accesibilidad no debería hacerse por inercia. Si el dato secreto solo se necesita mientras la app está en primer plano, kSecAttrAccessibleWhenUnlockedThisDeviceOnly suele ser una opción razonable. Si una extensión, una sincronización en background o un refresco silencioso necesitan acceder al dato tras el primer desbloqueo, puede tener sentido usar kSecAttrAccessibleAfterFirstUnlock. Si el dato no debe sobrevivir a una restauración en otro dispositivo, conviene usar una variante ThisDeviceOnly.
struct LicenseRepository {
private let store = SecureStore(service: "com.example.writer.license")
func activate(with key: String) throws {
try store.save(key, for: "editor-pro")
}
func currentLicenseKey() throws -> String? {
try store.read(account: "editor-pro")
}
func deactivate() throws {
try store.delete(account: "editor-pro")
}
}
🧪 Este ejemplo mantiene el wrapper separado del caso de uso. SecureStore no sabe nada de licencias, usuarios ni pantallas. LicenseRepository, en cambio, expresa intención de producto. Esta separación ayuda mucho cuando el proyecto crece, porque permite sustituir la implementación, crear mocks para tests o centralizar decisiones de seguridad sin repartir llamadas a SecItemAdd por toda la app.
🧬 Keychain también puede compartirse entre varias apps del mismo equipo mediante Keychain Access Groups. Esto resulta útil cuando una app principal y una extensión necesitan acceder al mismo secreto, o cuando varias apps de una misma organización comparten autenticación. Pero no basta con usar el mismo nombre de grupo: Apple lo valida mediante los entitlements, provisioning profiles y la firma de código. Esa restricción es precisamente lo que impide que otra app cualquiera lea tus elementos.
let sharedAccessGroup = "ABCDE12345.com.example.shared"
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.example.writer.sync",
kSecAttrAccount as String: "sync-token",
kSecAttrAccessGroup as String: sharedAccessGroup,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
⚠️ Aun así, compartir el Keychain entre targets no debería ser la opción por defecto. Cada grupo de acceso amplía el conjunto de procesos que pueden leer el secreto. Si una extensión solo necesita recibir un resultado ya procesado, quizá sea mejor pasarle datos menos sensibles mediante App Groups, o diseñar un flujo en el que la app principal sea la única que toca las credenciales. Seguridad también significa minimizar quién necesita saber qué.
🔒 Otro punto avanzado es el control de acceso con autenticación local. El Keychain puede exigir validación del usuario mediante Face ID, Touch ID o código del dispositivo antes de desbloquear el acceso a un elemento. Esto no sustituye al login de la app, pero sí añade una capa útil para datos especialmente sensibles: una clave de exportación, una firma criptográfica o una credencial que no debería estar disponible solo por estar el dispositivo desbloqueado.
import LocalAuthentication
import Security
func makeAccessControl() throws -> SecAccessControl {
var error: Unmanaged<CFError>?
guard let accessControl = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[.userPresence],
&error
) else {
throw error!.takeRetainedValue() as Error
}
return accessControl
}
func saveExportKey(_ key: Data) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.example.writer.export",
kSecAttrAccount as String: "document-signing-key",
kSecValueData as String: key,
kSecAttrAccessControl as String: try makeAccessControl()
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw SecureStoreError.unhandledStatus(status)
}
}
🧠 Cuando se usa SecAccessControl, hay que tener cuidado con la experiencia de usuario. Pedir Face ID para cada acceso puede ser seguro, pero también poco amigable. Normalmente conviene reservarlo para acciones de alto valor: revelar una clave, exportar datos, confirmar una operación irreversible o desbloquear un área sensible de la app. Para leer un token de sesión en cada arranque, suele ser demasiado agresivo.
🧯 También conviene recordar lo que el Keychain no soluciona. No protege contra que muestres el secreto en un logs. No impide que lo mantengas en memoria más tiempo del necesario. No arregla una API que devuelve tokens demasiado duraderos. No evita que un atacante use una sesión activa si tu app no valida correctamente el estado del usuario. El Keychain protege el almacenamiento, pero la seguridad real depende del ciclo completo del secreto.
🧹 Una buena práctica es tratar los secretos como recursos con ciclo de vida. Se crean al iniciar sesión o activar una funcionalidad, se leen cuando hacen falta, se actualizan al rotar credenciales y se eliminan al cerrar sesión o revocar acceso. El cierre de sesión debería limpiar tanto el estado visible como los elementos de Keychain asociados. Dejar tokens antiguos “por si acaso” suele terminar generando estados inconsistentes y riesgos innecesarios.
struct SessionCleaner {
private let credentialsStore = SecureStore(service: "com.example.writer.credentials")
private let licenseStore = SecureStore(service: "com.example.writer.license")
func removeLocalSecrets() throws {
try credentialsStore.delete(account: "api-token")
try credentialsStore.delete(account: "refresh-token")
try licenseStore.delete(account: "editor-pro")
}
}
🚀 En proyectos Swift modernos, mi recomendación es no exponer el Keychain directamente al resto de la app. Crea un wrapper pequeño, tipado y testado. Define los servicios y cuentas en un único sitio. Decide explícitamente las restricciones de acceso para cada secreto. Evita guardar objetos grandes o estructuras complejas si solo necesitas un token. Y, sobre todo, no trates el Keychain como un cajón genérico de persistencia segura.
✅ El Keychain brilla cuando se usa para lo que es: un almacén del sistema para secretos pequeños, con cifrado, aislamiento entre apps, control de accesibilidad y soporte para autenticación local. Entender estas piezas permite tomar mejores decisiones que simplemente “guardar el token en el Keychain”. En seguridad, la API importa, pero el criterio con el que eliges cada opción importa todavía más.