iOS Keychain: where an app loses data and rights
The most common failure — tokens and passwords sit in UserDefaults or in a plist inside the app sandbox.
The sandbox protects against other programs, but not against an iCloud backup and not against physical access to an unlocked device: the file is read with standard tools, and when backup encryption is enabled it ends up in the cloud along with a copy of the device.
Engineers find this during an audit, when the app is already in production and tokens have been living in plaintext for months.
The second typical failure — the wrong accessibility class for an iOS Keychain entry. By default an item is readable after the first unlock and remains available while the device is on — that is enough for background processes and extensions to pull the token out.
For sensitive data you need a class tied to unlock, and for biometrics — a flag that requires the owner's confirmation on every read. Without this, a "secure store" turns into an ordinary database.
The third failure — access rights. An item written without an explicit access group is not visible to an extension or a widget, and developers work around this by copying the token into a shared container — that is, outside again.
The reverse mistake: too broad a group exposes the data to all of the team's builds, including test ones.
We break down how this is closed: accessibility classes for each type of secret, a correct group, migration of existing entries, and verification that after a reinstall and a restore from backup the tokens do not surface in plaintext.
What proper storage configuration gives
Proper storage configuration is not a "security checkbox" but a managed attack surface. When we assign each secret its own accessibility class and access group, tokens and keys stop ending up in backups, in logs, and in shared access between apps on the device.
The attack surface shrinks not abstractly but along specific channels: a secret does not leak through a backup, is not read by another app, and does not remain on disk after the user deletes it.
The second effect is predictable behavior during app updates.
If the storage policy is described explicitly rather than "by default," then moving between versions does not break access to saved data: the user updates and stays in their session instead of signing in again.
This eliminates an entire class of failures — "data is unreadable after reinstallation," when the app formally works but loses everything that should have survived reinstallation.
The third effect is release stability. When the rules for accessing secrets are fixed and identical for all builds, engineers stop fixing divergent behavior across different devices and OS versions.
Storage access errors are caught at the verification stage rather than by the user, and do not turn into a stream of support requests.
You can verify the configuration against a checklist: each secret has an accessibility class and the moment when it is needed; the access group limits data exchange to only the apps that need it; backup is excluded where the secret must not leave the device; behavior during update and reinstallation is described and verified; data deletion follows an explicit scenario. If even one item is not met, the storage remains a source of risk rather than protection.
Storage access modes: comparison of options
The accessibility mode is set by the kSecAttrAccessible attribute when writing an item and determines the device state under which the data can be read at all.
This is not a "setting for the future": the value is fixed at the moment of saving and changes only by overwriting the item.
A mistake here does not manifest immediately — the app works while the phone is unlocked, and crashes on a background task after a reboot or when the screen is locked.
| Mode | When it fits | Limitations |
|---|---|---|
WhenUnlocked |
Access tokens, personal data, anything read only in the active interface | Unavailable when the screen is locked — background tasks and push handlers will get a read error |
AfterFirstUnlock |
Background synchronization, scheduled data updates, operation after a reboot without entering a passcode | Data is readable at all times after the first unlock — a higher cost of device compromise |
WhenUnlockedThisDeviceOnly |
Keys tied to a specific device, sensitive sessions | Not transferred with a backup — when the phone changes, the user signs in again |
AfterFirstUnlockThisDeviceOnly |
Background work tied to the device: synchronization, analytics, deferred sends | Combines both limitations: available after unlock, but not restored from a backup |
WhenPasscodeSetThisDeviceOnly |
The most sensitive keys, where a device passcode is required | Requires a passcode to be set; if it is removed, the items are deleted irrevocably |
The choice comes down to two questions: is access to the data needed before unlock, and must the item survive transfer to a new device. If background synchronization must work after a reboot — we take the AfterFirstUnlock branch.
If the data must not leave the device — we add ThisDeviceOnly. Mixing is not allowed: the same token written in two modes causes desynchronization when restoring from a backup.
In practice, modes are combined within a single app: a long-lived session token — AfterFirstUnlock, because it is read by a background task, and a key for signing a payment — WhenPasscodeSetThisDeviceOnly. We work this out at the storage design stage, before the first line of code.
Stages of secure storage implementation
Implementing storage begins not with code, but with an inventory of what the application already writes to disk. Session tokens, passwords, signing keys — if they sit in ordinary files or settings, anyone who gains access to the device or a backup reads them as plain text.
So the first step is a storage audit: go through every place where sensitive data is written and record exactly what must live and for how long.
- Storage audit. We assemble a complete data map: which values are written, where, who reads them, and what happens on sign-out. The output is a list of records marked "move to storage / delete / leave as is."
- Key schema. We design the record structure: a separate key for each data type, a common prefix for service records, explicit accessibility attributes — when a record should be readable only on an unlocked device, and when it should survive a reboot.
- Access group setup. If the ecosystem has several applications or extensions, we link them with a shared group so token exchange happens without duplication and without leaving the sandbox.
- Migration of existing data. We move accumulated values from the old storage to the new one with a one-time write on first launch, with integrity checks and rollback if the write is not confirmed.
- Scenario testing. We run through behavior on screen lock, app update, restore from backup, and device passcode change — this is exactly where access errors surface.
- Error and failure handling. We define what the application does if a record is unavailable: it does not crash, but correctly requests re-authentication.
- Documentation handover. We hand over the key schema, rotation rules, and instructions for adding new records — so the team does not have to figure it out from scratch again.
This order closes the main risk: data stops being accessible outside the application, and behavior during failures remains predictable. Describe your task — we will come back with an estimate and a work plan.
What the client receives after the work
After the work, you are left not with a "working application" but with a complete package that another team can use to maintain the secrets storage without us.
Everything we designed is documented as configurations, schemas, and regulations — they can be handed over to internal engineers or a contractor for maintenance.
- Access configurations. A set of protection classes for different data types: passwords and session tokens, encryption keys, service labels. Each has its own level: when a record is accessible only while the device is unlocked, when it survives a reboot, when it syncs across the user's devices.
- Storage schemas. A map of what is stored where: which data goes into secure storage, which into shared storage, which is not saved at all. Tied to the lifecycle — what is deleted on sign-out, what on password change, what on access revocation.
- Access policies. The rules by which the application accesses secrets: which operations are allowed in the background, which only while the screen is active, how access denial is handled. This protects against leaks through background processes and third-party libraries.
- Documentation. A description of the storage architecture, the points where it is accessed, and typical scenarios. An engineer opening the project for the first time will understand it without having to message the author.
- Rotation regulations. The procedure and frequency for changing keys and tokens, conditions for forced reset, behavior in case of compromise. Plus a rollback procedure if the new storage schema breaks sign-in for some users.
- Checklist of verifications. What is tested and how before release: persistence after restart, behavior when the screen is locked, correctness of migration from the old storage schema.
The package is self-sufficient: it can be used to conduct an audit, onboard a new developer, or pass a security review without us. If you need an analysis of your current storage — describe the task, and we will get back to you with an estimate and a plan.
Case: eliminating a class of failures during app updates
The client is a delivery service with an iOS app, an active audience, and releases every two weeks. After another update, complaints started coming in: some users lost their authorization when opening the app, others lost their cart and addresses.
Within a week, 340 support requests came in, and the share of failures reading saved data on new builds held at around 6% of the active base.
What it was
The data was stored in Keychain without an explicit accessibility policy: writes used the default setting, while reads used the condition "available only on an unlocked device." In some scenarios (background sync, recovery after a restart), the read returned an empty result, the app interpreted this as "new user," and reset the session. Diagnosis took days: it could not be reproduced on every device.
What was changed
Access was separated by data type: tokens — with access after first unlock and synchronization between the user's devices, sensitive keys — strictly local.
Explicit handling of read errors was added: an empty response and an access error are now different logic branches, rather than a common reset.
A migration was introduced: on the first launch of the new version, old records are re-read and rewritten with the correct policy, without losing the session.
What it became
| Metric | Before | After |
|---|---|---|
| Share of data read failures | ~6% of the active base | below 0.3% |
| Recovery time after release | 2-3 days | hours, based on logs |
| Support requests per week | 340 | 40-50 |
The class of failures in which an update knocked the user out of their account is gone: releases stopped being a risk to the session, and support was unburdened without growing the team.
How is Keychain different from a local file and cloud synchronization?
Keychain is a system-level secure storage that an app accesses not via a file path, but through a separate software interface.
A record is tied not to a folder in the sandbox, but to the app's identifier and its access rights, so the data does not just sit there as "a file" that can be copied or read by a third-party tool.
The system encrypts the contents and releases them only to the process whose signature and entitlements match those set when the record was written.
A local file in the sandbox is the simplest option, but it remains an ordinary file on disk. It ends up in the device backup, is carried over when restoring to a new device, and can be extracted if an attacker gains access to the file system or to a copy.
Cloud synchronization adds yet another perimeter on top of this: the data goes to a third-party server, and its protection now depends on someone else's infrastructure and account settings.
When an app is reinstalled, the behavior differs fundamentally. Files in the sandbox are deleted along with the app — after reinstalling, they are gone.
Keychain records are preserved by default unless explicitly specified otherwise, so the app can restore a saved session or token immediately after reinstalling.
This is both a convenience and a risk: if the visibility scope of records is not managed, the data will survive app deletion, which the user does not expect.
Backup is also managed explicitly. An accessibility policy is set for each record: for example, the value is available only when the device is unlocked, or is not carried over into the backup at all.
This makes it possible to separate what should be restored on a new device from what is tied to a specific instance and should not leave it.
Bottom line: a local file is data that is easy to lose on reinstall and easy to take away along with a copy; the cloud is convenient synchronization, but with an external trust perimeter; Keychain is controlled storage where access, transfer, and restoration are set by policies at the level of each record.
Technical concerns before starting work
Before the start, almost every team asks the same set of questions: won't we break the current logic, what to do with already saved data, how iOS Keychain will behave on different system versions.
Below are short answers to each point so that you have an understanding of the scope of work even before the estimate.
Is iOS Keychain compatible with our current code?
Yes. Keychain is a system storage, not a separate library, so it does not conflict with the existing architecture.
We wrap access in a thin layer on top of the current calls: if you already have your own storage module, it remains the entry point, and the implementation changes inside. Public interfaces are not rewritten, so the rest of the code continues to work without edits.
What will happen to the data that is already saved?
Migration is planned before the first release. We describe the transfer scheme: we read the old values, write them to Keychain with the required access attributes, and only after successful verification do we delete the original storage.
The transfer is idempotent — a repeated run does not corrupt the data, and during the transition both sources are available in parallel.
Does this work the same way on different system versions?
The base behavior is stable, but some access attributes and access groups appeared in newer system branches. We check the target minimum version and, for older builds, keep a compatible set of parameters.
On devices where the required mode is unavailable, the storage layer switches to a safe option without loss of functionality.
Can we roll back if something goes wrong?
Yes, rollback is built into the plan. We keep a backup copy of the original storage before migration and maintain a switch that returns the application to the previous reading logic.
This removes the risk of data loss during the update and allows releasing a fix without an urgent release.
What risks do you see at the start?
The main ones are incomplete migration if the write is interrupted and differences in behavior on older system versions. The first is covered by idempotency and post-transfer verification, the second by a test run on the minimum supported build. Both scenarios are checked before rollout, not after.
Let's discuss your data protection task
To come back with an estimate and a plan rather than generalities, we need to understand your current approach to handling secrets in the app. Below is what we clarify during the first conversation and which input data helps calculate the scope of work accurately.
- Task breakdown. We determine what exactly is stored in the secure storage: access tokens, passwords, encryption keys, biometric templates. This determines the choice of protection class and access policy.
- Inventory of the current state. We look at how the app accesses the storage now: through direct calls, through a wrapper, whether there is synchronization between devices and group availability for extensions.
- Threat model review. We determine who we are protecting against: physical access to the device, backups, jailbreak. This sets the level of policy strictness.
- Gathering input data. We ask for a list of scenarios where data is written and read, requirements for biometrics and token lifetime, as well as compatibility constraints with already released versions.
- Estimate and plan. We form a list of changes: migration of existing records, configuration of access policies, handling of read errors, tests for recovery after reinstallation.
- Agreeing on the next step. We fix the order of work, checkpoints, and what you get as a result — working code, policy documentation, and a report on scenario verification.
What to send for a quick estimate: a description of the current storage approach, a list of data handling scenarios, and security requirements. Describe the task — we will come back with an estimate and a plan.







