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

MechanismEncrypted at rest?Survives reinstall?Backed up to iCloud?Use for
UserDefaultsAt-rest via Data Protection (when locked)NoYesNon-sensitive preferences
FileManager (Documents)Yes (Data Protection)NoYesUser-generated content
FileManager (Library/Caches)YesNoNoRe-downloadable cache
FileManager (tmp)YesNoNoThrowaway
Core Data / SwiftData (default)Yes (Data Protection)NoConfigurableStructured app data
Core Data + SQLCipherYes + app-level keyNoConfigurableHighly sensitive structured data
Keychain (default)Yes + hardware-boundYesConfigurableTokens, passwords, small secrets
Secure EnclaveN/A (keys never leave SE)YesNoPrivate 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)

ConstantWhen readableiCloud Keychain syncs
kSecAttrAccessibleWhenUnlockedDevice unlockedYes if app opts in
kSecAttrAccessibleWhenUnlockedThisDeviceOnlyDevice unlockedNo
kSecAttrAccessibleAfterFirstUnlockAfter first unlock since bootYes if app opts in
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnlyAfter first unlock since bootNo
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnlyOnly if user has a passcode set; device unlockedNo

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])
OptionWhen readable
.noFileProtectionAlways (anti-pattern; only for cache that must work pre-first-unlock)
.completeUntilFirstUserAuthenticationAfter first unlock since boot
.completeUnlessOpenLocked except while file is open
.completeOnly 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 CryptoKit HKDF, 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

  1. “UserDefaults is encrypted enough.” Only when locked, and trivially readable on jailbreak. Never for secrets.
  2. “Keychain is automatically shared with extensions.” No — explicit kSecAttrAccessGroup + entitlement required.
  3. “Items in Keychain survive app uninstall.” They do on iOS by default (changed historically). Delete deliberately on logout/reset.
  4. “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.
  5. .complete Data 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 UserDefaults for 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: kSecAttrAccessibleAlways exists 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.


Next: 9.3 — Network Security & TLS Pinning