iOS – Persistence, UserDefaults and the Keychain

August 28, 20268 min readUpdated 9/18/2026

"Where do I save this?" has four reasonable answers on iOS, and picking the wrong one is occasionally a security incident rather than a preference. This lesson is the decision, then the Keychain in detail, because it is the one with an API that fights back.

The four places

UserDefaults — small, unencrypted, key-value. Preferences, the last tab, a feature flag. Readable by anyone with a backup of the device.

The Keychain — small, encrypted, survives an app update, wiped on uninstall. Tokens, passwords, anything you would be embarrassed to see in a backup.

The file system — documents, images, downloaded data. Sandboxed per app, and the directory you choose decides whether it is backed up and whether the system may delete it under disk pressure.

A database — SwiftData or Core Data, for structured data you query. Worth it when you have relationships and predicates, overkill when you have a token and a cart id.

Naming the keys once

enum StorageKey: String, CaseIterable {
    /// The JWT. Goes in the Keychain, never in `UserDefaults`.
    case authToken = "pizza.token"
    /// Which server-side cart belongs to this device. Not a secret — see `CartIdentifierStore`.
    case cartIdentifier = "pizza.cartId"
}

A typo in a storage key fails silently — the read returns nil and the app behaves as if the customer had never signed in. Naming them in one enum removes that whole class of bug, and it makes it obvious at a glance what this app leaves on the device, which is a question worth being able to answer quickly.

Two protocols, because the split is a decision

protocol SecureStore: Sendable {
    func string(for key: StorageKey) throws -> String?
    func set(_ value: String, for key: StorageKey) throws
    func removeValue(for key: StorageKey) throws
}
protocol KeyValueStore: Sendable {
    func string(for key: StorageKey) -> String?
    func set(_ value: String, for key: StorageKey)
    func removeValue(for key: StorageKey)
}

Two protocols with almost the same shape, and keeping them separate is the point. It makes the decision explicit at every call site: a value written to the plain store is readable by anyone with a backup, so putting a token there has to be a mistake somebody can see in review rather than one that hides behind a shared API.

Note that only the secure one throws. A Keychain operation can genuinely fail — UserDefaults effectively cannot, and pretending otherwise would put a try at every call site for nothing.

The Keychain

The web equivalent of a session token is localStorage, with an apology that any XSS bug can read it. On a phone there is a better answer: the Keychain is encrypted, inaccessible to other apps, and survives an app update while being wiped on uninstall.

The accessibility class is the decision that matters

        query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly

Two halves, both deliberate.

AfterFirstUnlock means readable once the customer has unlocked the phone since boot, so a background refresh still works with the screen locked. WhenUnlocked is stricter and would break any future background task; Always is deprecated and unencrypted at rest.

ThisDeviceOnly means never copied into an iCloud Keychain backup. A session token is bound to this device, and syncing one to a restored iPad hands a live session to a machine the customer may no longer control.

This is the line to think hardest about. The default if you omit it is kSecAttrAccessibleWhenUnlocked, which is reasonable but syncs.

Save is delete-then-add, and that is not a mistake

    func set(_ value: String, for key: StorageKey) throws {
        guard let data = value.data(using: .utf8) else { throw Failure.unreadableData }

        // Delete first: SecItemAdd returns errSecDuplicateItem for an existing key.
        try? removeValue(for: key)

        var query = baseQuery(for: key)
        query[kSecValueData as String] = data
        query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly

        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else { throw Failure.unexpectedStatus(status) }
    }

SecItemUpdate fails when the item is absent and SecItemAdd fails when it is present. Delete-then-add is not elegant; it is the only spelling that is correct in both states without an extra round trip to find out which one applies.

Absent is not an error

        switch status {
        case errSecSuccess:
            guard let data = item as? Data, let value = String(data: data, encoding: .utf8) else {
                throw Failure.unreadableData
            }
            return value
        case errSecItemNotFound:
            // Absent is a normal state — nobody has signed in yet — not an error.
            return nil
        default:
            throw Failure.unexpectedStatus(status)
        }

Treating errSecItemNotFound as a failure means every caller writes a catch that turns it back into nil. Three cases, and the middle one is the common path.

The service attribute

kSecAttrService namespaces your items, and the bundle identifier is the conventional value. It matters as soon as you have a second target — an extension, a widget — because without it they would collide, and with an access group they can deliberately share.

Wrapping it in an actor

The raw store is synchronous and throwing. What the app wants is something safe to touch from several places at once:

actor TokenStore: AuthTokenProviding {
    private let secureStore: SecureStore
    private var cachedToken: String?
    private var hasLoaded = false

The token is genuinely contended: the HTTP client reads it on every authenticated request, sign-in writes it, sign-out clears it — and a customer can tap "Sign out" while a refresh is in flight. An actor serialises that with no lock to remember.

It also caches, because a Keychain read is a syscall into securityd — not slow enough to see, but pointless to repeat dozens of times a session. "Load once, then reuse" is unsafe to write in a shared object without a lock; inside an actor it is just code.

Failures, and what they should mean

    func currentToken() async -> String? {
        if hasLoaded { return cachedToken }
        cachedToken = try? secureStore.string(for: .authToken)
        hasLoaded = true
        return cachedToken
    }

A Keychain read failure is swallowed to nil rather than thrown. The only honest interpretation of "we cannot read the token" is "there is no session", and every caller would have to turn a thrown error into exactly that anyway.

The write is the opposite and more interesting:

    func store(_ token: String) async {
        cachedToken = token
        hasLoaded = true
        do {
            try secureStore.set(token, for: .authToken)
        } catch {
            AppLog.storage.error("Could not persist the auth token: \(error.localizedDescription, privacy: .public)")
        }
    }

The write failed, but the in-memory token is still good. The session works for as long as the app is running; it simply will not survive a relaunch. Failing the sign-in over this would be a worse outcome than a session that is merely short-lived — and logging it means the failure is still visible to whoever has to explain it later.

The unencrypted half

struct CartIdentifierStore: Sendable {
    private let store: KeyValueStore

    init(store: KeyValueStore) { self.store = store }

    var identifier: UUIDString? { store.string(for: .cartIdentifier) }

    func save(_ identifier: UUIDString) { store.set(identifier, for: .cartIdentifier) }

The cart id is deliberately not in the Keychain. It identifies a basket, not a person, and the server treats it as a claim rather than an authorisation. Two reasons it would be the wrong home: the Keychain is for secrets and diluting that makes the rule harder to enforce, and Keychain items can survive app deletion in some configurations — a reinstalled app pointing at a stranger's abandoned cart is a bug that would be very hard to explain.

Fakes for both

final class InMemorySecureStore: SecureStore, @unchecked Sendable {
    private var storage: [StorageKey: String] = [:]
    private let lock = NSLock()

    init(seed: [StorageKey: String] = [:]) { storage = seed }

Not a nicety. A preview runs in a host process with no Keychain entitlement, so the real store fails there — and a test that shares the Keychain with the app under development is a test that passes or fails depending on whether somebody happened to be signed in. The seed parameter is what lets a test start from "already signed in" in one line.

Testing storage

With both stores behind protocols, the interesting behaviour is testable without touching the device at all:

    func testItReadsTheKeychainOnceAndCachesAfterwards() async {
        let secureStore = CountingSecureStore(seed: [.authToken: "abc.123"])
        let store = TokenStore(secureStore: secureStore)

        let first = await store.currentToken()
        let second = await store.currentToken()

        XCTAssertEqual(first, "abc.123")
        XCTAssertEqual(second, "abc.123")
        XCTAssertEqual(secureStore.readCount, 1, "Every authenticated request would otherwise hit securityd.")
    }

Counting the reads is the only way to test a cache — the return value is identical either way. The two failure cases are worth testing explicitly too: that an unreadable Keychain reads as "no session", and that a failed write still leaves a usable one. Both are judgement calls written into the code, and a test is how they stay judgement calls rather than becoming accidents.

UserDefaults is thread-safe, but not Sendable

struct UserDefaultsKeyValueStore: KeyValueStore, @unchecked Sendable {
    private let defaults: UserDefaults

Apple documents UserDefaults as thread-safe, but it predates Sendable and has never been annotated, so the compiler cannot know. @unchecked is the escape hatch for exactly this: a promise the author is making on the type's behalf. It should never appear without a comment saying who made the promise and why it holds — an unexplained @unchecked Sendable is indistinguishable from someone silencing a warning they did not read.

What about SwiftData?

SwiftData is the modern persistence framework: declare a @Model, query it with @Query, and SwiftUI updates as it changes. It is genuinely pleasant, and this app does not use it because it has nothing to store — the catalogue comes from the server on launch and the cart lives in the backend so it survives a reinstall and a second device.

Reach for it when you have data that is yours: notes, offline drafts, a local cache with relationships worth querying. Reach past it when your data belongs to a server and the device is a view onto it. Adding a database to hold a copy of something you are going to re-fetch anyway buys a synchronisation problem.

A short checklist

  • Tokens, passwords, keys → Keychain, ThisDeviceOnly unless you have a reason.
  • Preferences and non-secret identifiers → UserDefaults.
  • Files the customer would miss → Documents. Re-downloadable caches → Caches, which the system may purge.
  • Never log a token, and never put one in an analytics event. Logger redacts interpolated values by default, which is one more reason to prefer it over print.

Next

Reading the Keychain is asynchronous, which forces a decision at launch that the web never has to make. The next lesson is the app lifecycle: the launch gate, and the write you must flush before the system suspends you.