It’s a common but poorly documented challenge that many developers face, especially during device upgrades, app reinstallations, or when sharing data across app groups. Since the Keychain stores sensitive user information like credentials and tokens, handling its migration securely is critical for maintaining a seamless user experience and ensuring data integrity.
This post explains how to implement a trial period for users in an iOS app. To prevent users from restarting the trial period by reinstalling the app, the controlling information should be securely stored in the Keychain.
Keychain
Keychain in iOS is a secure storage system provided by Apple that allows apps to store sensitive information such as passwords, cryptographic keys, and certificates securely. It uses strong encryption and is protected by the device’s hardware and the user’s authentication (e.g., Face ID, Touch ID, or passcode). The Keychain ensures that this data is safely stored and only accessible to the app that created it, unless explicitly shared through keychain access groups. It provides a convenient and secure way for developers to manage credentials and other private data without implementing their own encryption systems.
When an app is removed (uninstalled) from an iOS device, most of its data—including files stored in its sandboxed file system—is deleted. However, data stored in the Keychain is not automatically deleted when the app is removed. This means that if the app is reinstalled later, it can still access its previously stored Keychain data (assuming the Keychain item was saved with the correct accessibility settings and not tied to a now-invalid access group). This behavior allows for features like remembering a user’s login after reinstalling an app.
Persisted local data can change over the lifetime of an application, and apps distributed in the past may not always be updated to the latest version. To handle this, apps must implement a data migration mechanism to adapt old stored data to the new data model. Without proper migration, the app may crash when attempting to load outdated or incompatible data. When data is stored in UserDefaults, this issue can often be bypassed by simply reinstalling the app—since UserDefaults is part of the app’s sandbox and gets cleared upon uninstallation, the app starts fresh, and the user can continue using it. However, Keychain is not part of the app bundle; it is managed by the iOS operating system and persists even after the app is uninstalled. Therefore, if the app crashes while trying to parse outdated or incompatible data from the Keychain, it will continue to crash even after reinstallation. In such cases, the app developer will most likely need to release a new version with the necessary fixes.
Sample App
In this post, we will implement a sample app that includes a trial mechanism. This means the app will be freely usable for a limited period. After that, users will need to complete certain tasks to unlock continued usage. It’s highly likely you’ve encountered similar applications before.
The data structure that controls the trial mechanism can only be stored in the keychain for two main reasons. First, it requires a secure storage location. Second—and equally important—it must be stored in a place that retains the information even if the app is uninstalled. Otherwise, users could simply reinstall the app to reset the trial period and continue using it without restriction.
The structure that controls trial mechanism is following:
typealias TrialInfoLatestModel = TrialInfo
protocol TrialInfoMigratable:Codable {
var version: Int { get }
func migrate() -> TrialInfoMigratable?
}
// Current model (v0)
struct TrialInfo: Codable, TrialInfoMigratable, Equatable {
var version: Int = 0
let startDate: Date
static let defaultValue = TrialInfo(startDate: Date())
func migrate() -> (any TrialInfoMigratable)? {
nil
}
}
This Swift code defines a versioned data model system for TrialInfo
, where TrialInfoMigratable
is a protocol that allows models to specify their version and migrate to newer versions. The TrialInfo
struct represents version 0 of the model, contains a startDate
, and conforms to Codable
, Equatable
, and TrialInfoMigratable
. It includes a static default value and a migrate()
method that returns nil
, indicating no further migration is needed. The typealias TrialInfoLatestModel = TrialInfo
serves as an alias for the latest version of the model, making future upgrades easier by allowing seamless substitution with newer model versions.
Next is review the function that handles migration placed on viewModel:
@Suite("TrialViewModelTest", .serialized) // Serialize for avoiding concurrent access to Keychain
struct TrialViewModelTest {
@Test("loadTrialInfo when nil")
func loadTrialInfoWhenNil() async throws {
// Given
let sut = await TrialViewModel()
await KeychainManager.shared.deleteKeychainData(for: sut.key)
// When
let info = await sut.loadTrialInfo(key: sut.key)
// Then
#expect(info == nil)
}
@Test("Load LatestTrialInfo when previous stored TrialInfo V0")
func loadTrialInfoWhenV0() async throws {
// Given
let sut = await TrialViewModel()
await KeychainManager.shared.deleteKeychainData(for: sut.key)
let trialInfo = TrialInfo(startDate: Date.now)
await sut.saveMigrated(object: trialInfo, key: sut.key)
// When
let trialInfoStored = await sut.loadTrialInfo(key: sut.key)
// Then
#expect(trialInfoStored?.version == 0)
}
}
Basically validate then trial data has not been and has been stored. Once, we are sure that tests pass then deploy the app into the simulator.

First change on Trial Data
The core idea behind a migration mechanism is to keep incoming changes as simple as possible.
We now propose updating the trial data structure with two new attributes (v1).
Installing the app for the first time with v1 will not pose any problems. However, issues may arise when the app was initially installed with version 0 (v0) and later updated to v1.
In such cases, the app must perform a migration from v0 to v1 upon startup.
The following changes will be made to the Trial data structure:
typealias TrialInfoLatestModel = TrialInfoV1
.....
// Current model (v1)
struct TrialInfoV1: Codable, TrialInfoMigratable {
var version: Int = 1
let startDate: Date
let deviceId: String
let userId: String
static let defaultValue = TrialInfoV1(startDate: Date(), deviceId: UUID().uuidString, userId: UUID().uuidString)
init(startDate: Date, deviceId: String, userId: String) {
self.startDate = startDate
self.deviceId = deviceId
self.userId = userId
}
func migrate() -> (any TrialInfoMigratable)? {
nil
}
}
// Current model (v0)
struct TrialInfo: Codable, TrialInfoMigratable, Equatable {
...
func migrate() -> (any TrialInfoMigratable)? {
TrialInfoV1(startDate: self.startDate, deviceId: UUID().uuidString, userId: UUID().uuidString)
}
}
Typealias has to be set to latest version type and we have to implement the migration function that converts v0 to v1. And in the migration new type also to migration function:
func loadTrialInfo(key: String) async -> TrialInfoLatestModel? {
...
let versionedTypes: [TrialInfoMigratable.Type] = [
TrialInfo.self
]
...
}
No more migration changes, no execute unit tests:

Unit tests fails mainly because v0 stored was migrated to v1. Adapt unit test and new following test:
@Suite("TrialViewModelTest", .serialized) // Serialize for avoiding concurrent access to Keychain
struct TrialViewModelTest {
...
@Test("Load LatestTrialInfo when previous stored TrialInfo V0")
func loadTrialInfoWhenV0() async throws {
....
// Then
#expect(trialInfoStored?.version == 1)
}
@Test("Load LatestTrialInfo when previous stored TrialInfo V1")
func loadTrialInfoWhenV1() async throws {
// Given
let sut = await TrialViewModel()
await KeychainManager.shared.deleteKeychainData(for: sut.key)
let trialInfo = TrialInfoV1(startDate: Date.now, deviceId: UUID().uuidString, userId: UUID().uuidString)
await sut.saveMigrated(object: trialInfo, key: sut.key)
// When
let trialInfoStored = await sut.loadTrialInfo(key: sut.key)
// Then
#expect(trialInfoStored?.version == 1)
}
}
Repeat test execution to ensure everything is operating safely and as expected.
Next trial data
Next change, v2, removes one of the attributes added in v1. Changes on trial data structure are following:
typealias TrialInfoLatestModel = TrialInfoV2
...
// Current model (v2)
struct TrialInfoV2: Codable, TrialInfoMigratable {
var version: Int = 2
let startDate: Date
let deviceId: String
static let defaultValue = TrialInfoV2(startDate: Date(), deviceId: UUID().uuidString)
init(startDate: Date, deviceId: String) {
self.startDate = startDate
self.deviceId = deviceId
}
func migrate() -> (any TrialInfoMigratable)? {
nil
}
}
// Current model (v1)
struct TrialInfoV1: Codable, TrialInfoMigratable {
...
func migrate() -> (any TrialInfoMigratable)? {
TrialInfoV2(startDate: self.startDate, deviceId: self.deviceId)
}
}
...
}
Shift typealias to latest defined type and implement the migration function that transform v1 to v2. Adapt also viewmodel migration function and add v1 type to type array:
let versionedTypes: [TrialInfoMigratable.Type] = [
TrialInfoV1.self,
TrialInfo.self
]
Finally run the test, adapt them and add test case for v2:
@Test("Load LatestTrialInfo when previous stored TrialInfo V2")
func loadTrialInfoWhenV2() async throws {
// Given
let sut = await TrialViewModel()
await KeychainManager.shared.deleteKeychainData(for: sut.key)
let trialInfo = TrialInfoV2(startDate: Date.now, deviceId: UUID().uuidString)
await sut.saveMigrated(object: trialInfo, key: sut.key)
// When
let trialInfoStored = await sut.loadTrialInfo(key: sut.key)
// Then
#expect(trialInfoStored?.version == 2)
}
Conclusions
In this post, I presented a common issue encountered when working with persisted data, along with a possible solution for handling data migration.
You can find source code used for writing this post in following repository.
References
- Keychain
Apple documentation