All insights

Storing secrets in the iOS Keychain is only half the job

Introduction

Many iOS apps store sensitive data, such as passwords, keys and personal information, in Apple's Keychain, the iPhone's built-in safe for secrets. That's better than keeping it in UserDefaults, a plain settings file in the app's folder that ends up in backups and can be read with simple tools. But the settings that decide when it can be read and who must be present are often left at their defaults or set too loosely.

Login tokens are a typical example. Many apps keep an access token (short-lived, sent with every request) and a refresh token (long-lived, used to get new access tokens). We often find both stored without any access control, and with an accessibility setting that doesn't fit how they're used. On a test phone, we dump the Keychain, copy the refresh token and sign in as the user from a laptop. No check that it's the user's phone, no alert, no encryption broken. With access control, the same dump would have needed the owner's face. The fix is often just a few lines of code.

This post explains how Keychain protection works, the six mistakes we see most often, what we recommend instead, and how to implement it, using these two tokens as the example throughout.

How Keychain protection works

The Keychain encrypts secrets and keeps apps apart, but when a secret can be read, and who must be present, depends on two settings you choose for each secret.

Accessibility: when and where

The accessibility setting (kSecAttrAccessible) decides when a secret can be read, based on whether the phone is locked. After a phone is switched on and first unlocked, it spends most of the day locked in a pocket:

When each accessibility setting lets a secret be read A timeline of a phone's day: before the first unlock, in use, locked in a pocket, in use again. With kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, the secret can only be read while the phone is unlocked, and only if a passcode is set. With kSecAttrAccessibleWhenUnlocked, it can only be read while the phone is unlocked. On a phone without a passcode, the first can't be saved at all, while the other three can be read at any time. With kSecAttrAccessibleAfterFirstUnlock, it can also be read while the phone is locked, from the first unlock onwards. With kSecAttrAccessibleAlways, which is deprecated, it can be read at any time, even before the first unlock.If the phone hasno passcodeCan't be savedAlways readableAlways readableAlways readableBefore firstunlockIn useLocked inpocketIn useagainUnlocked, passcode setkSecAttrAccessibleWhenPasscodeSetThisDeviceOnlyOnly when unlockedkSecAttrAccessibleWhenUnlockedAfter first unlockkSecAttrAccessibleAfterFirstUnlockAlways (deprecated)kSecAttrAccessibleAlwaysCan be read while you use the phoneCan be read while you aren't using itBefore firstunlockIn useLocked inpocketIn useagainUnlocked, passcode setkSecAttrAccessibleWhenPasscodeSetThisDeviceOnlyOnly when unlockedkSecAttrAccessibleWhenUnlockedAfter first unlockkSecAttrAccessibleAfterFirstUnlockAlways (deprecated)kSecAttrAccessibleAlwaysCan be read while you use the phoneCan be read while you aren't using itIf the phone has no passcodeUnlocked, passcode setCan't be savedOnly when unlockedAlways readableAfter first unlockAlways readableAlways (deprecated)Always readable

The strictest option, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly (top row), works like "only when unlocked" as long as the phone has a passcode. The difference shows on a phone without one (the dashed box in the diagram): a phone without a passcode always counts as unlocked, so "only when unlocked" can be read at any time, while the strictest option can't be saved at all. If the passcode is removed later, the secret is deleted.

The other three options each come in two versions, such as kSecAttrAccessibleWhenUnlocked and kSecAttrAccessibleWhenUnlockedThisDeviceOnly. Both follow the same timeline, but the ThisDeviceOnly version keeps the secret on this phone. Without it, a secret can be restored onto another phone from an encrypted or iCloud backup. The passcode option only exists as ThisDeviceOnly.

Access control (ACL): who

Access control (kSecAttrAccessControl), often called the ACL (access control list), decides who must be present. It can require Face ID, Touch ID or the phone's passcode before the secret is released, checked by the phone's security chip rather than the app. That includes code running inside the app, such as an attacker's hook or our own Keychain dump: without passing the check, the secret stays encrypted. Instead of, or as well as, Face ID, it can require a PIN from your app's own PIN screen, which the Keychain then needs to decrypt the secret. When you use an ACL, you choose the accessibility value inside it: both go into one call, SecAccessControlCreateWithFlags, instead of setting kSecAttrAccessible separately. On iPhone X and older models, an unpatchable flaw in the chip's built-in boot code (checkm8) gives forensic tools deeper access, so these protections are weaker there.

Who can open the refresh token under each access control option A grid of five access control options against five kinds of person or code. With no access control, code in the app without Face ID, someone who knows the passcode, someone whose face was added later, someone holding the phone up to the owner's face, and the owner can all open the refresh token. With .userPresence, code in the app is blocked, but everyone else can open it. With .biometryAny, a face added later, someone holding the phone up to the owner and the owner can open it. With .biometryCurrentSet, only the owner's current face can open it, including when someone else holds the phone up to the owner. With .biometryCurrentSet plus .applicationPassword, only the owner can open it, with their current face and the app PIN.Code in theapp, no Face IDSomeone whoknows thepasscodeSomeone whoseface was addedlaterSomeone holdingthe phone up toyour faceYou, the owner(current face)No access controlNothing requiredCan openCan openCan openCan openCan open.userPresenceFace ID or passcodeBlockedCan openCan openCan openCan open.biometryAnyAny enrolled faceBlockedBlockedCan openCan openCan open.biometryCurrentSetFaces enrolled when savedRECOMMENDEDBlockedBlockedBlockedCan openCan open.biometryCurrentSet +.applicationPasswordFOR APPS WITH A PINBlockedBlockedBlockedBlockedCan openSomeone other than you can open itYou can open itCode in theapp, no FaceIDSomeone whoknows thepasscodeSomeonewhose facewas addedlaterSomeoneholding thephone up toyour faceYou, theowner(currentface)No access controlNothing required.userPresenceFace ID or passcode.biometryAnyAny enrolled face.biometryCurrentSetFaces enrolled when savedRECOMMENDED.biometryCurrentSet + .applicationPasswordFOR APPS WITH A PINSomeone other than you can open itYou can open itBlocked

Six common mistakes

Mistakes 1 to 3 are about when and where a token can be read, 4 and 5 about who can read it, and 6 about who else shares it and how long it's kept. For each one, we show an example, how to spot it and the fix.

To spot them in a compiled app, we use Frida (a tool that hooks into a running app) and Objection (a toolkit built on Frida) on a jailbroken test phone, or on a build repackaged with Frida's Gadget. A short Frida script can also log the accessibility and ACL of every secret the app saves, including secrets saved by third-party code:

Frida script: log the accessibility and ACL of every saved secretJavaScript
// keychain-watch.js: logs a secret's accessibility and ACL every time the app saves one.
// Run: frida -U -f com.example.app -l keychain-watch.js
// Hooks SecItemAdd only. Apps that change items with SecItemUpdate need a second hook.
const security = Process.findModuleByName('Security')
  ?? Module.load('/System/Library/Frameworks/Security.framework/Security');

Interceptor.attach(security.getExportByName('SecItemAdd'), {
  onEnter(args) {
    const item = new ObjC.Object(args[0]);            // the item's attributes
    console.log('Saved:', item.objectForKey_('acct'),  // account name
      '| when:', item.objectForKey_('pdmn'),           // ak = when unlocked, ck = after first unlock,
                                                       // dk = always, akpu = when passcode set.
                                                       // A "u" at the end = this device only
      '| ACL:', item.objectForKey_('accc'));           // access control, if any. When an item
                                                       // has an ACL, its accessibility is inside it.
  }
});

Mistake 1: Using kSecAttrAccessibleAfterFirstUnlock

The refresh token can be read while the phone is locked. Developers often pick this setting to fix a background-task error. Think of a safe that stays open all day after you first open it. The default, kSecAttrAccessibleWhenUnlocked, doesn't have this problem: its key is discarded shortly after the phone locks. Its weak points are mistakes 2 and 3.

ExampleA phone is lost or stolen and ends up with someone who has a forensic tool, like those sold to police and private labs. It's locked, but it was unlocked that morning, so the refresh token can still be read, and the tool pulls it off the phone. The attacker signs in as the user from a laptop. Each refresh hands them a new access token, so they keep access until the session is revoked. Since iOS 18.1, a phone that stays locked for three days restarts, which closes this window, but an attacker who acts sooner isn't stopped.

How to spot itSearch the code for kSecAttrAccessibleAfterFirstUnlock or kSecAttrAccessibleAlways. In the compiled app, Objection's ios keychain dump shows each secret's accessibility in the Accessible column.

objection · com.example.app
objection (python3.12)
~ » objection -n com.example.app start
com.example.app (run) on (iOS: 18.5) [usb] # ios keychain dump
Note: You may be asked to authenticate using the devices passcode or TouchID
Save the output by adding `--json keychain.json` to this command
Dumping the iOS keychain...
Created                    Accessible                  ACL  Type      Account        Service           Data
-------------------------  --------------------------  ---  --------  -------------  ----------------  -----------------------------------------
2026-10-14 09:12:41 +0000  WhenUnlockedThisDeviceOnly       Password  device_id      com.example.auth  7f3a9c1e
2026-10-14 09:12:41 +0000  AfterFirstUnlock                 Password  refresh_token  com.example.auth  eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiI4ZjE0Z...
The second item is mistake 1: the Accessible column shows AfterFirstUnlock. With no access control (ACL), the dump shows it without any Face ID prompt.

The fixStore the refresh token so it can only be read on this phone while it's unlocked, behind Face ID (see How to implement it). Background tasks that run while the phone is locked should use the short-lived access token instead.

Mistake 2: No passcode required

The refresh token uses an accessibility value that doesn't require a passcode, such as kSecAttrAccessibleWhenUnlocked (the default), instead of kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly. On a phone without a passcode, the token is then always readable (the dashed box in the diagram above). It's like a safe whose lock only works if the owner happened to set a code.

ExampleA family iPad has no passcode. Anyone who picks it up opens your app and is signed in as the user. And because the token's encryption isn't tied to anything the user knows, forensic extraction tools can copy it off the iPad without having to guess a passcode.

How to spot itLook for the refresh token or other long-lived secrets saved without kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly. In the dump, the Accessible column shows the value. On a test phone with no passcode, check whether the app still saves and reads the refresh token.

The fixUse kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly for the refresh token. On a phone without a passcode, saving then fails: ask the user to set a passcode, or don't keep them signed in.

Mistake 3: Tokens that follow you to a new phone

The refresh token isn't marked ThisDeviceOnly, or is synced to iCloud. Think of a house key that gets copied into every backup.

ExampleA user backs up their iPhone to a shared family computer. Someone else with access to that computer and the backup password restores it onto their own iPhone. Your app opens already signed in, and if the server treats the refresh token as proof of a trusted phone, it also skips extra checks such as SMS codes.

How to spot itLook for options without ThisDeviceOnly, or kSecAttrSynchronizable set to true. In the dump, a missing ThisDeviceOnly shows in the Accessible column. The dump doesn't show kSecAttrSynchronizable, so check for it in the code.

objection · com.example.app
objection (python3.12)
com.example.app (run) on (iOS: 18.5) [usb] # ios keychain dump
Note: You may be asked to authenticate using the devices passcode or TouchID
Save the output by adding `--json keychain.json` to this command
Dumping the iOS keychain...
Created                    Accessible    ACL  Type      Account        Service           Data
-------------------------  ------------  ---  --------  -------------  ----------------  -------------------------------------------------------
2026-10-14 09:12:41 +0000  WhenUnlocked       Password  refresh_token  com.example.auth  eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiI4ZjE0ZTQ1Zi1jZWVhLTQ...
The refresh token uses "WhenUnlocked" without "ThisDeviceOnly", so it can be restored onto another phone.

The fixUse a ThisDeviceOnly option and never sync tokens.

Mistake 4: Face ID as a yes/no question

The app asks Face ID for a yes or no, then reads an unprotected refresh token itself. The app decides, not the phone. It's like a guard who asks "are you the owner?" and simply believes the answer.

ExampleA thief watches the owner type their passcode, then steals the phone. It's an older model that public jailbreak tools support, so with the passcode they can jailbreak it and run code inside your app. Hooking the app's Face ID check takes one line: it answers "yes" and the app goes ahead and reads the token. Had the phone's security chip made the decision, neither the passcode nor the jailbreak would get the token out of the Keychain without the owner's face. Code running in the app could still read the token from memory after a real Face ID check, so keep it in memory only while it's in use.

App-checked Face ID compared with phone-enforced Face ID Left: the app shows a Face ID prompt, gets back yes or no, then reads a refresh token that has no access control. An attacker forces the answer to yes, or skips straight to reading the refresh token, so anyone who can run code in the app gets in. Right: the app asks for the refresh token, and the phone's security chip checks Face ID and only releases it on a real match, so there is nothing inside the app to trick.APP CHECKS, THEN READS1Face ID prompt2App gets "yes" or "no"Just a value inside the app3App reads the tokenThe token has no access controlAttackerforces "yes",or skipsstraight to 3Anyone who can run code on the phone gets in.PHONE CHECKS, THEN RELEASES1App asks for the tokenPHONE'S SECURITY CHIP2Face ID check3Token releasedReleased onlyon a real matchNothing inside the app to trick.APP CHECKS, THEN READS1Face ID prompt2App gets "yes" or "no"Just a value inside the app3App reads the tokenThe token has no access controlAttacker forces "yes", or skips straight to 3Anyone who can run code on the phone gets in.PHONE CHECKS, THEN RELEASES1App asks for the tokenPHONE'S SECURITY CHIP2Face ID check3Token releasedReleased only on a real matchNothing inside the app to trick.

How to spot itLook for evaluatePolicy guarding a token saved without kSecAttrAccessControl. In the compiled app, run Objection's ios ui biometrics_bypass and fail Face ID. If the app still unlocks, it has this mistake.

objection · com.example.app
objection (python3.12)
com.example.app (run) on (iOS: 18.5) [usb] # ios ui biometrics_bypass
(agent) Registering job NaN. Name: ios-biometrics-disable-evaluatePolicy
(agent) Registering job NaN. Name: ios-biometrics-disable-evaluateAccessControl
com.example.app (run) on (iOS: 18.5) [usb] # (agent) [NaN] Localized Reason for auth requirement (evaluatePolicy): Unlock your account
(agent) [NaN] OS authentication response: false
(agent) [NaN] Marking OS response as True instead
(agent) [NaN] Biometrics bypass hook complete (evaluatePolicy)
Face ID failed, but the hook told the app "yes".

The fixPut the Face ID requirement in the refresh token's access control, and remove any "check, then read" logic. The bypass then achieves nothing.

Mistake 5: "Any face" instead of "this face"

The refresh token's access control uses .biometryAny or .userPresence, so faces or fingers added later also work, and .userPresence also accepts the passcode. It's a lock that accepts any key added to the key ring, now or later.

ExampleA controlling partner who knows the passcode adds an "alternate appearance" in the phone's Face ID settings. From then on, they can open your app with their own face, see the account and approve sensitive actions. The owner gets no warning.

How to spot itLook for .biometryAny or .userPresence. In the dump, the ACL column shows each secret's access control.

objection · com.example.app
objection (python3.12)
com.example.app (run) on (iOS: 18.5) [usb] # ios keychain dump
Note: You may be asked to authenticate using the devices passcode or TouchID
Save the output by adding `--json keychain.json` to this command
Dumping the iOS keychain...
Created                    Accessible                     ACL                           Type      Account        Service           Data
-------------------------  -----------------------------  ----------------------------  --------  -------------  ----------------  ------------
2026-10-14 09:12:41 +0000  WhenPasscodeSetThisDeviceOnly  kSecAccessControlBiometryAny  Password  refresh_token  com.example.auth  eyJhbGciOi...
The ACL column shows kSecAccessControlBiometryAny, so a face added later can unlock the refresh token. The token in the Data column only appears because the tester passed Face ID on the test phone first.

The fixUse .biometryCurrentSet for the refresh token.

Mistake 6: Shared too widely, kept too long

The refresh token is shared with every widget and extension, and it stays in the Keychain after the app is deleted. It's like giving every room a key to the safe, and never changing the lock between tenants.

ExampleA parent deletes your app from an old iPad and hands it down to their child without erasing it. The child installs the app again and is signed straight into the parent's account, because the refresh token was still in the Keychain. Wide sharing adds to the risk: every widget and extension in the access group, including any third-party code inside it, can read the token too.

How to spot itCheck which secrets set kSecAttrAccessGroup, and which targets share that group. Secrets saved without it go into the app's default access group: the first entry under Keychain Sharing in Xcode's Signing & Capabilities (the keychain-access-groups entitlement). If the app has no Keychain Sharing entries, the default is the app's own ID (team ID plus bundle ID), which isn't shared unless you share it on purpose. So if the first Keychain Sharing entry is a group shared with extensions, every secret saved without an explicit group is shared with them too. In the compiled app, codesign -d --entitlements - YourApp.app shows the list in order.

The fixOnly share the refresh token with targets that truly need it. Keychain items currently survive deleting the app (undocumented behavior that Apple could change), so delete the app's tokens on the first launch after a reinstall. The reinstall check under Update existing users does this without signing out people who simply updated.

Our recommendation

Storing tokens in the Keychain is the right start, but it's only half the job. Start with the most secure setup, and only step down where your threat model allows. Each step down gives up some protection:

LevelSetupWhat you give up
Most secureRefresh token on this phone only, readable only while unlocked with a passcode set, and behind the owner's current Face ID. Short-lived access token kept in memory, or saved with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly if background tasks need it. Both tokens bound to the phone with DPoP. Refresh tokens rotated on every use.One Face ID prompt per session. Needs DPoP support on your server.
StrongThe same, without DPoP.A token copied off the phone works on a laptop or another phone until it expires or is rotated.
MinimumRefresh token on this phone only and readable only while unlocked with a passcode set, without Face ID or DPoP. Access token and rotation as in Most secure.The same as Strong. Also, anyone holding the unlocked phone can use the app as the user, and code running in the app, for example on a jailbroken phone, can read the refresh token while the phone is unlocked. Suits apps where a Face ID prompt would be too much friction.

Never go below the minimum: below it, you're back to the six mistakes above. At every level, never sync tokens to iCloud, don't share them with extensions that don't need them, clear them after a reinstall, and keep Face ID checks in the access control, never in the app's own code. Because every level needs a passcode, users without one can't stay signed in (see mistake 2).

How to implement it

The code below follows the most secure setup from our recommendation above. For the minimum level, skip SecAccessControlCreateWithFlags and set kSecAttrAccessible to kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly directly. For the strong level, skip the DPoP step.

Save and read the refresh token

Save the refresh token with both settings. When the app reads it, the phone shows Face ID itself, so there's nothing in the app for an attacker to fake.

Save the refresh tokenSwift
import Security
import LocalAuthentication

enum KeychainError: Error { case unexpectedStatus(OSStatus) }

func storeRefreshToken(_ token: Data) throws {
    var cfError: Unmanaged<CFError>?
    guard let access = SecAccessControlCreateWithFlags(
        kCFAllocatorDefault,
        kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, // unlocked + passcode set, never leaves this device
        .biometryCurrentSet,                             // adding or removing a face invalidates the item
        &cfError
    ) else { throw cfError!.takeRetainedValue() as Error }
    let base: [String: Any] = [
        kSecClass as String:       kSecClassGenericPassword,
        kSecAttrService as String: "com.example.auth",
        kSecAttrAccount as String: "refresh_token"
    ]
    var old = base
    old[kSecAttrSynchronizable as String] = kSecAttrSynchronizableAny
    SecItemDelete(old as CFDictionary)  // replace any older, weaker item. Synced copies are
                                        // removed from the user's other devices too.
    var add = base
    add[kSecValueData as String]          = token
    add[kSecAttrAccessControl as String]  = access   // includes the accessibility, so no kSecAttrAccessible
    add[kSecAttrSynchronizable as String] = false   // never sync to iCloud
    let status = SecItemAdd(add as CFDictionary, nil)
    guard status == errSecSuccess else { throw KeychainError.unexpectedStatus(status) }
}
Read the refresh tokenSwift
func readRefreshToken() throws -> Data? {
    let context = LAContext()
    context.localizedReason = "Unlock your account"
    let query: [String: Any] = [
        kSecClass as String:                    kSecClassGenericPassword,
        kSecAttrService as String:              "com.example.auth",
        kSecAttrAccount as String:              "refresh_token",
        kSecReturnData as String:               true,
        kSecMatchLimit as String:               kSecMatchLimitOne,
        kSecUseAuthenticationContext as String: context
    ]
    var result: CFTypeRef?
    let status = SecItemCopyMatching(query as CFDictionary, &result) // waits for Face ID: call off the main thread
    switch status {
    case errSecSuccess:      return result as? Data
    // nil: not saved, Face ID cancelled or failed, or invalidated by a Face ID change
    case errSecItemNotFound, errSecUserCanceled, errSecAuthFailed: return nil
    default: throw KeychainError.unexpectedStatus(status)
    }
}

Read it once per session

Every read shows Face ID, so read the refresh token once when the app opens and keep the tokens in memory while the app is in use. Forget them when the app goes to the background. If background tasks need to call the server, store the access token separately with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. Keep its lifetime to minutes, so the exposure from mistake 1 stays small. Never do this with the refresh token. If the access token has expired, the refresh waits until the user opens the app.

Keep the tokens in memory for one sessionSwift
// Keeps the tokens in memory for one session, so Face ID is shown once.
@MainActor
final class TokenSession {
    private(set) var accessToken: String?
    private(set) var refreshToken: Data?
    // When the app opens: one Face ID prompt, shown by readRefreshToken(). Then use
    // the refresh token to get a new access token, and pass both to didRefresh.
    // The read blocks until Face ID finishes, so it runs off the main thread.
    // nil means signed out, a face was added or removed, or Face ID was
    // cancelled or failed. Ask the user to try again or sign in.
    func unlock() async throws {
        refreshToken = try await Task.detached { try readRefreshToken() }.value
    }
    // After each refresh: keep the new tokens in memory and save the rotated
    // refresh token. Saving doesn't show Face ID. Only reading does.
    // If background tasks need the access token, also save it separately with
    // kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly (never the refresh token).
    // If saving fails, keep using the in-memory token and retry before lock():
    // the old stored token is already deleted.
    func didRefresh(accessToken: String, refreshToken: Data) throws {
        self.accessToken = accessToken
        self.refreshToken = refreshToken
        try storeRefreshToken(refreshToken)
    }
    // When the app goes to the background, or after a few idle minutes:
    // forget the tokens, so the next session needs Face ID again.
    func lock() {
        accessToken = nil
        refreshToken = nil
    }
}

Handle Face ID changes

If a face or fingerprint is added or removed, or the passcode is removed, the refresh token stops working. Treat that as signed out and ask the user to sign in. For users without Face ID or Touch ID, a passcode fallback ([.biometryCurrentSet, .or, .devicePasscode]) works, but anyone who knows the passcode can then use the token, so keep sensitive actions on Face ID only.

Use an app PIN in the access control

Some apps, such as banking apps, ask for their own PIN instead of, or as well as, Face ID. Tie that PIN to the access control with .applicationPassword, rather than checking it in the app's own code. The Keychain then needs the PIN to release the refresh token, so faking the app's PIN check doesn't help an attacker. They would have to capture or guess the PIN itself. Apple doesn't document a limit on wrong attempts, so a short PIN could be guessed by an attacker who can run code in the app. Use six or more digits, and combine it with Face ID where you can.

Tie an app PIN to the access controlSwift
// Require the app PIN as well as the owner's current Face ID.
// (Use .applicationPassword alone for a PIN without Face ID.)
let access = SecAccessControlCreateWithFlags(
    nil, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
    [.biometryCurrentSet, .and, .applicationPassword], nil)!

// Hand over the PIN from your own PIN screen, so iOS doesn't ask for it.
let context = LAContext()
_ = context.setCredential(Data(pin.utf8), type: .applicationPassword)

// Save the refresh token with both kSecAttrAccessControl: access and
// kSecUseAuthenticationContext: context, and pass a new context with the
// PIN when reading. A wrong PIN makes the read fail (typically errSecAuthFailed).

Update existing users

New code only protects tokens saved from now on. Save existing refresh tokens again once, on the first launch after the update, while the app is on screen. The reinstall check below runs first: it clears secrets left by a previous install but keeps them after an update, so updating doesn't sign anyone out.

Reinstall checkSwift
// Call once at launch, while the app is on screen, before reading any secrets.
// Keychain items survive deleting the app, but UserDefaults doesn't. Comparing the two
// tells a reinstall apart from an app update.
func prepareKeychain() {
    let defaults = UserDefaults.standard
    let marker: [String: Any] = [
        kSecClass as String:       kSecClassGenericPassword,
        kSecAttrService as String: "com.example.auth",
        kSecAttrAccount as String: "install_marker"
    ]
    let keychainHasMarker = SecItemCopyMatching(marker as CFDictionary, nil) == errSecSuccess
    let defaultsHasMarker = defaults.bool(forKey: "installMarker")
    if keychainHasMarker && !defaultsHasMarker {
        // Reinstall: these secrets belong to a previous install. Clear them.
        // Only items on this phone: deleting synced items would remove them everywhere.
        let all: [String: Any] = [
            kSecClass as String:       kSecClassGenericPassword,
            kSecAttrService as String: "com.example.auth"
        ]
        SecItemDelete(all as CFDictionary)
        defaults.set(true, forKey: "keychainMigratedV2") // a reinstall has nothing to migrate
    }
    // Upgrade secrets saved by older app versions (see One-time update below). Does nothing
    // once it has succeeded, or after a reinstall. Note: a first launch after upgrading
    // from a version without the marker looks like an update, so a reinstall over
    // such a version keeps its old secrets this once.
    migrateRefreshTokenIfNeeded()
    // First launch of this install (or the marker was just cleared): mark it in both places.
    if !keychainHasMarker || !defaultsHasMarker {
        var add = marker
        add[kSecValueData as String] = Data("1".utf8)
        add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
        SecItemAdd(add as CFDictionary, nil)
        defaults.set(true, forKey: "installMarker")
    }
}
One-time updateSwift
func migrateRefreshTokenIfNeeded() {
    guard !UserDefaults.standard.bool(forKey: "keychainMigratedV2") else { return }
    // The new settings need Face ID or Touch ID. Without them, saving would fail,
    // so keep the old item (or save it with the passcode fallback from Handle Face ID changes).
    guard LAContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
    else { return }
    let context = LAContext()
    context.interactionNotAllowed = true   // old item has no ACL, so no prompt
    let query: [String: Any] = [
        kSecClass as String:                    kSecClassGenericPassword,
        kSecAttrService as String:              "com.example.auth",
        kSecAttrAccount as String:              "refresh_token",
        kSecAttrSynchronizable as String:       kSecAttrSynchronizableAny, // include iCloud-synced items
        kSecReturnData as String:               true,
        kSecReturnAttributes as String:         true,
        kSecUseAuthenticationContext as String: context
    ]
    var result: CFTypeRef?
    let status = SecItemCopyMatching(query as CFDictionary, &result)
    if status == errSecSuccess,
       let old = result as? [String: Any],
       let token = old[kSecValueData as String] as? Data {
        do {
            try storeRefreshToken(token)   // re-saves it with the new settings
        } catch {
            // Saving failed after the old item was removed: put it back as it was,
            // then try again next launch.
            let restore: [String: Any] = [
                kSecClass as String:              kSecClassGenericPassword,
                kSecAttrService as String:        "com.example.auth",
                kSecAttrAccount as String:        "refresh_token",
                kSecValueData as String:          token,
                kSecAttrAccessible as String:     old[kSecAttrAccessible as String] ?? kSecAttrAccessibleWhenUnlocked,
                kSecAttrSynchronizable as String: old[kSecAttrSynchronizable as String] ?? false
            ]
            SecItemAdd(restore as CFDictionary, nil)
            return
        }
    } else if status != errSecItemNotFound {
        return                             // e.g. phone locked, or old item has an ACL: try again next launch
    }
    UserDefaults.standard.set(true, forKey: "keychainMigratedV2")
}

Rotate refresh tokens

Issue a new refresh token every time one is used, and invalidate the old one. If an old token is used again, someone has a copy: revoke the session. Allow a few seconds' grace so a network retry doesn't sign out a real user, and make it possible to sign out a single phone.

Bind the tokens with DPoP

The Keychain protects tokens while they're stored, but they're still bearer tokens: a copy works on any phone or laptop. DPoP (Demonstrating Proof of Possession, RFC 9449) binds both tokens to a key in the Secure Enclave, the phone's security chip, which creates keys no one can copy out. The app signs a short proof with that key on every request, and the server rejects any token that arrives without a valid proof.

How DPoP binds tokens to a Secure Enclave key The phone holds a key in its Secure Enclave that can't be copied. At sign-in, it sends the public key, and the server binds both tokens to it. On each request, the phone sends the token with a proof signed by the chip, and the server accepts it. An attacker's laptop with a copied token but no key sends no valid proof, and the server rejects it.Phone with the appSECURE ENCLAVESigning key, can't be copiedAttacker's laptopHas a copied token, but no keyYour server1Public key, at sign-in2Token + proof signed by the chip3Copied token, no valid proofBinds both tokens to the keyAcceptedRejectedPhone with the appSECURE ENCLAVESigning key,can't be copiedYour server1Public key, at sign-inBinds both tokensto the key2Token + proof signed by thechipAcceptedAttacker's laptopHas a copied token, butno key3Copied token, no valid proofRejected
Create a Secure Enclave key and sign DPoP proofsSwift
import CryptoKit
import Foundation

enum DeviceKeyError: Error { case noSecureEnclave }
// 1. Once, at sign-in: create a key in the Secure Enclave and send its
//    public half to the server. No Face ID here, because the app signs a proof
//    with every request, including background ones, so it's usable after the
//    first unlock. Code running in the app on this phone could still sign with it, but the
//    key can't leave the chip, and without the refresh token no new access tokens are issued.
//    Save key.dataRepresentation in the Keychain (AfterFirstUnlockThisDeviceOnly) to reload it.
func makeDeviceKey() throws -> SecureEnclave.P256.Signing.PrivateKey {
    guard SecureEnclave.isAvailable else { throw DeviceKeyError.noSecureEnclave } // e.g. Simulator
    var error: Unmanaged<CFError>?
    guard let access = SecAccessControlCreateWithFlags(
        nil, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, .privateKeyUsage, &error)
    else { throw error!.takeRetainedValue() as Error }
    return try SecureEnclave.P256.Signing.PrivateKey(accessControl: access)
}
// 2. With every request: a DPoP proof (RFC 9449), sent in the "DPoP" header.
//    Pass the access token for API calls, and nil when refreshing tokens.
func dpopProof(key: SecureEnclave.P256.Signing.PrivateKey,
               method: String, url: URL, accessToken: String?) throws -> String {
    var target = URLComponents(url: url, resolvingAgainstBaseURL: false)!
    target.query = nil
    target.fragment = nil
    let pub = key.publicKey.rawRepresentation          // x and y, 32 bytes each
    let header: [String: Any] = [
        "typ": "dpop+jwt", "alg": "ES256",
        "jwk": ["kty": "EC", "crv": "P-256",
                "x": b64url(pub.prefix(32)), "y": b64url(pub.suffix(32))]
    ]
    var payload: [String: Any] = [
        "jti": UUID().uuidString,                      // unique per proof
        "htm": method,                                 // e.g. "GET"
        "htu": target.string!,                         // URL without query or fragment
        "iat": Int(Date.now.timeIntervalSince1970)     // issued at, in seconds
    ]
    if let accessToken {                               // ties the proof to this access token
        payload["ath"] = b64url(Data(SHA256.hash(data: Data(accessToken.utf8))))
    }
    let signingInput = b64url(try JSONSerialization.data(withJSONObject: header, options: .withoutEscapingSlashes)) + "." +
                       b64url(try JSONSerialization.data(withJSONObject: payload, options: .withoutEscapingSlashes))
    let signature = try key.signature(for: Data(signingInput.utf8))
    return signingInput + "." + b64url(signature.rawRepresentation)
}
func b64url(_ data: Data) -> String {
    data.base64EncodedString()
        .replacingOccurrences(of: "+", with: "-")
        .replacingOccurrences(of: "/", with: "_")
        .replacingOccurrences(of: "=", with: "")
}

The key doesn't need Face ID for each request, and it doesn't move to a new phone, so users sign in again there. DPoP needs support on your OAuth server, and if the server requires a DPoP nonce, the app must add it to each proof (the code above leaves this out).

Not sure how your app stores its tokens? We test exactly this. Get in touch.

References

Found this useful? Share it with your network.