Skip to content

feat(expo): move biometric credentials to JS with @clerk/expo-biometrics - #9989

Merged
mikepitre merged 35 commits into
mainfrom
mike/expo-biometrics-package
Oct 1, 2026
Merged

mikepitre merged 35 commits into
mainfrom
mike/expo-biometrics-package

Conversation

@mikepitre

@mikepitre mikepitre commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

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/expo and its current native client sync. It does not depend on the sync redesign or the @clerk/expo-native-components split, 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 the Clerk class changes)

  • trusted_device sign-in strategy: signIn.create({ strategy: 'trusted_device', trustedDeviceId }) and signIn.attemptFirstFactor({ strategy: 'trusted_device', trustedDeviceId, clientData, signature, algorithm: 'ES256' }). The challenge is exposed on signIn.firstFactorVerification.trustedDeviceChallenge (FAPI's expires_at there is in seconds).
  • AuthConfig.nativeSettings.
  • A new BiometricCredential resource, plus User.__experimental_getBiometricCredentials(), __experimental_prepareBiometricCredential(), __experimental_attemptBiometricCredential() and __experimental_revokeBiometricCredential(id) for /v1/me/biometric_credentials.
  • Bundlewatch: the new resources push clerk.native.js, clerk.browser.js and clerk.js over their limits, so their maxSize values go from 80KB to 82KB, from 81KB to 83KB, and from 554KB to 556KB. clerk.browser.js is now 81.2KB gzip and clerk.js 554.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.

  • iOS
    • Secure Enclave P-256 keys, with the access-control flags set per policy.
    • Records are kept in the trustedDeviceCredentials keychain 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 by ClerkKit in the same app are interchangeable.
    • On the Simulator, getAvailability() reports secureKeyStorageAvailable: false, because Secure Enclave keys fail there.
  • Android
    • Android Keystore secp256r1 keys, signed through BiometricPrompt.
    • Records live in 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.
    • Android stores only a SHA-256 of the identifier hint. hashIdentifierHint() and identifierHintSha256 on records let hints match on both platforms.
  • Tests: Swift unit tests and Robolectric tests pin both storage contracts with the same fixtures the native SDKs use.

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/expo would link LocalAuthentication and androidx.biometric into every app, and those apps would have to declare NSFaceIDUsageDescription whether or not they use biometrics. It follows the same pattern as @clerk/expo-passkeys and @clerk/expo-google-signin.

@clerk/expo: useBiometricCredentials() runs in JS

  • @clerk/expo-biometrics is an optional peer dependency, loaded with a guarded require. Without it, the hook's methods throw an error that explains how to install it and rebuild.
  • The orchestration follows clerk-ios BiometricCredentials.swift:
    • Environment gating uses nativeSettings.
    • Local candidates are filtered by app identifier, id and identifier hint. Records whose key is missing are pruned, and records are reconciled with the server list when a session is active.
    • Enrollment runs createKey → prepare → sign → attempt → saveRecord, with the key and server credential cleaned up if a step fails.
    • Sign-in runs 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, prepares trusted_device with the local credential, signs the challenge with @clerk/expo-biometrics, and attempts it, for first_factor, second_factor and multi_factor. It needs a credential enrolled with biometry_current_set. clerk-js sends FAPI version 2026-05-12, where FAPI doesn't list trusted_device as a reverification factor but accepts it on prepare and attempt. clerk-js gains the trusted_device case in session.prepareFirstFactorVerification() and the matching experimental types.
  • The native SDK's biometric bridge methods in ClerkExpo, including reverifyWithBiometrics, 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 test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

🤖 Generated with Claude Code

mikepitre and others added 10 commits September 29, 2026 17:46
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-bot

changeset-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d82987a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 24 packages
Name Type
@clerk/clerk-js Minor
@clerk/shared Minor
@clerk/expo Minor
@clerk/expo-biometrics Minor
@clerk/chrome-extension Patch
@clerk/electron Patch
@clerk/mosaic Patch
@clerk/astro Patch
@clerk/backend Patch
@clerk/expo-passkeys Patch
@clerk/express Patch
@clerk/fastify Patch
@clerk/hono Patch
@clerk/localizations Patch
@clerk/msw Patch
@clerk/nextjs Patch
@clerk/nuxt Patch
@clerk/react-router Patch
@clerk/react Patch
@clerk/swingset Patch
@clerk/tanstack-react-start Patch
@clerk/testing Patch
@clerk/ui Patch
@clerk/vue Patch

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

@vercel

vercel Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
clerk-js-sandbox Ready Ready Preview Oct 1, 2026 7:13pm UTC
swingset Ready Ready Preview Oct 1, 2026 7:13pm UTC

Request Review

@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Team

Run ID: 85b521fd-5aa1-460f-9e06-18c15249d18b

📥 Commits

Reviewing files that changed from the base of the PR and between a662774 and 690bef2.

📒 Files selected for processing (1)
  • .changeset/expo-biometric-credentials-js.md
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

💤 Files with no reviewable changes (1)
  • .changeset/expo-biometric-credentials-js.md

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.


📝 Walkthrough

Walkthrough

The 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 690be

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)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning 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… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: moving Expo biometric credentials to JavaScript with the new @clerk/expo-biometrics package.
Description check ✅ Passed The description directly explains the JavaScript biometric credential flow, the new native module, package behavior, storage contracts, tests, and release considerations.
Full details: Docstring Coverage

Explanation

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.)

  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Comment @coderabbitai help to get the list of available commands.

…rify Android storage sharing

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

starting review

Open in Web View Automation 

Sent by Cursor Automation: Multi Model Code Review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 Swift lowercased() follow the device locale. Turkish I does not match a Locale.ROOT / JS toLowerCase() hash.
  • multi_factor reverification signs both factors with the same key.

Verified from one review

  • iOS removeOtherRecordsForApp deletes every other record for the app, including other users. Android scopes that cleanup to userId.
  • 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 SecItemCopyMatching failures become authentication_failed / signing_failed, never key_invalidated, so the record is kept and hasKey still succeeds.
  • biometry_or_device_passcode is documented as requiring biometrics at creation. On API 30+, Android canAuthenticate(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? persist on iOS, runCatching { fileStore.delete } on Android). A failed rewrite leaves records whose keys are already gone until the next hasKey sweep.

Dismissed

  • Dropping undecodable same-app records on iOS is intentional and covered by testSaveDropsMalformedRecordsForTheSameAppOnly.
  • Reverification requiring biometry_current_set matches the behavior described in the PR.
  • Missing feature detection for an older clerk-js is the opposite direction of the runtime compatibility rule. These methods are additive on clerk-js.
  • The @clerk/ui createTheme break-check hit is not in this diff.

Inline comments are on the lines above.

Open in Web View Automation 

Sent by Cursor Automation: Multi Model Code Review

Comment thread packages/expo-biometrics/ios/BiometricCredentialStore.swift
Comment thread packages/expo/src/biometric-credentials/createBiometricCredentials.ts Outdated
Comment thread packages/expo-biometrics/ios/BiometricKeyManager.swift
Comment thread packages/expo/src/biometric-credentials/createBiometricCredentials.ts Outdated
…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>
@mikepitre
mikepitre merged commit f6937c8 into main Oct 1, 2026
59 checks passed
@mikepitre
mikepitre deleted the mike/expo-biometrics-package branch October 1, 2026 19:59
mikepitre added a commit that referenced this pull request Oct 1, 2026
#9989's squash commit is already applied through its branch history (d82987a).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch was successfully deployed

2 active deployments
Preview – swingset — d82987a0 Deployed Oct 1, 2026 by vercel[bot]
Preview – clerk-js-sandbox — d82987a0 Deployed Oct 1, 2026 by vercel[bot]
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.

4 participants