Keychain Services is documented as thread-safe. In theory, yes. In practice, concurrent access patterns gave me results I didn’t expect. Here are three race conditions, so you don’t walk into the same ones.
1. The read-then-write double fetch
Classic pattern for refreshing an access token: read the token, check if it’s expired, refresh, save. With concurrent requests, two threads read the token at the same moment, both decide it’s expired, both hit the refresh endpoint. The backend enforces a one-minute rate limit, so the second refresh fails. Worse: the first refresh succeeds and writes the new token, the second one fails and overwrites it with a bad one.
Fix: serialize with an actor.
actor TokenManager {
private var cached: Token?
private var refreshTask: Task<Token, Error>?
func currentToken() async throws -> Token {
if let c = cached, !c.isExpired { return c }
if let task = refreshTask { return try await task.value }
let task = Task {
defer { refreshTask = nil }
let new = try await refreshFromServer()
try Keychain.save(new)
cached = new
return new
}
refreshTask = task
return try await task.value
}
}The important bit is caching the refreshTask. Ten concurrent callers still trigger a single refresh, everyone awaits the same task value.
2. The delete-then-insert race
Keychain has no “upsert”. The typical flow for “update if exists, otherwise add” is SecItemDelete then SecItemAdd. If two threads try to upsert the same key at the same time:
Thread A: delete succeeds, add succeeds.
Thread B: delete returns itemNotFound (because A deleted it and hasn’t added yet), add returns errSecDuplicateItem (because A has now added).
Fix: check first with SecItemCopyMatching, then add if missing, update if present. Wrap that three-step operation in an actor or serial queue. One example:
actor KeychainActor {
func set(_ data: Data, key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
var getQuery = query
getQuery[kSecReturnData as String] = true
let status = SecItemCopyMatching(getQuery as CFDictionary, nil)
if status == errSecSuccess {
let attrs: [String: Any] = [kSecValueData as String: data]
let upd = SecItemUpdate(query as CFDictionary, attrs as CFDictionary)
guard upd == errSecSuccess else { throw KeychainError.update(upd) }
} else if status == errSecItemNotFound {
var addQuery = query
addQuery[kSecValueData as String] = data
let add = SecItemAdd(addQuery as CFDictionary, nil)
guard add == errSecSuccess else { throw KeychainError.add(add) }
} else {
throw KeychainError.read(status)
}
}
}3. App extension and host app writing at the same time
A widget extension and the host app share a keychain group and both write the same item. The widget refreshes its timeline and updates the token; the user opens the host app at exactly that moment and triggers its own token refresh. Actor synchronization works inside a single process; there’s no process-level lock across two separate processes writing to the same keychain item.
My fix: only the host app updates the keychain. The widget is read-only. If the widget needs a token refresh, it signals the host app with a silent push (“do a refresh”), the host refreshes and updates the keychain, and the widget picks up the new token on its next timeline refresh.
Storage alternatives I considered
UserDefaults: not suitable for sensitive data (unencrypted, gets backed up). A file with File Protection: a decent alternative, but multi-device sync needs keychain’s iCloud integration. SQLite + CryptoKit: too much code, and you now own encryption. Verdict: keychain is still the best choice, you just have to manage concurrency properly.
What to log to Sentry
Don’t log OSStatus as a raw number; log it by name. errSecDuplicateItem and errSecItemNotFound have very different meanings in the error distribution. Someone has already written an OSStatus mapping helper on the Apple Developer forums.
Final advice
Don’t call keychain APIs directly all over the codebase. Put everything behind a single KeychainActor service and inject it with DI. Thread safety is guaranteed in one spot and the rest of the code can stay ignorant. That’s about 100 extra lines of code, and it earns its place by eliminating one rare bug every two months.