# Migrating Secrets Out of Legacy Stores

Scope: moving credentials that an older release kept in `UserDefaults`, property lists or `NSCoding` archives into the keychain, deleting the old copies safely, wiping keychain leftovers after a reinstall, running migrations by schema version, and surviving a Team ID change after an app transfer.

Availability: the patterns rely on actors and on iOS 15 pre-warming behaviour, so they apply from iOS 15. Write new code against iOS 17 or later where you can.

Related references: [keychain-fundamentals.md](keychain-fundamentals.md) (add-or-update, OSStatus), [keychain-access-control.md](keychain-access-control.md) (accessibility classes), [common-anti-patterns.md](common-anti-patterns.md) (patterns 1 and 9), [credential-storage-patterns.md](credential-storage-patterns.md) (token lifecycles), [testing-security-code.md](testing-security-code.md) (mocks and device tests).

## Contents

- [Why the old stores are not acceptable](#why-the-old-stores-are-not-acceptable)
- [Five traps to avoid](#five-traps-to-avoid)
- [Cleaning up after a reinstall](#cleaning-up-after-a-reinstall)
- [Atomic migration: read, write, verify, delete](#atomic-migration-read-write-verify-delete)
- [Versioned migration with a schema number](#versioned-migration-with-a-schema-number)
- [Orphaned items after a rename](#orphaned-items-after-a-rename)
- [Locked devices and background launches](#locked-devices-and-background-launches)
- [The phantom mismatch](#the-phantom-mismatch)
- [Team ID change after an app transfer](#team-id-change-after-an-app-transfer)
- [Cleaning up legacy artefacts](#cleaning-up-legacy-artefacts)
- [Launch order](#launch-order)
- [Concurrency](#concurrency)
- [Testing migrations](#testing-migrations)
- [Very old versions](#very-old-versions)
- [Deleting files](#deleting-files)
- [Key decisions](#key-decisions)
- [Checklist](#checklist)

## Why the old stores are not acceptable

`UserDefaults`, a hand-written `.plist` and a keyed archive are all plain files inside the app container. Nothing encrypts their contents beyond the file's data protection class, so anyone with a jailbroken device or an unencrypted Finder or iTunes backup can open them. OWASP files this under M9, Insecure Data Storage.

| Property | UserDefaults / plist / NSCoding archive | Keychain |
| --- | --- | --- |
| Encryption of the value | None beyond file protection | AES-256-GCM per item |
| Included in backups | Yes | Yes, except `...ThisDeviceOnly` classes |
| Removed when the app is deleted | Yes | No, items outlive the app |
| Fit for secrets | No | Yes |

Keychain rows live in a database owned by the `securityd` daemon, outside the app sandbox. Each row is encrypted with its own key, and those keys are wrapped by class keys that the Secure Enclave protects.

## Five traps to avoid

1. **Believing deletion is the risk.** `removeObject(forKey:)` does not scrub flash storage, but it does not need to: iOS protects each file with its own AES-256 key and destroys that key through Effaceable Storage when the file goes away, which is cryptographic erasure. The exposure that remains is backups taken, unencrypted, before the migration ran. Delete each legacy key explicitly once its keychain copy is verified.
2. **Forgetting that keychain items outlive the app.** An iOS 10.3 beta briefly removed items on uninstall; the change was reverted and items still persist. After a reinstall, stale entries cause confusing authentication failures or silently resume the previous owner's session.
3. **Migrating on every launch.** Besides wasted work, a pre-warmed launch on iOS 15 and later can start before first unlock, when the encrypted `UserDefaults` file cannot be read and returns nothing. Code that reads "empty" as "nothing to migrate" skips data or writes nil over good keychain values.
4. **Treating write and delete as unrelated steps.** If the process dies between them, or the write failed silently, the secret is gone.
5. **Renaming the item's identity.** Changing `kSecAttrService` or `kSecAttrAccount` produces a new item and strands the old one as a duplicate. `SecItemUpdate` cannot change primary-key attributes, so a rename is always read old, write new, verify, delete old.

## Cleaning up after a reinstall

The two stores behave differently on uninstall: `UserDefaults` is removed, the keychain is not. A missing marker in `UserDefaults` together with keychain content therefore means "fresh install over old data".

Run this before anything else touches the keychain. Analytics, crash reporting, backend-as-a-service and authentication SDKs often read the keychain while they initialise, and would pick up the old session.

Wait for protected data before reading the marker. During pre-warm both `UserDefaults` and `WhenUnlocked` keychain items are unreadable, so the marker looks unset and the guard would wipe a signed-in user. Widely used apps mass-logged-out their users on iOS 15 by treating that empty read as "no credentials".

```swift
import Security
import UIKit

actor ReinstallSweeper {
    private let markerKey = "vault.installMarker"
    private let itemClasses: [CFString] = [
        kSecClassGenericPassword, kSecClassInternetPassword,
        kSecClassCertificate, kSecClassKey, kSecClassIdentity
    ]

    func sweepIfFreshInstall() async {
        await Self.waitUntilProtectedDataIsReadable()
        guard !UserDefaults.standard.bool(forKey: markerKey) else { return }
        for itemClass in itemClasses {
            let scope: [CFString: Any] = [
                kSecClass: itemClass,
                kSecAttrSynchronizable: kSecAttrSynchronizableAny
            ]
            let status = SecItemDelete(scope as CFDictionary)
            guard status == errSecSuccess || status == errSecItemNotFound else { return }
        }
        UserDefaults.standard.set(true, forKey: markerKey)
    }

    @MainActor
    static func waitUntilProtectedDataIsReadable() async {
        guard !UIApplication.shared.isProtectedDataAvailable else { return }
        let signals = NotificationCenter.default.notifications(
            named: UIApplication.protectedDataDidBecomeAvailableNotification
        )
        for await _ in signals { return }
    }
}
```

`kSecAttrSynchronizable: kSecAttrSynchronizableAny` is required in every cleanup query. Without it `SecItemDelete` only matches non-synchronized items and leaves iCloud Keychain entries behind. If any delete fails, the marker stays unset and the sweep runs again next launch.

What goes wrong without it:

```swift
@main
struct LedgerApp: App {
    init() {
        SessionStore.shared.restoreTokenFromKeychain()
    }
    var body: some Scene { WindowGroup { RootView() } }
}
```

Here the token is restored with no reinstall check, so a new owner of a resold device, or a different person on a reinstalled app, inherits the previous session.

## Atomic migration: read, write, verify, delete

The order never changes. Removing the plaintext before the keychain write is confirmed is the single most dangerous mistake in this area.

```swift
import Foundation
import Security

protocol MigrationKeychainProtocol: Actor {
    func save(_ data: Data, account: String, accessibility: String) throws
    func read(account: String) throws -> Data?
    func delete(account: String) throws
    func deleteAll() throws
}

struct KeychainFailure: Error {
    let status: OSStatus
    init(_ status: OSStatus) { self.status = status }
}

enum KeyMigrationResult: Sendable, Equatable {
    case moved, nothingToMove, failed(reason: String)
}

actor DefaultsToKeychainMover {
    private let vault: any MigrationKeychainProtocol
    private let defaults: UserDefaults

    init(vault: any MigrationKeychainProtocol, defaults: UserDefaults = .standard) {
        self.vault = vault
        self.defaults = defaults
    }

    func move(keys: [String]) async -> [String: KeyMigrationResult] {
        var report: [String: KeyMigrationResult] = [:]
        for key in keys {
            guard let plaintext = defaults.string(forKey: key) else {
                report[key] = .nothingToMove
                continue
            }
            let payload = Data(plaintext.utf8)
            do {
                try await vault.save(payload, account: key,
                                     accessibility: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String)
                guard try await vault.read(account: key) == payload else {
                    report[key] = .failed(reason: "read-back mismatch")
                    continue
                }
                defaults.removeObject(forKey: key)
                report[key] = .moved
            } catch {
                report[key] = .failed(reason: String(describing: error))
            }
        }
        return report
    }
}
```

`save` is the add-or-update helper from [keychain-fundamentals.md](keychain-fundamentals.md): `SecItemAdd` first, and on `errSecDuplicateItem` a `SecItemUpdate` with the new data. A failed key keeps its `UserDefaults` value, so the result is idempotent: moved keys read as absent next time and are skipped, failed keys are retried after a crash, a force quit or a memory kill.

The protocol takes the accessibility class as a `String`. The `kSecAttrAccessible*` constants are `CFString` globals, which are not `Sendable`, and Swift 6 rejects handing one to another actor; `as String` makes a copy that is. The conforming type turns it back with `as CFString` when it builds the query.

The version to avoid:

```swift
let token = UserDefaults.standard.string(forKey: "authToken") ?? ""
UserDefaults.standard.removeObject(forKey: "authToken")
let item: [CFString: Any] = [kSecClass: kSecClassGenericPassword,
                             kSecAttrAccount: "authToken",
                             kSecValueData: Data(token.utf8)]
SecItemAdd(item as CFDictionary, nil)
```

The plaintext is gone before the add, and the add's status is ignored, so any failure loses the token.

## Versioned migration with a schema number

Store the schema version in the keychain, not in `UserDefaults`, so it survives a reinstall together with the items it describes.

```swift
import OSLog
import UIKit

enum MigrationState: Sendable, Equatable {
    case upToDate
    case migrated(from: Int, to: Int)
    case deferred(reason: String)
    case failed(reason: String)
}

enum MigrationError: Error { case keysLeftBehind(Int), corruptArchive, readBackMismatch }

actor SchemaMigrator {
    static let targetVersion = 3
    private let vault: any MigrationKeychainProtocol
    private let versionAccount = "vault.schemaVersion"
    private let log = OSLog(subsystem: "com.example.ledger", category: "KeychainMigration")

    init(vault: any MigrationKeychainProtocol) { self.vault = vault }

    func migrateIfNeeded() async -> MigrationState {
        let readable = await MainActor.run { UIApplication.shared.isProtectedDataAvailable }
        guard readable else { return .deferred(reason: "device locked") }
        do {
            let current = try await storedVersion()
            guard current < Self.targetVersion else { return .upToDate }
            for step in current..<Self.targetVersion {
                try await run(stepFrom: step)
            }
            try await vault.save(Data(String(Self.targetVersion).utf8), account: versionAccount,
                                 accessibility: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String)
            return .migrated(from: current, to: Self.targetVersion)
        } catch {
            os_log("migration stopped: %{public}@", log: log, type: .error,
                   String(describing: type(of: error)))
            return .failed(reason: String(describing: error))
        }
    }

    private func storedVersion() async throws -> Int {
        guard let raw = try await vault.read(account: versionAccount) else { return 0 }
        return Int(String(decoding: raw, as: UTF8.self)) ?? 0
    }

    private func run(stepFrom version: Int) async throws {
        switch version {
        case 0: try await moveDefaults()
        case 1: try await moveArchive()
        case 2: try await tightenAccessibility()
        default: break
        }
    }
}
```

The steps, one per version:

- **v0 to v1, UserDefaults.** Run `DefaultsToKeychainMover` over the named keys, log how many failed, and throw if any did so the version does not advance. Older samples call `UserDefaults.standard.synchronize()` afterwards; it is harmless and no longer needed.
- **v1 to v2, NSCoding archive.** If the archive file exists, decode it with `NSKeyedUnarchiver.unarchivedObject(ofClass:from:)` (a corrupt file throws), encode the modern session value as JSON, save it to the keychain, read it back, and only then call `FileManager.default.removeItem(at:)`.
- **v2 to v3, stricter accessibility.** Re-save every existing item with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` through add-or-update; the duplicate branch runs `SecItemUpdate` with the new `kSecAttrAccessible` in the attributes dictionary. Items that carry a `SecAccessControl` are the exception: changing their access control means delete and re-add.

```swift
extension SchemaMigrator {
    private func moveArchive() async throws {
        let file = URL.applicationSupportDirectory.appending(path: "Session.archive")
        guard FileManager.default.fileExists(atPath: file.path()) else { return }
        let raw = try Data(contentsOf: file)
        guard let old = try NSKeyedUnarchiver.unarchivedObject(ofClass: LegacySession.self, from: raw) else {
            throw MigrationError.corruptArchive
        }
        let modern = try JSONEncoder().encode(SessionRecord(userID: old.userID, accessToken: old.token))
        try await vault.save(modern, account: "session",
                             accessibility: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String)
        guard try await vault.read(account: "session") == modern else { throw MigrationError.readBackMismatch }
        try FileManager.default.removeItem(at: file)
    }
}
```

`LegacySession` stands for the app's existing `NSSecureCoding` class and `SessionRecord` for a new `Codable` struct.

A migration that runs every launch, skips the protected-data check, ignores `errSecDuplicateItem`, never reads back and never removes the `UserDefaults` copy shows every defect this file warns about.

Why a chain of single steps instead of direct jumps: each step is tested once and reused, a user skipping several releases simply runs every step in order, and a crash in the middle leaves the stored version at its old value so the next launch retries cleanly. Log through a dedicated `OSLog` subsystem with a `KeychainMigration` category so the steps can be filtered in Console.

## Orphaned items after a rename

`SecItemUpdate` with a new `kSecAttrService` in the attributes dictionary fails, because the service is part of the item's primary key. Rekey instead:

```swift
func rekey(accounts: [String], from oldService: String, to newService: String) throws {
    for account in accounts {
        let lookup: [CFString: Any] = [kSecClass: kSecClassGenericPassword,
                                       kSecAttrService: oldService,
                                       kSecAttrAccount: account,
                                       kSecReturnData: true]
        var found: CFTypeRef?
        let status = SecItemCopyMatching(lookup as CFDictionary, &found)
        if status == errSecItemNotFound { continue }
        guard status == errSecSuccess, let secret = found as? Data else { throw KeychainFailure(status) }
        try KeychainVault.upsert(secret, service: newService, account: account)
        guard try KeychainVault.read(service: newService, account: account) == secret else {
            throw KeychainFailure(errSecDecode)
        }
        let oldItem: [CFString: Any] = [kSecClass: kSecClassGenericPassword,
                                        kSecAttrService: oldService,
                                        kSecAttrAccount: account]
        SecItemDelete(oldItem as CFDictionary)
    }
}
```

`KeychainVault` stands for the app's add-or-update helper described in [keychain-fundamentals.md](keychain-fundamentals.md). The better fix is to never need this: choose `kSecAttrService` once, typically the bundle identifier, and keep it forever.

## Locked devices and background launches

Pre-warming and background work (remote notifications, background fetch, Live Activity updates) can run while the device is locked. The item's accessibility class decides whether reads succeed.

| Class | Readable in the background | Notes |
| --- | --- | --- |
| `WhenUnlocked` (default) | No | Foreground only, backed up and eligible for sync |
| `AfterFirstUnlockThisDeviceOnly` | Yes, after first unlock | Recommended; stays on this device |
| `AfterFirstUnlock` | Yes, after first unlock | Also backed up and eligible for sync; use only when needed |
| `WhenPasscodeSetThisDeviceOnly` | No | For biometric-gated items; removed if the passcode is removed |
| `Always` | Yes | Deprecated in iOS 12; do not use |

Migrated credentials default to `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`: usable from background code, never backed up, never synced.

On sync: every `...ThisDeviceOnly` class keeps the item on this device. Such an item is never restored to another device and never syncs through iCloud Keychain, and asking for both (`kSecAttrSynchronizable: true` with a `ThisDeviceOnly` class) is rejected by `SecItemAdd` with `errSecParam`. Pick a non-ThisDeviceOnly class only when the item is meant to sync.

`SecItemDelete` does not need the class key, so it succeeds even while the data is unreadable. That makes this pattern destructive:

```swift
if SecItemCopyMatching(query as CFDictionary, &out) != errSecSuccess {
    SecItemDelete(query as CFDictionary)
}
```

During a locked background launch the read returns `errSecInteractionNotAllowed` (-25308), the delete succeeds, and a valid token is gone. Map the statuses instead:

```swift
enum VaultError: Error { case lockedTryLater, unexpected(OSStatus) }

func readToken(_ query: [CFString: Any]) throws -> Data? {
    var out: CFTypeRef?
    let status = SecItemCopyMatching(query as CFDictionary, &out)
    switch status {
    case errSecSuccess: return out as? Data
    case errSecItemNotFound: return nil
    case errSecInteractionNotAllowed: throw VaultError.lockedTryLater
    default: throw VaultError.unexpected(status)
    }
}
```

`errSecInteractionNotAllowed` is neither "missing" nor a reason to delete; retry once protected data is available. Gate migration on `isProtectedDataAvailable`, defer it through `protectedDataDidBecomeAvailableNotification`, and never interpret an empty read on a locked device as "nothing to migrate".

## The phantom mismatch

Putting `kSecAttrAccessible` into a search query turns it into a filter. If the stored item has a different class, `SecItemCopyMatching` reports `errSecItemNotFound` while `SecItemAdd` reports `errSecDuplicateItem` for the same item, and the code loops between the two. Search with primary-key attributes only (`kSecClass`, `kSecAttrService`, `kSecAttrAccount`). Accessibility belongs in the `SecItemAdd` dictionary or in the attributes-to-update dictionary of `SecItemUpdate`.

## Team ID change after an app transfer

Moving the app to another developer account changes the Team ID, which is part of every keychain access group. All existing items become unreadable and every user is signed out. There is no recovery afterwards, so plan a bridge well before the transfer:

1. Ship an update under the old Team ID that exports the needed values into a temporary app-group container or an encrypted file in the sandbox.
2. Transfer the app.
3. In the first release under the new team, import the values, write them to the keychain, verify them, then delete the temporary copy.

## Cleaning up legacy artefacts

Secret values are deleted as soon as their keychain copy is verified, in the same step that moved them. Keeping plaintext credentials around "just in case" preserves exactly the exposure the migration exists to remove, and Apple's guidance is that `UserDefaults` and plain files are not for sensitive data.

A rollback window is still useful for what is left: non-secret legacy files and preference domains that an older build or a support engineer might want. Record the migration date in the keychain and clear those artefacts after roughly one release cycle, such as 30 days.

```swift
actor LegacyArtefactJanitor {
    private let vault: any MigrationKeychainProtocol
    private let window: TimeInterval = 30 * 24 * 60 * 60
    private let leftovers = ["SessionCache.archive", "Preferences.plist", "FeedCache.dat"]

    init(vault: any MigrationKeychainProtocol) { self.vault = vault }

    func sweepIfWindowElapsed(now: Date = .now) async throws {
        guard let stamp = try await vault.read(account: "vault.migratedAt"),
              let migratedAt = ISO8601DateFormatter().date(from: String(decoding: stamp, as: UTF8.self)),
              now.timeIntervalSince(migratedAt) > window else { return }
        let base = URL.documentsDirectory
        for name in leftovers {
            try? FileManager.default.removeItem(at: base.appending(path: name))
        }
        if let domain = Bundle.main.bundleIdentifier {
            UserDefaults.standard.removePersistentDomain(forName: domain)
        }
    }
}
```

## Launch order

```swift
import SwiftUI
import OSLog

final class LaunchDelegate: NSObject, UIApplicationDelegate {
    private let vault = LiveMigrationKeychain()
    private let log = Logger(subsystem: "com.example.ledger", category: "KeychainMigration")

    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
        Task {
            await ReinstallSweeper().sweepIfFreshInstall()
            let state = await SchemaMigrator(vault: vault).migrateIfNeeded()
            log.info("migration state: \(String(describing: state), privacy: .public)")
            try? await LegacyArtefactJanitor(vault: vault).sweepIfWindowElapsed()
            ThirdPartyServices.start()
        }
        return true
    }
}

@main
struct LedgerApp: App {
    @UIApplicationDelegateAdaptor(LaunchDelegate.self) private var delegate
    var body: some Scene { WindowGroup { RootView() } }
}
```

The order is fixed: reinstall sweep, schema migration, artefact cleanup, and only then the analytics, crash reporting and authentication SDKs.

## Concurrency

The `SecItem*` functions are safe to call from any thread. The wrapper's own state (caches, flags, the stored version) is not, so protect it; an actor is the simplest choice when the deployment target allows it, otherwise a serial queue.

## Testing migrations

| Aspect | Simulator | Device |
| --- | --- | --- |
| Data protection | Not enforced | Enforced |
| Entitlement checks | Lenient | Strict |
| `errSecInteractionNotAllowed` | Rare | Returned while locked |
| Lock-state testing | Not possible | Required |

An in-memory mock that conforms to the same protocol lets unit tests inject failures:

```swift
actor InMemoryMigrationKeychain: MigrationKeychainProtocol {
    private var rows: [String: [String: Data]] = [:]
    var injectedStatus: OSStatus?

    func inject(_ status: OSStatus?) { injectedStatus = status }

    func save(_ data: Data, account: String, accessibility: String) throws {
        if let status = injectedStatus { throw KeychainFailure(status) }
        rows[accessibility, default: [:]][account] = data
    }
    func read(account: String) throws -> Data? {
        rows.values.lazy.compactMap { $0[account] }.first
    }
    func delete(account: String) throws {
        for key in rows.keys { rows[key]?[account] = nil }
    }
    func deleteAll() throws { rows.removeAll() }
}
```

```swift
import Testing

@Test func lockedWriteLeavesPlaintextForRetry() async throws {
    let suite = try #require(UserDefaults(suiteName: "migration-test"))
    suite.removePersistentDomain(forName: "migration-test")
    suite.set("refresh-abc", forKey: "refreshToken")
    let vault = InMemoryMigrationKeychain()
    await vault.inject(errSecInteractionNotAllowed)

    let mover = DefaultsToKeychainMover(vault: vault, defaults: try #require(UserDefaults(suiteName: "migration-test")))
    let report = await mover.move(keys: ["refreshToken"])

    #expect(suite.string(forKey: "refreshToken") == "refresh-abc")
    #expect(report["refreshToken"] != .moved)
}
```

The mover gets its own `UserDefaults` instance for the same suite. `UserDefaults` is not `Sendable`, so once `suite` has been handed to the actor the test could not read it afterwards.

Clear the keychain in `setUp` and `tearDown` for any test that touches the real one, because Simulator keeps items between runs. Integration tests against the real keychain need a host app target with the Keychain Sharing capability. More in [testing-security-code.md](testing-security-code.md).

## Very old versions

The App Store only serves the newest binary, so it must still carry every migration from every schema a user could be on. Once analytics show fewer than about 1% of users on the oldest versions, collapse the early steps into a single v0-to-current migration. An unknown or corrupt legacy format fails gracefully and sends the user to a fresh sign-in; it never crashes.

## Deleting files

Do not overwrite a file with zeros or random bytes before deleting it. Wear levelling on NAND means the overwrite lands elsewhere, and it only burns write cycles. `FileManager.removeItem(at:)` and `UserDefaults.removeObject(forKey:)` are enough, because the per-file key is destroyed through Effaceable Storage. The residual risk is an unencrypted backup made before migration; encourage encrypted backups and remove the plaintext promptly once the migration is verified.

## Key decisions

The irreversible step is the delete, not the write: verify first, defer when in doubt, and assume keychain items outlive the app. The five decisions that matter most:

1. `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` as the default class for migrated credentials.
2. Reinstall cleanup before any SDK initialises.
3. Schema version stored in the keychain.
4. Migration gated on `isProtectedDataAvailable`.
5. `kSecAttrService` never changes after the first release.

## Checklist

- [ ] Reinstall cleanup runs before SDK setup, after protected data is readable, and uses `kSecAttrSynchronizableAny`.
- [ ] Each key moves read, write, verify, delete; failed keys keep their source value.
- [ ] Schema version lives in the keychain and advances only after every step succeeds.
- [ ] Migration checks protected-data availability and defers through the notification.
- [ ] `errSecInteractionNotAllowed` (-25308) is never read as "missing" and never triggers a delete.
- [ ] Service and account never change after shipping; an unavoidable rename uses a rekey migration.
- [ ] Search queries never include `kSecAttrAccessible`.
- [ ] Migrated credentials use `AfterFirstUnlockThisDeviceOnly` unless they are meant to sync.
- [ ] Plaintext secrets are deleted right after verification; only non-secret artefacts wait for a 30-day window whose start date lives in the keychain.
- [ ] A Team ID change is preceded by a bridge release.
- [ ] Unit tests use a protocol mock; accessibility is tested on a device; real-keychain tests clean up in `setUp` and `tearDown`.
