feat(expo): move biometric credentials to JS with @clerk/expo-biometrics - #9989
Conversation
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ntract tests Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Add secureKeyStorageAvailable to getAvailability() and reject createKey() with secure_key_storage_unavailable when the device has no Secure Enclave, such as the iOS Simulator, instead of failing inside SecKeyCreateRandomKey. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Implement the Android side of the module against clerk-android's biometric credential storage contract v2, and add hashIdentifierHint() plus identifierHintSha256 on listed records on both platforms. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Report secureKeyStorageAvailable from getAvailability() on Android (false below API 28) and reject createKey() there with secure_key_storage_unavailable instead of biometry_not_available. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
getAvailability() and signIn() report biometric_authentication_unavailable before reading local records when @clerk/expo-biometrics reports no secure key storage, and enroll() maps its secure_key_storage_unavailable rejection to biometric_authentication_unavailable before contacting Clerk. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: d82987a The changes in this PR will be included in the next version bump. This PR includes changesets to release 24 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository YAML (base), Organization UI (inherited) Review profile: ASSERTIVE Plan: Team Run ID: 📒 Files selected for processing (1)
🔗 Linked repositories identifiedCodeRabbit considers these linked repositories for cross-repo context during reviews:
💤 Files with no reviewable changes (1)
Included review availability: This review used your included allowance. 4 included reviews remain after this review. Your included PR review attempts over the past 7 days set your current allowance at 6 reviews per hour. 📝 WalkthroughWalkthroughThe change adds experimental trusted-device sign-in, session reverification, and biometric credential management to Clerk. It adds an Expo biometrics package with iOS and Android support for device-bound keys, signatures, and local credential records. The Expo credential hook coordinates those native operations with Clerk APIs for enrollment, revocation, sign-in, and reverification. The change also updates package build integration, tests, and release notes. Priority: ⬇️ Low Estimated code review effort: 5 (Critical) | ~120 minutes Merge Risk: ⚪ Minimal · up to No actionable merge-blocking issue was established for the release-note change. Compatibility of existing Android enrollments with pinned SDK version 1.1.10 remains unverified. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 13.78% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 283 functions across 50 files. (1 skipped: 1 unsupported.)
Comment |
…rify Android storage sharing Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Review
Two independent reviews (Grok 4.7 Extra High Fast and Muse Spark 1.3 Extra High) of the current head. Already-resolved threads were left alone: the useBiometricCredentials JSDoc, the accepted orphaned-key cleanup after a successful save, and the Android save race. The synchronized store lock covers that interleaving, so it is not repeated here.
Risk: medium. This is on-device credential and key handling. There is no remote authentication bypass in the diff, but a few paths destroy or stick local credentials, and one policy does not match its own contract.
Merge as-is: no. I would not approve.
The PR already asks not to merge until a clerk-android release with v2 storage is pinned. Separate from that, I would want the corrupt-store overwrite and the iOS cross-user deletion fixed, or the cross-user wipe explicitly confirmed as clerk-ios contract v1, before approval. The challenge checks, locale-stable hashing, iOS key_invalidated mapping, and the Android creation-time biometrics check should land in this PR as well.
Consensus
- Sign-in (and enrollment) prompt for a biometric signature without the challenge binding and expiry checks reverification already does.
- Identifier-hint hashing is documented as locale-independent, but Kotlin
lowercase()and Swiftlowercased()follow the device locale. TurkishIdoes not match aLocale.ROOT/ JStoLowerCase()hash. multi_factorreverification signs both factors with the same key.
Verified from one review
- iOS
removeOtherRecordsForAppdeletes every other record for the app, including other users. Android scopes that cleanup touserId. - A non-JSON Android store is treated as an empty writable document, so the next save replaces the file. The comment directly above says read errors must not allow that.
- After a biometric-set change, iOS
SecItemCopyMatchingfailures becomeauthentication_failed/signing_failed, neverkey_invalidated, so the record is kept andhasKeystill succeeds. biometry_or_device_passcodeis documented as requiring biometrics at creation. On API 30+, AndroidcanAuthenticate(BIOMETRIC_STRONG | DEVICE_CREDENTIAL)succeeds with only a PIN.
Consider, not blocking on their own
- Signed out, sign-in takes the single newest local record across users (
selectLocalCredential) instead of walking later candidates when that record is stale on the server. - The second-phase record delete, after other keys are removed, is swallowed on both platforms (
try? persiston iOS,runCatching { fileStore.delete }on Android). A failed rewrite leaves records whose keys are already gone until the nexthasKeysweep.
Dismissed
- Dropping undecodable same-app records on iOS is intentional and covered by
testSaveDropsMalformedRecordsForTheSameAppOnly. - Reverification requiring
biometry_current_setmatches the behavior described in the PR. - Missing feature detection for an older
clerk-jsis the opposite direction of the runtime compatibility rule. These methods are additive onclerk-js. - The
@clerk/uicreateThemebreak-check hit is not in this diff.
Inline comments are on the lines above.
Sent by Cursor Automation: Multi Model Code Review
…g and require biometrics for Android keys - Sign-in rejects a challenge for another credential or an expired one before the biometric prompt, as reverification does. - Reverification no longer signs a second factor with the key that just satisfied the first; an outstanding second factor is returned. - Android key creation requires a strong biometric for every policy, matching iOS and the documented policy contract. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
BiometricPrompt.authenticate() returns without calling back once the activity's state is saved, and a recreated activity never reattaches the callback, so sign() could leave its promise pending forever. It now rejects with system_canceled in both cases and cancels the prompt when the activity is destroyed. sign() also rejects with secure_key_storage_unavailable before Android 9, matching createKey(), and CI now runs the module's Robolectric tests in the Android fixture build.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…install The packed @clerk/expo-biometrics leaves out android/src/test, so the fixture's testDebugUnitTest found no tests and failed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>


Description
Moves biometric credential enrollment, listing, revocation and sign-in out of Clerk's native iOS/Android SDKs and into JS, backed by a new thin native module,
@clerk/expo-biometrics. It builds on today's@clerk/expoand its current native client sync. It does not depend on the sync redesign or the@clerk/expo-native-componentssplit, which will rebase on top of this.This combines #9953, #9959, #9961 and #9960.
@clerk/shared/@clerk/clerk-js: experimental trusted device resources (additive, nothing on theClerkclass changes)trusted_devicesign-in strategy:signIn.create({ strategy: 'trusted_device', trustedDeviceId })andsignIn.attemptFirstFactor({ strategy: 'trusted_device', trustedDeviceId, clientData, signature, algorithm: 'ES256' }). The challenge is exposed onsignIn.firstFactorVerification.trustedDeviceChallenge(FAPI'sexpires_atthere is in seconds).AuthConfig.nativeSettings.BiometricCredentialresource, plusUser.__experimental_getBiometricCredentials(),__experimental_prepareBiometricCredential(),__experimental_attemptBiometricCredential()and__experimental_revokeBiometricCredential(id)for/v1/me/biometric_credentials.clerk.native.js,clerk.browser.jsandclerk.jsover their limits, so theirmaxSizevalues go from 80KB to 82KB, from 81KB to 83KB, and from 554KB to 556KB.clerk.browser.jsis now 81.2KB gzip andclerk.js554.01KB.@clerk/expo-biometrics: new native module (iOS and Android)Apps install it to use
useBiometricCredentials(); its JS API is only called by@clerk/expo, so it must only change additively. It handles only the device side: key creation, ES256 signing behind a biometric prompt, and on-device credential records. It makes no FAPI calls and doesn't depend on clerk-ios or clerk-android.trustedDeviceCredentialskeychain item. The item and the reinstall marker follow the clerk-ios storage contract v1 (test: pin biometric credential storage format clerk-ios#584), so credentials created here and byClerkKitin the same app are interchangeable.getAvailability()reportssecureKeyStorageAvailable: false, because Secure Enclave keys fail there.secp256r1keys, signed throughBiometricPrompt.noBackupFilesDir/clerk/biometric_credentials.v2.json. Writes follow the clerk-android v2 storage contract (feat(api): isolate biometric credential storage clerk-android#966): file lock, atomic writes, unknown fields preserved.hashIdentifierHint()andidentifierHintSha256on records let hints match on both platforms.Why it's a separate package rather than part of
@clerk/expo: Expo autolinks every native module in an installed package. Putting this in@clerk/expowould link LocalAuthentication andandroidx.biometricinto every app, and those apps would have to declareNSFaceIDUsageDescriptionwhether or not they use biometrics. It follows the same pattern as@clerk/expo-passkeysand@clerk/expo-google-signin.@clerk/expo:useBiometricCredentials()runs in JS@clerk/expo-biometricsis an optional peer dependency, loaded with a guardedrequire. Without it, the hook's methods throw an error that explains how to install it and rebuild.BiometricCredentials.swift:nativeSettings.idand identifier hint. Records whose key is missing are pruned, and records are reconciled with the server list when a session is active.createKey→ prepare → sign → attempt →saveRecord, with the key and server credential cleaned up if a step fails.signIn.create→ sign the challenge →attemptFirstFactor. The local record is dropped when the server or keystore reports it gone.reverify()also runs in JS: it starts session reverification, preparestrusted_devicewith the local credential, signs the challenge with@clerk/expo-biometrics, and attempts it, forfirst_factor,second_factorandmulti_factor. It needs a credential enrolled withbiometry_current_set. clerk-js sends FAPI version 2026-05-12, where FAPI doesn't listtrusted_deviceas a reverification factor but accepts it on prepare and attempt. clerk-js gains thetrusted_devicecase insession.prepareFirstFactorVerification()and the matching experimental types.ClerkExpo, includingreverifyWithBiometrics, are left in place, unused by the hook, and will be removed in a follow-up.Sign-in with Face ID completes in JS through
setActive. Apps that render native components receive the session through the existing sync, the same as any other JS sign-in.Checklist
pnpm testruns as expected.pnpm buildruns as expected.Type of change
🤖 Generated with Claude Code