9.2 — Secure Data Storage
Opening scenario
A pentester opens your app’s container on a jailbroken iPhone and finds UserDefaults.plist containing a raw OAuth refresh token. With that token they can impersonate the user from anywhere for the next 30 days. The bug took 4 lines of code to introduce; preventing it takes the same 4 lines pointed at a different API. This chapter is the map of where to put what.
Context — the iOS storage hierarchy
| Mechanism | Encrypted at rest? | Survives reinstall? | Backed up to iCloud? | Use for |
|---|---|---|---|---|
UserDefaults | At-rest via Data Protection (when locked) | No | Yes | Non-sensitive preferences |
FileManager (Documents) | Yes (Data Protection) | No | Yes | User-generated content |
FileManager (Library/Caches) | Yes | No | No | Re-downloadable cache |
FileManager (tmp) | Yes | No | No | Throwaway |
| Core Data / SwiftData (default) | Yes (Data Protection) | No | Configurable | Structured app data |
| Core Data + SQLCipher | Yes + app-level key | No | Configurable | Highly sensitive structured data |
| Keychain (default) | Yes + hardware-bound | Yes | Configurable | Tokens, passwords, small secrets |
| Secure Enclave | N/A (keys never leave SE) | Yes | No | Private keys for signing |
The single most-violated rule: secrets go in Keychain, not UserDefaults. UserDefaults is Data-Protected when the device is locked, but readable freely when unlocked — including by file-system inspection on jailbroken devices. Keychain entries are tied to the device hardware and accessible only to your app (or your app group / team).
Keychain deep dive
The Keychain API is C-flavored CFDictionary-based. Wrap it once, never expose the raw API:
import Security
import Foundation
enum KeychainError: Error { case unhandled(OSStatus) }
struct Keychain {
static func set(_ data: Data, for key: String) throws {
let q: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
]
SecItemDelete(q as CFDictionary)
var add = q
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let status = SecItemAdd(add as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.unhandled(status) }
}
static func get(_ key: String) -> Data? {
let q: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: AnyObject?
guard SecItemCopyMatching(q as CFDictionary, &result) == errSecSuccess else { return nil }
return result as? Data
}
static func delete(_ key: String) {
let q: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
]
SecItemDelete(q as CFDictionary)
}
}
Accessibility classes (the most important parameter)
| Constant | When readable | iCloud Keychain syncs |
|---|---|---|
kSecAttrAccessibleWhenUnlocked | Device unlocked | Yes if app opts in |
kSecAttrAccessibleWhenUnlockedThisDeviceOnly | Device unlocked | No |
kSecAttrAccessibleAfterFirstUnlock | After first unlock since boot | Yes if app opts in |
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly | After first unlock since boot | No |
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly | Only if user has a passcode set; device unlocked | No |
Default to AfterFirstUnlockThisDeviceOnly for background-friendly secrets (BGAppRefresh needs them, but you don’t want them on a restored backup on a different device). Bump to WhenUnlockedThisDeviceOnly for high-value items that should never be readable from background. Use WhenPasscodeSetThisDeviceOnly for items that fundamentally require a passcoded device.
Keychain access groups + extensions
Sharing between an app and its extension (widget, share extension) requires a keychain access group:
let q: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecAttrAccessGroup as String: "TEAMID.com.acme.shared",
]
The access group must be declared in both targets’ entitlements. Without it, your widget cannot read the token your app wrote.
Data Protection classes (file-level)
File-system files have their own protection class:
let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("vault.dat")
try data.write(to: url, options: [.completeFileProtection])
| Option | When readable |
|---|---|
.noFileProtection | Always (anti-pattern; only for cache that must work pre-first-unlock) |
.completeUntilFirstUserAuthentication | After first unlock since boot |
.completeUnlessOpen | Locked except while file is open |
.complete | Only while device unlocked |
Default for new files is .completeUntilFirstUserAuthentication. Upgrade to .complete for high-sensitivity files; understand it’ll block background access while the screen is locked.
What never to put in UserDefaults
- Auth tokens (access, refresh, session)
- Passwords or password hashes
- API keys with non-revocable scope
- PII (email, phone, address, government IDs)
- Encryption keys
- Webhook secrets
Pattern of replacement: each UserDefaults.standard.set(token, forKey:) becomes try Keychain.set(tokenData, for: "auth.token"). Add a lint rule that flags UserDefaults writes from any file matching *Auth* or *Secret*.
Encrypted Core Data / SwiftData
The default SwiftData / Core Data store is Data-Protected but not app-key encrypted — anyone with file-system access (jailbreak, forensic tools) and the device passcode can read it. For genuinely sensitive structured data:
- SQLCipher — drop-in SQLite replacement with AES-256 encryption. Use via GRDB.swift. Key derivation through
CryptoKitHKDF, key stored in Keychain. - Manual envelope encryption — encrypt sensitive fields at the model layer before persistence, decrypt on read. More work, but Core Data introspection remains useful.
Don’t roll your own. SQLCipher is battle-tested and the GRDB wrapper is clean.
Secure Enclave for private keys
For asymmetric cryptography (signing, key exchange), the Secure Enclave is a separate co-processor that generates and holds keys the main CPU never sees:
import CryptoKit
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
kSecPrivateKeyAttrs as String: [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: "com.acme.signing".data(using: .utf8)!,
],
]
var error: Unmanaged<CFError>?
let key = SecKeyCreateRandomKey(attributes as CFDictionary, &error)
// Sign with key; private key never leaves the SE.
Only ECC P-256 keys are supported in the SE. Use for: signing user-facing tokens, Passkeys, app-attestation receipts, anything where loss of the private key would be catastrophic.
In the wild
1Password’s iOS app uses Keychain for the master vault key with kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, plus SQLCipher for the local vault database keyed by a derived key. Restoring a backup to a new device intentionally cannot unlock the vault — the user must re-authenticate with the master password.
Common misconceptions
- “UserDefaults is encrypted enough.” Only when locked, and trivially readable on jailbreak. Never for secrets.
- “Keychain is automatically shared with extensions.” No — explicit
kSecAttrAccessGroup+ entitlement required. - “Items in Keychain survive app uninstall.” They do on iOS by default (changed historically). Delete deliberately on logout/reset.
- “Secure Enclave can store arbitrary data.” No — only ECC P-256 keys. For other secrets, store the key handle in SE and encrypted blob in Keychain or file.
- “
.completeData Protection is always best.” It breaks background tasks running while the device is locked (BGAppRefresh, location updates). Match the class to the workload.
Seasoned engineer’s take
Storage decisions are quietly the most consequential security decisions in an iOS app. They’re invisible in code review unless someone is looking; they show up in audit reports years later. Build a thin SecureStorage abstraction with three methods (storeSecret, readSecret, deleteSecret) and a single internal implementation that picks the right primitive per data sensitivity tag. Force every callsite through it; ban direct UserDefaults/FileManager writes for anything tagged sensitive.
TIP: For brand-new projects, never call
UserDefaultsfor anything but UI preferences. Even a launch counter belongs in a wrapper that you can later swap. The discipline pays for itself the first time someone tries to stash a token there.
WARNING:
kSecAttrAccessibleAlwaysexists but is deprecated/discouraged. If you see it in a legacy codebase, treat it as a bug — it lets the secret be read while the device is locked, defeating the whole point.
Interview corner
Junior: “Where would you store an auth token?”
Keychain, with accessibility AfterFirstUnlockThisDeviceOnly for typical background-friendly tokens, or WhenUnlockedThisDeviceOnly for high-value tokens that shouldn’t be read while the screen is locked. Never UserDefaults.
Mid: “Why prefer *ThisDeviceOnly accessibility classes?”
They opt out of iCloud Keychain sync. If a user restores a backup to a different device, the secret doesn’t follow — which is usually what you want for tokens, since you’d rather force re-authentication than risk cross-device token replay.
Senior: “Design the storage layer for a banking app — what goes where?”
I’d build a SecureStorage facade with sensitivity tags: public, private, secret, top-secret. Public lives in UserDefaults. Private lives in Documents with .completeUntilFirstUserAuthentication Data Protection. Secret — auth tokens, API keys — Keychain with WhenUnlockedThisDeviceOnly. Top-secret — payment authorization keys — Secure Enclave for the private key plus SQLCipher for the encrypted local database, with the SQLCipher key derived via HKDF from a Keychain-stored seed gated by biometrics. Backups: explicitly exclude the SQLCipher store from iCloud via URLResourceKey.isExcludedFromBackupKey so a restored device doesn’t ship an encrypted-but-extractable database. Then I’d ban direct UserDefaults/FileManager access for anything but UI prefs via SwiftLint rule, forcing every persistence path through the facade. The facade is testable, swappable, and audit-friendly.
Red-flag answer: “We encrypt it ourselves with AES.” Hand-rolled storage encryption is M10. Use CryptoKit or SQLCipher; never invent.
Lab preview
Lab 9.1 (Secure Notes App) starts with an intentionally insecure version storing notes plaintext in UserDefaults. You’ll migrate to Keychain-keyed SQLCipher with biometric unlock.