# Credential Storage Patterns

OAuth tokens, API keys and passwords have exactly one place Apple sanctions for
them on the device: the Keychain. When a 2025 study pulled apart App Store
binaries, 71% of the iOS apps it examined exposed one or more embedded secrets, most
often through `UserDefaults`,
`Info.plist` or `.xcconfig` values, all of which can be read out of a backup or an
unzipped IPA.

This file covers the storage lifecycle: where credentials go, how they are written
and refreshed, how they are cleared, rotated and migrated. The sign-in UI itself
(Sign in with Apple, passkeys, `ASAuthorizationController`) belongs to the
`authentication` skill, and App Attest belongs to `device-integrity`; both appear
here only as far as token storage depends on them.

Grounding: Apple's Keychain Services and Authentication Services documentation; the
December 2024 edition of the Platform Security Guide; WWDC sessions 516 (2019), 10105
(2021) and 10125 (2024); on the OWASP side the 2024 Mobile Top 10, the January 2024
MASVS release 2.1.0 and MASTG version 2; version 2.0 of the joint CISA and FBI list of
product security bad practices, published January 2025; and a March 2025 industry
survey of secrets in iOS apps.

## Contents

- [Six ways credentials leak](#six-ways-credentials-leak)
- [Choosing the protection class](#choosing-the-protection-class)
- [One actor in front of the Keychain](#one-actor-in-front-of-the-keychain)
- [OAuth tokens: from sign-in to Keychain](#oauth-tokens-from-sign-in-to-keychain)
- [Refresh that survives crashes and races](#refresh-that-survives-crashes-and-races)
- [API keys fetched at runtime](#api-keys-fetched-at-runtime)
- [Logout clears everything](#logout-clears-everything)
- [Rotation and versioned storage](#rotation-and-versioned-storage)
- [Device binding and restores](#device-binding-and-restores)
- [Biometrics on high-value credentials](#biometrics-on-high-value-credentials)
- [What iOS 17 and 18 changed](#what-ios-17-and-18-changed)
- [Guardrails in CI](#guardrails-in-ci)
- [OWASP mapping](#owasp-mapping)
- [The three decisions that matter](#the-three-decisions-that-matter)
- [Checklist](#checklist)

## Six ways credentials leak

**1. UserDefaults.** Values are written as plain XML into the app container's
`Library/Preferences` folder, in a plist named after the bundle identifier
(under `/var/mobile/Containers/Data/Application/` on the device).
That file is part of Finder and iTunes backups and desktop backup browsers read it
from a non-jailbroken phone; on a jailbroken one, `objection` reads it with
`ios nsuserdefaults get`. Apple's documentation says not to keep sensitive data
there. OWASP mapping: Top 10 M9, control MASVS-STORAGE-1, weakness MASWE-0002; it
fails MASTG-TEST-0300 and MASTG-TEST-0301. Code
samples are in [common-anti-patterns.md](common-anti-patterns.md) (#1).

**2. Keys in source.** CISA and the FBI list hardcoded credentials as a bad practice
(item 8 of their list), and CWE-798 sits in the 2024 CWE Top 25. The 2025 study pulled
more than 815,000 secrets from 156,080 iOS apps just by unzipping IPAs; payment and
backend API keys fall out of `strings MyApp | grep sk_live`. Maps to M1 and
MASWE-0005.

**3. `.xcconfig` as a hiding place.** It keeps a secret out of git, nothing more. A
`$(API_TOKEN)` reference in `Info.plist` is substituted at build time, so the
plaintext value ships in the bundle; `unzip` the IPA and run `plutil -p` on
`Info.plist` to see it.

**4. No explicit accessibility.** Without `kSecAttrAccessible` the item gets
`kSecAttrAccessibleWhenUnlocked` (the default since iOS 4). That class migrates to a
new device through an encrypted backup, and on a device without a passcode it is
effectively always unlocked. Set `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` to
keep the item on this hardware.

**5. Non-atomic refresh.** Deleting the old tokens, then crashing before the new ones
are stored, leaves the user signed out or half signed in. Two refreshes racing can
store a refresh token the server already rotated away; with refresh token rotation
(RTR) replaying it can revoke the whole token family.

**6. Partial logout.** Removing the access token but leaving the refresh token behind
keeps a longer-lived credential on the device that can mint new access tokens.

```swift
// Broken logout: only one of two credentials removed
let query: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrService as String: "com.example.bank.auth",
    kSecAttrAccount as String: "access_token"
]
SecItemDelete(query as CFDictionary)
```

The baseline that avoids all six: Keychain only, an explicit accessibility class on
every item, add-then-update-on-duplicate writes that check every `OSStatus`, and a
logout that removes every credential artifact.

## Choosing the protection class

Every Keychain row is protected twice with AES-256-GCM. Its attributes use a key the
system keeps cached; its secret value uses a key specific to that row, and unwrapping
that one means asking the Secure Enclave (details in [keychain-fundamentals.md](keychain-fundamentals.md)).
Accessibility decides when that unwrap is allowed.

| Class | Behaviour | Use for |
| --- | --- | --- |
| `WhenUnlockedThisDeviceOnly` | Device-bound, unavailable while locked | Recommended default: OAuth tokens, API keys |
| `WhenPasscodeSetThisDeviceOnly` | Highest assurance; destroyed if the passcode is removed | High-value secrets |
| `AfterFirstUnlockThisDeviceOnly` | Readable in the background after first unlock; longer exposure | Only when background work needs it, such as a silent push handler |
| `AfterFirstUnlock` | Not device-bound; moves through backups | Avoid for sensitive tokens |

Do not set `kSecAttrSynchronizable` on session tokens. Syncing through iCloud
Keychain is meant for passwords the user sees and manages, not for credentials an app
minted for itself.

More detail on classes and flags: [keychain-access-control.md](keychain-access-control.md).

## One actor in front of the Keychain

The `SecItem*` functions (present since iOS 2.0) can be called from any thread when
each call touches a different item. Two calls aimed at one item, however, race: two simultaneous adds end with one
`errSecDuplicateItem`, and a read between a delete and an add sees nothing. An actor
(iOS 13+, and the idiomatic choice in code targeting iOS 17 and later) serializes
every operation.

```swift
import Foundation
import Security

public actor CredentialVault {
    public enum Failure: Error {
        case status(OSStatus)
        case missing
        case encoding
        case decoding
    }

    private let service: String
    private let accessGroup: String?
    private let accessibility: CFString

    public init(service: String,
                accessGroup: String? = nil,
                accessibility: CFString = kSecAttrAccessibleWhenUnlockedThisDeviceOnly) {
        self.service = service
        self.accessGroup = accessGroup
        self.accessibility = accessibility
    }

    private func identity(for account: String) -> [String: Any] {
        var query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account
        ]
        if let accessGroup { query[kSecAttrAccessGroup as String] = accessGroup }
        #if os(macOS)
        query[kSecUseDataProtectionKeychain as String] = true
        #endif
        return query
    }

    public func store(_ secret: Data, account: String) throws {
        var insert = identity(for: account)
        insert[kSecValueData as String] = secret
        insert[kSecAttrAccessible as String] = accessibility

        let added = SecItemAdd(insert as CFDictionary, nil)
        if added == errSecSuccess { return }
        guard added == errSecDuplicateItem else { throw Failure.status(added) }

        let changes: [String: Any] = [
            kSecValueData as String: secret,
            kSecAttrAccessible as String: accessibility
        ]
        let updated = SecItemUpdate(identity(for: account) as CFDictionary,
                                    changes as CFDictionary)
        guard updated == errSecSuccess else { throw Failure.status(updated) }
    }

    public func read(account: String) throws -> Data {
        var query = identity(for: account)
        query[kSecReturnData as String] = true
        query[kSecMatchLimit as String] = kSecMatchLimitOne

        var output: CFTypeRef?
        let outcome = SecItemCopyMatching(query as CFDictionary, &output)
        if outcome == errSecItemNotFound { throw Failure.missing }
        guard outcome == errSecSuccess else { throw Failure.status(outcome) }
        guard let payload = output as? Data else { throw Failure.decoding }
        return payload
    }

    public func remove(account: String) throws {
        try Self.acceptingAbsence(SecItemDelete(identity(for: account) as CFDictionary))
    }

    private static func acceptingAbsence(_ outcome: OSStatus) throws {
        if outcome != errSecSuccess && outcome != errSecItemNotFound {
            throw Failure.status(outcome)
        }
    }

    public func removeEverything() throws {
        var query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service
        ]
        if let accessGroup { query[kSecAttrAccessGroup as String] = accessGroup }
        #if os(macOS)
        query[kSecUseDataProtectionKeychain as String] = true
        #endif
        try Self.acceptingAbsence(SecItemDelete(query as CFDictionary))
    }
}
```

Notes on the shape:

- `store` adds first and updates only on `errSecDuplicateItem`. The update query is
  the item's identity (class, service, account, group) and carries no accessibility
  or data; the new data and accessibility go in the attributes dictionary. Any other
  status throws. Deleting and re-adding is reserved for changing an item's
  `kSecAttrAccessControl`, which cannot be updated in place.
- `read` asks for data with a match limit of one and turns not-found into a typed
  error. `remove` treats not-found as success. `removeEverything` deletes by class
  and service (plus group) and is what logout calls.
- Because this is an actor, none of these can interleave, and callers suspend
  instead of blocking a thread in the cooperative pool.

When the same serialization has to span several types or modules, a global actor
does the job:

```swift
@globalActor
actor KeychainIsolation {
    static let shared = KeychainIsolation()
}

@KeychainIsolation
func rotateDeviceSecret(using vault: CredentialVault) async throws {
    let fresh = Data(UUID().uuidString.utf8)
    try await vault.store(fresh, account: "device_secret")
}
```

### Storing CryptoKit keys with the same vault

Apple's sample code for keeping CryptoKit keys in the Keychain defines a small protocol for
keys that have no `SecKey` form (Curve25519, post-quantum keys, Secure Enclave
blobs), so they can be kept as generic passwords:

```swift
import CryptoKit

protocol GenericPasswordConvertible {
    init<Bytes: ContiguousBytes>(rawRepresentation: Bytes) throws
    var rawRepresentation: Data { get }
}

extension Curve25519.Signing.PrivateKey: GenericPasswordConvertible {}

extension CredentialVault {
    func storeKey<K: GenericPasswordConvertible>(_ key: K, account: String) throws {
        try store(key.rawRepresentation, account: account)
    }

    func loadKey<K: GenericPasswordConvertible>(_ type: K.Type, account: String) throws -> K {
        try K(rawRepresentation: read(account: account))
    }
}
```

The write goes through the same add-then-update-on-duplicate `store`. Which key
types need this and which go in as `kSecClassKey` is covered in
[cryptokit-public-key.md](cryptokit-public-key.md).

## OAuth tokens: from sign-in to Keychain

Web-based sign-in uses `ASWebAuthenticationSession` (iOS 12+). Running OAuth inside a
`WKWebView` or `SFSafariViewController` is an anti-pattern: the hosting app can read
or script the page and capture the password. WWDC 2019 also told apps to leave the
deprecated `SFAuthenticationSession`. The UI side is the `authentication` skill's
territory; what matters here is what gets stored.

```swift
struct SessionTokens: Codable {
    let accessToken: String
    let refreshToken: String
    let expiresAt: Date
    let tokenType: String

    var isExpired: Bool { Date() >= expiresAt }
    var needsRefresh: Bool { Date().addingTimeInterval(60) >= expiresAt }
}
```

Refresh before expiry, either with a fixed buffer (60 seconds above) or at 75-90% of
the token lifetime, to absorb network latency and clock skew.

The authorization-code flow with PKCE (RFC 7636) for a public client (no client
secret on the device):

```swift
import AuthenticationServices
import CryptoKit

func base64URL(_ data: Data) -> String {
    data.base64EncodedString()
        .replacingOccurrences(of: "+", with: "-")
        .replacingOccurrences(of: "/", with: "_")
        .replacingOccurrences(of: "=", with: "")
}

func makeVerifier() throws -> String {
    var entropy = Data(count: 32)
    let rc = entropy.withUnsafeMutableBytes { buffer in
        buffer.baseAddress.map { SecRandomCopyBytes(kSecRandomDefault, 32, $0) } ?? errSecAllocate
    }
    guard rc == errSecSuccess else { throw CredentialVault.Failure.status(rc) }
    return base64URL(entropy)
}

@MainActor
final class SignInCoordinator: NSObject, ASWebAuthenticationPresentationContextProviding {
    private let vault: CredentialVault
    private let anchor: ASPresentationAnchor

    init(vault: CredentialVault, anchor: ASPresentationAnchor) {
        self.vault = vault
        self.anchor = anchor
    }

    func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor { anchor }

    func signIn() async throws {
        let verifier = try makeVerifier()
        let digest = SHA256.hash(data: Data(verifier.utf8))
        let challenge = base64URL(Data(digest))
        let state = UUID().uuidString

        guard var parts = URLComponents(string: "https://login.example.com/authorize") else { return }
        parts.queryItems = [
            .init(name: "response_type", value: "code"),
            .init(name: "client_id", value: "ios-reader"),
            .init(name: "redirect_uri", value: "exampleapp://oauth"),
            .init(name: "scope", value: ["openid", "profile", "offline_access"].joined(separator: " ")),
            .init(name: "code_challenge", value: challenge),
            .init(name: "code_challenge_method", value: "S256"),
            .init(name: "state", value: state)
        ]
        guard let authorizeURL = parts.url else { return }

        let callback: URL = try await withCheckedThrowingContinuation { waiter in
            let session = ASWebAuthenticationSession(
                url: authorizeURL, callbackURLScheme: "exampleapp"
            ) { redirect, failure in
                guard let redirect else {
                    waiter.resume(throwing: failure ?? URLError(.cancelled))
                    return
                }
                waiter.resume(returning: redirect)
            }
            session.prefersEphemeralWebBrowserSession = true
            session.presentationContextProvider = self
            session.start()
        }

        let returned = Dictionary(
            (URLComponents(url: callback, resolvingAgainstBaseURL: false)?.queryItems ?? [])
                .map { ($0.name, $0.value ?? "") },
            uniquingKeysWith: { first, _ in first }
        )
        guard returned["state"] == state, let code = returned["code"] else {
            throw URLError(.badServerResponse)
        }

        let tokens = try await exchange(code: code, verifier: verifier)
        try await vault.store(try JSONEncoder().encode(tokens), account: "oauth_tokens_v2")
    }

    private func exchange(code: String, verifier: String) async throws -> SessionTokens {
        throw URLError(.unsupportedURL) // POST code + code_verifier to the token endpoint here
    }
}
```

The verifier is 32 random bytes from `SecRandomCopyBytes`, base64url-encoded without
padding; the challenge is base64url of its SHA-256, sent with method `S256`. The state
value is checked on return. `prefersEphemeralWebBrowserSession = true` (iOS 13+) shares
no cookies with Safari, which gives the strongest isolation at the cost of single
sign-on; decide per product.

From iOS 17.4, `ASWebAuthenticationSession.Callback` can use an HTTPS universal link
as the redirect. That proves domain ownership and is much harder for another app to
intercept than a custom scheme (RFC 8252).

## Refresh that survives crashes and races

Identity providers that implement refresh token rotation issue a new refresh token
on every refresh and invalidate the old one. If the app persists the new access token
and the process dies before the matching refresh token reaches storage, the next
launch has nothing valid to refresh with and the user must sign in again. Many
providers keep the previous refresh token valid for a short grace period (often
configurable from 0 to 60 seconds, 30 is common) so a retried request still works;
presenting it after the grace period revokes the entire family, which the server
treats as a sign of theft.

Store both tokens as one encoded item so that a single write replaces them together,
encode before touching the Keychain, and route the write through the actor:

```swift
extension CredentialVault {
    func replaceTokens(_ tokens: SessionTokens) throws {
        guard let encoded = try? JSONEncoder().encode(tokens) else { throw Failure.encoding }
        try store(encoded, account: "oauth_tokens_v2")
    }
}
```

There is no delete step. `store` adds, and on `errSecDuplicateItem` updates the
existing row in place, so at no point is the item absent.

Concurrent callers must share one refresh:

```swift
actor RefreshGate {
    private let vault: CredentialVault
    private let tokenEndpoint: URL
    private var inFlight: Task<SessionTokens, Error>?

    init(vault: CredentialVault, tokenEndpoint: URL) {
        self.vault = vault
        self.tokenEndpoint = tokenEndpoint
    }

    func validTokens(current: SessionTokens) async throws -> SessionTokens {
        if !current.needsRefresh { return current }
        if let inFlight { return try await inFlight.value }

        let task = Task { try await self.refresh(using: current) }
        inFlight = task
        defer { inFlight = nil }
        return try await task.value
    }

    private func refresh(using old: SessionTokens) async throws -> SessionTokens {
        var request = URLRequest(url: tokenEndpoint)
        request.httpMethod = "POST"
        request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
        request.httpBody = Data("grant_type=refresh_token&refresh_token=\(old.refreshToken)".utf8)

        let reply = try await URLSession.shared.data(for: request)
        let body = reply.0
        let code = (reply.1 as? HTTPURLResponse)?.statusCode ?? 0

        switch code {
        case 200:
            let decoder = JSONDecoder()
            decoder.keyDecodingStrategy = .convertFromSnakeCase
            let payload = try decoder.decode(RefreshPayload.self, from: body)
            let next = SessionTokens(
                accessToken: payload.accessToken,
                refreshToken: payload.refreshToken ?? old.refreshToken,
                expiresAt: Date().addingTimeInterval(payload.expiresIn),
                tokenType: payload.tokenType
            )
            try await vault.replaceTokens(next)
            return next
        case 400, 401:
            try await vault.removeEverything()
            throw SessionError.refreshExpired
        default:
            throw SessionError.server(code)
        }
    }
}

struct RefreshPayload: Decodable {
    let accessToken: String
    let refreshToken: String?
    let expiresIn: TimeInterval
    let tokenType: String
}

enum SessionError: Error {
    case refreshExpired
    case server(Int)
}
```

A token that is not near expiry is returned as is; a refresh already running is
awaited instead of started again; the task slot clears itself when done. If the
server does not return a new refresh token, the old one is kept. A 400 or 401 means
the refresh token is dead, so everything is wiped and the user signs in again.

## API keys fetched at runtime

The best design keeps third-party API keys off the device entirely behind your own
backend proxy. If the device really must hold the key, download it when needed and keep
a Keychain copy stamped with its own expiry, since Keychain items never expire by
themselves.

Before the backend hands out a secret, have the app prove it is genuine with App
Attest (`DCAppAttestService`, iOS 14+): the app creates a Secure Enclave key pair,
Apple vouches for it, and only after checking that attestation on the server does
your backend hand out keys, and short-lived ones at that. The attestation mechanics belong to `device-integrity`.

```swift
struct CachedSecret: Codable {
    let value: String
    let fetchedAt: Date
    let lifetime: TimeInterval
    var isExpired: Bool { fetchedAt.addingTimeInterval(lifetime) < Date() }
}

actor RemoteSecretStore {
    private let vault: CredentialVault
    private let fetch: @Sendable (String) async throws -> String
    private var memory: [String: CachedSecret] = [:]

    init(vault: CredentialVault, fetch: @escaping @Sendable (String) async throws -> String) {
        self.vault = vault
        self.fetch = fetch
    }

    func secret(named name: String, lifetime: TimeInterval = 3600) async throws -> String {
        if let hit = memory[name], !hit.isExpired { return hit.value }

        let account = "secret_\(name)"
        let fromDisk = (try? await vault.read(account: account))
            .flatMap { try? JSONDecoder().decode(CachedSecret.self, from: $0) }
        if let stored = fromDisk, !stored.isExpired {
            memory[name] = stored
            return stored.value
        }

        let fresh = CachedSecret(value: try await fetch(name), fetchedAt: Date(), lifetime: lifetime)
        try await vault.store(try JSONEncoder().encode(fresh), account: account)
        memory[name] = fresh
        return fresh.value
    }
}
```

Lookup order is memory, then Keychain, then network, with a one-hour default
lifetime.

## Logout clears everything

Logout removes the access token, the refresh token, cached secrets, stored profile
data and in-memory caches, and revokes the tokens on the server when it can. Keeping
every auth item under one `kSecAttrService` means one `SecItemDelete` clears them.

```swift
struct SignOutService {
    let vault: CredentialVault
    let revokeEndpoint: URL
    let authHost: String

    // Covers storage controls 1 and 2 of MASVS
    func signOut(tokens: SessionTokens?) async {
        if let tokens {
            for token in [tokens.refreshToken, tokens.accessToken] {
                var request = URLRequest(url: revokeEndpoint)
                request.httpMethod = "POST"
                _ = try? await URLSession.shared.upload(for: request, from: Data("token=\(token)".utf8))
            }
        }
        try? await vault.removeEverything()

        let jar = HTTPCookieStorage.shared
        jar.cookies?.filter { $0.domain.hasSuffix(authHost) }.forEach(jar.deleteCookie)
        URLCache.shared.removeAllCachedResponses()
    }
}
```

Revocation is best effort and happens first (refresh token, then access token); local
cleanup happens regardless. The server can also force a logout: return 401 or 403
with a reason such as `token_revoked`, or send a silent push that triggers the same
cleanup in the background.

## Rotation and versioned storage

- Refresh tokens rotate on the server's schedule (RTR); grace periods are typically
  configurable between 0 and 60 seconds.
- Long-lived API keys need a planned rotation (issue a new least-privilege key,
  ship, verify traffic, revoke the old one) and a written emergency procedure for a
  leak.
- Version the storage layout through the account name, for example
  `oauth_tokens_v2`, so a format change can be migrated and rolled back.

```swift
extension CredentialVault {
    func upgradeTokenLayout() throws {
        if (try? read(account: "oauth_tokens_v2")) != nil { return }
        guard let legacy = try? read(account: "oauth_tokens_v1") else { return }

        let converted = try convertV1ToV2(legacy)
        try store(converted, account: "oauth_tokens_v2")
        guard (try? read(account: "oauth_tokens_v2")) == converted else { return }
        try remove(account: "oauth_tokens_v1")
    }

    private func convertV1ToV2(_ data: Data) throws -> Data { data }
}
```

The old item is removed as soon as the new one has been written and read back.
Keeping the old copy around "for rollback" only doubles the number of places a
credential can leak from; the same rule applies with more force to plaintext copies
in UserDefaults or files, which are deleted in the same step that verifies the
Keychain write (see [migration-legacy-stores.md](migration-legacy-stores.md)).

Signals of compromise: a rotated refresh token being replayed (revoke the family),
unusual location or time patterns, and leaked-password checks some identity
providers run against breach corpora. Refresh proactively at 75-90% of lifetime.

## Device binding and restores

`ThisDeviceOnly` items cannot be copied to another device, which also means they do
not come back after restoring an iCloud backup onto a new phone. Detect that on first
launch and send the user to sign in:

```swift
func routeAtLaunch(vault: CredentialVault) async -> LaunchDestination {
    do {
        let raw = try await vault.read(account: "oauth_tokens_v2")
        _ = try JSONDecoder().decode(SessionTokens.self, from: raw)
        return .home
    } catch CredentialVault.Failure.missing {
        return .signIn
    } catch {
        // log the failure without the token contents
        return .signIn
    }
}

enum LaunchDestination { case home, signIn }
```

`kSecAttrSynchronizable: true` copies an item to every device on the user's iCloud
account. That suits website passwords and widens the attack surface for session
tokens, so leave it out. The two cannot be combined anyway: Apple documents that a
synchronizable item may not use a `ThisDeviceOnly` accessibility value, and
`SecItemAdd` rejects that combination with `errSecParam` (-50).

## Biometrics on high-value credentials

A biometric `SecAccessControl` fits secrets used in a user-initiated moment, such as
authorizing a payment or revealing sensitive data. It does not fit a refresh token
that must renew silently in the background.

```swift
func protectPaymentPIN(_ pin: Data) throws {
    guard let gate = SecAccessControlCreateWithFlags(
        nil,
        kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
        [.biometryCurrentSet, .or, .devicePasscode],
        nil
    ) else { throw CredentialVault.Failure.encoding }

    let identity: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrService as String: "com.example.bank.auth",
        kSecAttrAccount as String: "payment_pin",
        kSecUseDataProtectionKeychain as String: true
    ]
    var insert = identity
    insert[kSecValueData as String] = pin
    insert[kSecAttrAccessControl as String] = gate

    var status = SecItemAdd(insert as CFDictionary, nil)
    if status == errSecDuplicateItem {
        SecItemDelete(identity as CFDictionary)
        status = SecItemAdd(insert as CFDictionary, nil)
    }
    guard status == errSecSuccess else { throw CredentialVault.Failure.status(status) }
}
```

`.or` needs iOS 11.3+. This is the MASTG L2 configuration: device-bound, passcode
required, current biometric set or passcode on every read. It is also the one case
where a duplicate is handled by delete-and-re-add, because access control cannot be
changed with `SecItemUpdate`. Reads that the user cancels fail with
`errSecUserCanceled` (-128). See
[biometric-authentication.md](biometric-authentication.md).

## What iOS 17 and 18 changed

- iOS 17: HTTPS callbacks through `ASWebAuthenticationSession.Callback` (17.4+);
  shared password groups synced end-to-end encrypted through iCloud Keychain;
  credential provider extensions can offer passkeys.
- iOS 18: the Passwords app; automatic passkey upgrades using the `.conditional`
  request style; credential providers can fill verification codes. No new `SecItem*`
  API.
- WWDC 2024 session 10125 covers automatic passkey upgrades; WWDC 2021 session 10105
  covers on-device verification codes synced through iCloud Keychain.
- The community package `swift-keychain-kit` experiments with a `~Copyable`
  `SecretData` that `mlock`s its buffer and zeroes it on deallocation. It is not an
  Apple framework, but it shows where secret handling in Swift is heading.

## Guardrails in CI

| Tool | Stage | Checks |
| --- | --- | --- |
| truffleHog, gitleaks | Pre-commit hook, PR | Secrets in source |
| `strings`, class-dump | After build | Secrets in the binary |
| SwiftLint custom rule | Lint | `UserDefaults` writes with token-like keys |
| Frida, Objection | QA, pentest | Actual `kSecAttrAccessible` values at runtime |
| MobSF | Dynamic scan | Storage and network leakage |

A secret found in code or in the binary fails the build.

## OWASP mapping

OWASP Mobile Top 10 (2024) puts M1, Improper Credential Usage, first. MASVS v2.1.0
adds MASWE, the weakness list that links controls to MASTG tests.

| Control | Top 10 | MASVS | MASWE | MASTG |
| --- | --- | --- | --- | --- |
| Keychain, WhenUnlockedThisDeviceOnly | M1, M9 | STORAGE-1 | 0002, 0004, 0036 | TEST-0299, 0300, 0301, 0302 |
| Actor-serialized access | M9 | STORAGE-1 | 0002 | TEST-0300 |
| Ephemeral `ASWebAuthenticationSession` | M1 | AUTH-1 | 0032 | TEST-0064 |
| Atomic refresh | M1 | AUTH-1 | 0038 | |
| Runtime secret fetch | M1 | STORAGE-1 | 0005 | |
| Logout cleanup | M9 | STORAGE-2 | 0004 | TEST-0298 |
| Biometrics plus ThisDeviceOnly | M9 | STORAGE-2 | 0046 | TEST-0298, DEMO-0043 to 0047 |

The legacy MSTG-STORAGE-1 and -2 tests (MASTG-TEST-0052 and 0053) are deprecated and
replaced by MASTG-TEST-0296 through 0314. More in
[compliance-owasp-mapping.md](compliance-owasp-mapping.md).

## The three decisions that matter

One actor is the only door to the Keychain. Runtime secrets come from your own
backend, gated by App Attest. Every auth item shares one `kSecAttrService` so logout
is a single call.

## Checklist

- [ ] Credentials only in the Keychain with WhenUnlockedThisDeviceOnly; never
      UserDefaults, `Info.plist`, `.xcconfig` or source.
- [ ] All Keychain access through one actor or a `@globalActor`.
- [ ] Sign-in via `ASWebAuthenticationSession`, ephemeral, with PKCE (RFC 7636);
      never `WKWebView` or `SFSafariViewController`.
- [ ] Saves are add-then-update-on-duplicate; delete-and-re-add only when access
      control changes.
- [ ] Refresh encodes first, writes both tokens in one item, and coalesces concurrent
      refreshes.
- [ ] Runtime API keys handed out only after the backend checks an App Attest (or
      DeviceCheck) proof, iOS 14+, and cached with an app-managed expiry; memory, then Keychain, then network.
- [ ] Logout: delete by service, revoke on the server, clear cookies and `URLCache`.
- [ ] No `kSecAttrSynchronizable` on app tokens.
- [ ] Missing ThisDeviceOnly credentials after a restore route to sign-in.
- [ ] Versioned account names; old versions deleted once the new write is verified.
- [ ] CI secret scanning (truffleHog, gitleaks, `strings`) fails the build.
- [ ] M1, M9, MASVS-STORAGE-1 and MASVS-AUTH-1 checked against MASTG-TEST-0298 to
      0302 and with Frida or Objection.
