El primer error de seguridad que veo en apps iOS casi siempre es el mismo: un token de sesión guardado en UserDefaults. Funciona, el código es de una línea, y por eso se cuela. El problema es que UserDefaults guarda todo en un archivo .plist en texto plano dentro del contenedor de la app, y ese archivo entra en las copias de seguridad. Cualquiera con acceso al backup del dispositivo, o al propio archivo, lee el token sin esfuerzo.
Esta serie es sobre los fundamentos que un dev iOS debería tener resueltos antes de publicar. Empezamos por el más básico y el más ignorado: dónde vive un secreto en el dispositivo.
Qué es el Keychain, en corto
El Keychain es el almacén cifrado del sistema para datos pequeños y sensibles: tokens, contraseñas, claves. No es una base de datos para tus modelos, es una caja fuerte para credenciales. Lo que guardas ahí se cifra con claves ligadas al dispositivo y, según cómo lo configures, al código de desbloqueo del usuario.
La API es la de Security framework (SecItem...). Es una API de C, así que en Swift se siente más áspera que un UserDefaults.standard.set(...). Esa aspereza es la razón por la que la gente la evita, y por la que vale la pena encapsularla una vez y olvidarse.
Guardar un token
Lo mínimo para guardar un valor:
import Security
import Foundation
enum KeychainError: Error {
case unexpectedStatus(OSStatus)
}
func guardarToken(_ token: String, cuenta: String) throws {
let data = Data(token.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: cuenta,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
]
// El Keychain no sobrescribe: si el ítem ya existe, primero lo borramos.
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.unexpectedStatus(status)
}
}
Dos detalles importan aquí.
El primero es que SecItemAdd falla con errSecDuplicateItem si ya hay un valor para esa cuenta. Por eso el SecItemDelete antes: en la práctica casi siempre quieres “guarda o reemplaza”. La alternativa más fina es usar SecItemUpdate, pero para un token que rota, borrar y volver a insertar es más simple y no deja estados raros.
El segundo es kSecAttrAccessible, que es donde se juega casi toda la seguridad de este código.
El atributo que casi nadie configura
kSecAttrAccessible decide cuándo el sistema te deja leer el ítem. El valor que puse arriba, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, es el más estricto y el que recomiendo por defecto. Significa dos cosas:
- WhenUnlocked: el ítem solo es legible con el dispositivo desbloqueado. Si la app intenta leerlo en segundo plano con la pantalla bloqueada, falla.
- ThisDeviceOnly: el ítem nunca sale de este dispositivo. No entra en las copias de seguridad de iCloud ni iTunes, y no se migra a un iPhone nuevo.
Ese ThisDeviceOnly es la parte que la gente se salta, porque el valor por defecto (kSecAttrAccessibleAfterFirstUnlock, sin el sufijo) sí entra en los backups. Un token de sesión no tiene ninguna razón para viajar en un backup a otro dispositivo. Si necesitas acceso en segundo plano (por ejemplo, para refrescar datos con la pantalla bloqueada), sube solo un escalón a kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, pero mantén el ThisDeviceOnly.
Un apunte que sorprende: si el usuario no tiene código de desbloqueo, las protecciones ligadas al desbloqueo pierden fuerza. El Keychain sigue funcionando, pero el cifrado que depende del passcode no está. No puedes obligar a tener passcode, pero sí conviene saberlo.
Leer y borrar
La lectura sigue el mismo patrón de diccionario:
func leerToken(cuenta: String) throws -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: cuenta,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var resultado: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &resultado)
switch status {
case errSecSuccess:
guard let data = resultado as? Data else { return nil }
return String(decoding: data, as: UTF8.self)
case errSecItemNotFound:
return nil
default:
throw KeychainError.unexpectedStatus(status)
}
}
func borrarToken(cuenta: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: cuenta,
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.unexpectedStatus(status)
}
}
Fíjate en cómo trato errSecItemNotFound: no es un error, es “no hay nada guardado”. Distinguir ese caso del fallo real evita que un catch genérico te oculte un problema de verdad. Tragarse el OSStatus es otro error frecuente; ese número te dice exactamente qué pasó, y conviene propagarlo.
Poner un secreto detrás de Face ID
A veces quieres que leer un valor exija presencia del usuario: un PIN de pago, una frase de recuperación. Para eso está el control de acceso, que ata el ítem a la biometría:
func guardarSecretoConBiometria(_ secreto: String, cuenta: String) throws {
var error: Unmanaged<CFError>?
guard let acceso = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryCurrentSet,
&error
) else {
throw error!.takeRetainedValue() as Error
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: cuenta,
kSecValueData as String: Data(secreto.utf8),
kSecAttrAccessControl as String: acceso,
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.unexpectedStatus(status)
}
}
La bandera .biometryCurrentSet es más fuerte de lo que parece. No solo exige Face ID o Touch ID para leer el valor: invalida el ítem si el usuario agrega o quita una huella o rehace el Face ID. La idea es que si alguien registra su propia biometría en un dispositivo comprometido, el secreto guardado deja de ser accesible. Si prefieres permitir el código de desbloqueo como alternativa a la biometría, usa .userPresence en lugar de .biometryCurrentSet.
Cuando el ítem tiene control de acceso, la lectura dispara el prompt de Face ID por sí sola, sin que tengas que invocar LocalAuthentication a mano. El sistema se encarga.
Un par de trampas que cuestan caro
Hay dos comportamientos del Keychain que muerden si no los conoces.
Uno: los datos del Keychain pueden sobrevivir a la desinstalación de la app. A diferencia del contenedor de archivos, que se borra al desinstalar, un ítem del Keychain puede quedar ahí y reaparecer cuando el usuario reinstala. No lo trates como almacenamiento que se limpia solo. Si tu lógica necesita que al reinstalar todo empiece limpio, borra tus ítems en el primer arranque.
Dos: el atributo kSecAttrAccessGroup y los grupos de acceso. Si compartes Keychain entre tu app y una extensión, o entre apps del mismo equipo, ese es el mecanismo. Configurado mal, terminas guardando en un grupo y leyendo de otro, y pasas una tarde entera preguntándote por qué leerToken siempre devuelve nil.
Lo que llevamos
El resumen práctico de esta parte cabe en cuatro líneas:
- Tokens y credenciales van al Keychain, nunca a
UserDefaultsni a archivos planos. - Usa
kSecAttrAccessibleWhenUnlockedThisDeviceOnlysalvo que de verdad necesites acceso en segundo plano. - Distingue
errSecItemNotFounddel error real, y no te tragues elOSStatus. - Para secretos que exigen presencia del usuario, ata el ítem a la biometría con control de acceso.
Qué sigue
El Keychain protege secretos pequeños. Pero tu app también guarda archivos: una base de datos local, imágenes cacheadas, un JSON con el perfil del usuario. Todo eso vive en el disco, y por defecto no está tan protegido como crees.
En la parte 2 vemos Data Protection: cómo iOS cifra los archivos en reposo, qué clases de protección existen, y por qué un archivo marcado como accesible siempre es una puerta abierta cuando el dispositivo está bloqueado.
Fuentes: Keychain Services (Apple) · SecAccessControlCreateWithFlags (Apple)