Skip to content

Fix FirebaseAuth random logouts on iOS 15+ during prewarming - #16505

Open
paulb777 wants to merge 6 commits into
mainfrom
pb-auth-keychain-prewarming
Open

Fix FirebaseAuth random logouts on iOS 15+ during prewarming#16505
paulb777 wants to merge 6 commits into
mainfrom
pb-auth-keychain-prewarming

Conversation

@paulb777

Copy link
Copy Markdown
Member

Fixes #16498

This PR addresses an issue where users were randomly logged out during iOS prewarming. The root cause is a known iOS 15+ bug where SecItemCopyMatching spuriously returns errSecItemNotFound instead of errSecInteractionNotAllowed when the device is locked.

Because FirebaseAuth trusted this error code, it assumed the user was genuinely signed out, wiping the in-memory user state and causing a silent logout.

Changes:

  • Added isKeychainAccessible() to AuthKeychainServices.swift which attempts to write a dummy keychain item with the kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly protection class to reliably detect if the keychain is locked.
  • Updated error handling in getItem and getItemLegacy to verify keychain accessibility when errSecItemNotFound is returned. If the keychain is not accessible, it now appropriately throws errSecInteractionNotAllowed.
  • Added unit tests to AuthKeychainServicesTests.swift to verify this behavior using a mocked keychain storage.

@gemini-code-assist

Copy link
Copy Markdown
Contributor
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

@paulb777

Copy link
Copy Markdown
Member Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a workaround for a known iOS bug where SecItemCopyMatching spuriously returns errSecItemNotFound instead of errSecInteractionNotAllowed when the device is locked. It adds a helper method isKeychainAccessible() to check the actual accessibility of the keychain by attempting a dummy write, and updates the lookup logic to throw an error if the keychain is inaccessible. Additionally, unit tests are added to verify this behavior. The reviewer suggested a performance optimization to cache the keychain accessibility status once it becomes accessible, avoiding redundant and expensive keychain write/delete operations on subsequent checks.

Comment thread FirebaseAuth/Sources/Swift/Storage/AuthKeychainServices.swift
@paulb777

Copy link
Copy Markdown
Member Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a workaround for an iOS bug where SecItemCopyMatching spuriously returns errSecItemNotFound instead of errSecInteractionNotAllowed when the device is locked (such as during prewarming). It adds a helper method isKeychainAccessible() that attempts to write a dummy item to the keychain to determine accessibility, caching the result. However, the review points out a critical issue: passing kSecValueData or kSecAttrAccessible in the query to SecItemDelete is invalid and will fail with errSecParam (error -50), causing a silent keychain leak of the dummy item. A dedicated deletion query containing only search attributes should be used instead.

Comment thread FirebaseAuth/Sources/Swift/Storage/AuthKeychainServices.swift Outdated
@paulb777

Copy link
Copy Markdown
Member Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request addresses an issue on iOS 15+ where users are randomly logged out during prewarming while the device is locked. It introduces a check to verify if the keychain is accessible when errSecItemNotFound is returned, throwing an error instead of returning nil if the keychain is locked. However, the reviewer identified a critical bug in the caching mechanism (isKeychainAccessibleCache). Since keychain accessibility is dynamic and changes when the device is locked or unlocked, caching this state can lead to silent logouts if the cache is set to true while unlocked and then queried while locked. The reviewer suggested removing the cache entirely to ensure the check is always accurate.

Comment thread FirebaseAuth/Sources/Swift/Storage/AuthKeychainServices.swift Outdated
Comment thread FirebaseAuth/Sources/Swift/Storage/AuthKeychainServices.swift
@paulb777

Copy link
Copy Markdown
Member Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request addresses an issue on iOS 15+ where users are randomly logged out during prewarming while the device is locked. It introduces a check to verify if the keychain is accessible when a query returns errSecItemNotFound, throwing an error if it is inaccessible rather than returning nil. The review feedback recommends caching the keychain accessibility status in memory once it is confirmed accessible, preventing redundant and expensive synchronous keychain write/delete operations on subsequent lookups.

Comment thread FirebaseAuth/Sources/Swift/Storage/AuthKeychainServices.swift
@paulb777
paulb777 requested a review from ncooke3 August 12, 2026 22:40

@ncooke3 ncooke3 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI-assisted review comments below.

Comment thread FirebaseAuth/Tests/Unit/AuthKeychainServicesTests.swift
Comment thread FirebaseAuth/Sources/Swift/Storage/AuthKeychainServices.swift Outdated
Comment thread FirebaseAuth/Sources/Swift/Storage/AuthKeychainServices.swift Outdated
@paulb777

Copy link
Copy Markdown
Member Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request addresses an issue on iOS 15+ where users are randomly logged out during prewarming while the device is locked by verifying keychain accessibility when SecItemCopyMatching returns errSecItemNotFound. The review feedback highlights a mismatch in the keychain protection class used for the accessibility check (kSecAttrAccessibleWhenUnlocked instead of kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly), which could lead to false negatives. It is recommended to align the protection class with the actual credentials, cache the accessibility status to optimize performance, and update the corresponding unit test mock.

Comment on lines +261 to +282
/// Determines if the keychain is currently accessible.
/// This is used to work around a known iOS bug where `SecItemCopyMatching` spuriously returns
/// `errSecItemNotFound` instead of `errSecInteractionNotAllowed` when the device is locked (e.g.
/// during prewarming).
private func isKeychainAccessible() -> Bool {
let dummyKey = "firebase_auth_keychain_accessibility_check"
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: dummyKey,
kSecAttrService as String: service,
kSecValueData as String: Data([0]),
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
]
query[kSecUseDataProtectionKeychain as String] = true

let status = keychainStorage.add(query: query)
if status == errSecInteractionNotAllowed {
return false
}

return true
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The implementation of isKeychainAccessible() uses kSecAttrAccessibleWhenUnlocked for the dummy keychain item. However, the actual credentials in FirebaseAuth are stored using kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly (as seen in setItemLegacy).

This mismatch introduces a correctness and usability bug:
If the device is locked but has been unlocked at least once since boot, items with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly are fully accessible. However, attempting to write a kSecAttrAccessibleWhenUnlocked dummy item will fail with errSecInteractionNotAllowed. This causes isKeychainAccessible() to return false and throw errSecInteractionNotAllowed even when the keychain is actually accessible for the actual tokens (e.g., for a signed-out user, it will throw an error instead of returning nil).

To fix this, the dummy item should use kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly to match the actual token protection class.

Additionally, since keychain operations are slow and expensive, we can optimize performance by caching the accessibility status once it succeeds. Since AfterFirstUnlock items remain accessible for the entire duration of the device being powered on once the first unlock has occurred, caching true is completely safe and avoids redundant keychain writes on subsequent checks.

  private let isKeychainAccessibleCached = UnfairLock<Bool>(false)

  /// Determines if the keychain is currently accessible.
  /// This is used to work around a known iOS bug where SecItemCopyMatching spuriously returns
  /// errSecItemNotFound instead of errSecInteractionNotAllowed when the device is locked (e.g.
  /// during prewarming).
  private func isKeychainAccessible() -> Bool {
    if isKeychainAccessibleCached.value() {
      return true
    }

    let dummyKey = "firebase_auth_keychain_accessibility_check"
    var query: [String: Any] = [
      kSecClass as String: kSecClassGenericPassword,
      kSecAttrAccount as String: dummyKey,
      kSecAttrService as String: service,
      kSecValueData as String: Data([0]),
      kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
    ]
    query[kSecUseDataProtectionKeychain as String] = true

    let status = keychainStorage.add(query: query)
    if status == errSecInteractionNotAllowed {
      return false
    }

    isKeychainAccessibleCached.withLock { $0 = true }
    return true
  }

Comment on lines +204 to +210
func add(query: [String: Any]) -> OSStatus {
if let accessible = query[kSecAttrAccessible as String] as? String,
accessible == kSecAttrAccessibleWhenUnlocked as String {
return errSecInteractionNotAllowed
}
return errSecSuccess
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low

Since we updated the production code to use kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly for the dummy keychain item, we must also update the mock LockedKeychainStorage in the unit tests to check for this protection class instead of kSecAttrAccessibleWhenUnlocked.

Suggested change
func add(query: [String: Any]) -> OSStatus {
if let accessible = query[kSecAttrAccessible as String] as? String,
accessible == kSecAttrAccessibleWhenUnlocked as String {
return errSecInteractionNotAllowed
}
return errSecSuccess
}
func add(query: [String: Any]) -> OSStatus {
if let accessible = query[kSecAttrAccessible as String] as? String,
accessible == kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String {
return errSecInteractionNotAllowed
}
return errSecSuccess
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Firebase Auth returns no user for recently active iOS users

2 participants