Prevent duplicate iOS biometric prompts & silent reads#601
Conversation
Avoid double Face ID / Touch ID prompts and keep metadata-only operations silent on iOS. Native Swift changes add an allowAuthentication flag, an itemExists fast-path, and set kSecUseAuthenticationUIFail for non-auth probes so hasItem and metadata enumeration never trigger authentication. JS API separates option normalization into storage-scope vs prompted-read helpers (normalizeStorageScopeOptions, normalizePromptedReadOptions) and updates core storage functions and hooks to only forward prompts when values are explicitly requested. Tests, docs, README and example iOS lockfile updated to reflect behavior and API clarifications.
There was a problem hiding this comment.
Pull request overview
Fixes iOS Keychain prompt behavior so “silent” operations (existence checks and metadata-only reads/enumerations) don’t trigger biometric UI, while value reads/writes continue to prompt appropriately.
Changes:
- Split option normalization into “storage scope only” vs “prompted read” paths, and applied them across core APIs + hooks to avoid forwarding prompt-bearing fields for silent operations.
- Refactored iOS native Keychain querying to control authentication UI (including adding a dedicated silent existence-check path).
- Expanded unit tests to verify prompt options are not forwarded for silent operations.
Reviewed changes
Copilot reviewed 16 out of 17 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/sensitive-info.nitro.ts | Updates TS docs to clarify silent vs prompted behavior (esp. iOS prompt boundaries). |
| src/internal/options.ts | Adds normalizeStorageScopeOptions and normalizePromptedReadOptions helpers. |
| src/index.ts | Updates package-level docs around iOS prompt behavior. |
| src/hooks/useSecureStorage.ts | Ensures metadata-only listings strip prompt-bearing fields before calling getAllItems. |
| src/hooks/useSecretItem.ts | Ensures metadata-only single-item reads strip prompt-bearing fields before calling getItem. |
| src/hooks/useHasSecret.ts | Ensures existence checks strip prompt-bearing fields before calling hasItem. |
| src/core/storage.ts | Routes each operation through the appropriate normalization path (silent vs prompted). |
| src/tests/internal.options.test.ts | Adds coverage for new normalization helpers. |
| src/tests/hooks.useSecureStorage.test.tsx | Tests silent enumeration doesn’t forward prompt options. |
| src/tests/hooks.useSecretItem.test.tsx | Tests silent metadata-only reads don’t forward prompt options. |
| src/tests/hooks.useHasSecret.test.tsx | Tests silent existence checks don’t forward prompt options. |
| src/tests/core.storage.test.ts | Expands tests to assert correct normalizer selection per operation. |
| ios/HybridSensitiveInfo.swift | Refactors Keychain query behavior to prevent duplicate prompts and support silent paths. |
| example/ios/Podfile.lock | Updates example app pod versions. |
| docs/ARCHITECTURE.md | Documents iOS prompt boundaries and intended silent-path semantics. |
| README.md | Clarifies option semantics and prompt forwarding behavior. |
| CHANGELOG.md | Adds release note for iOS duplicate prompt fix. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if !allowAuthentication { | ||
| workingQuery[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail | ||
| } else if let prompt { | ||
| workingQuery[kSecUseOperationPrompt as String] = prompt.title | ||
| workingQuery[kSecUseAuthenticationContext as String] = makeLAContext(prompt: prompt) |
There was a problem hiding this comment.
When allowAuthentication is false, this sets kSecUseAuthenticationUIFail. For SecItemCopyMatching queries that request attributes (e.g. metadata-only getItem / getAllItems), Keychain commonly returns errSecInteractionNotAllowed for biometric-protected items, and the current code maps that to nil (so JS sees null/[] even though entries exist). To keep silent operations usable, consider using kSecUseAuthenticationUISkip for enumeration (so non-protected items are still returned without failing), and for single-item metadata reads treat errSecInteractionNotAllowed/errSecAuthFailed as “exists but locked” (e.g. consult itemExists and return a minimal/fallback item instead of not-found).
| dedicated silent path, while value reads own an `LAContext` from the first `SecItemCopyMatching` | ||
| attempt so one user action maps to one Face ID / Touch ID sheet. |
There was a problem hiding this comment.
This section suggests metadata-only enumeration has a “dedicated silent path” that still behaves like a normal listing. With the current native implementation, metadata-only queries disable auth UI and can end up omitting/returning nothing for biometric-protected entries (Keychain may respond with errSecInteractionNotAllowed). Consider clarifying here what callers should expect (e.g., protected items may not be listable without includeValues: true, and hasItem is the reliable silent existence probe).
| dedicated silent path, while value reads own an `LAContext` from the first `SecItemCopyMatching` | |
| attempt so one user action maps to one Face ID / Touch ID sheet. | |
| silent path that disables auth UI, while value reads own an `LAContext` from the first | |
| `SecItemCopyMatching` attempt so one user action maps to one Face ID / Touch ID sheet. | |
| Callers should not assume metadata-only enumeration behaves like a normal listing for | |
| biometric-protected entries: Keychain may return `errSecInteractionNotAllowed`, so protected | |
| items can be omitted or the result can be empty unless the query allows a value read | |
| (for example, `includeValues: true`). For a silent existence check, prefer `hasItem`. |
Stop normalizing storage options in useHasSecret and pass the provided SensitiveInfoOptions straight to hasItem. Update unit tests to assert the forwarded options and the native getAllItems call signature. On iOS, remove the early-return that suppressed errSecInteractionNotAllowed/errSecAuthFailed when allowAuthentication was false so those statuses now raise the runtime error instead of returning nil.
This pull request addresses a bug on iOS where duplicate biometric prompts could appear during existence checks and metadata-only reads, and improves the handling of authentication prompts across the codebase. The changes ensure that silent operations such as existence checks and metadata-only enumerations no longer trigger biometric prompts, while value reads and writes handle prompts correctly. The update also introduces new normalization utilities and expands test coverage to verify the correct prompt behavior.
iOS biometric prompt handling improvements:
HybridSensitiveInfo.swift) to ensure that existence checks (hasItem) and metadata-only operations do not trigger biometric prompts, by introducing anallowAuthenticationflag and a dedicateditemExistsmethod. Value reads now only prompt once per operation. [1] [2] [3] [4]docs/ARCHITECTURE.md,README.md) to clarify prompt boundaries and option semantics for silent vs. prompted operations. [1] [2]Core logic and API changes:
getIteminsrc/core/storage.tsto use new normalization functions:normalizePromptedReadOptionsfor value reads (with prompt), andnormalizeStorageScopeOptionsfor silent metadata-only reads. [1] [2]normalizePromptedReadOptionsandnormalizeStorageScopeOptionsto the internal options module and expanded their test coverage. [1] [2]Test coverage improvements:
src/__tests__/core.storage.test.tsand hooks tests to verify that silent operations do not forward prompt options and do not trigger biometric prompts, even when prompt options are provided. [1] [2] [3] [4] [5] [6] [7] [8] [9]These changes collectively ensure a smoother and more predictable biometric prompt experience on iOS, aligning the library's behavior with platform expectations and improving developer ergonomics.