Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/expo-biometrics-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/expo-biometrics': minor
---

Add `@clerk/expo-biometrics`, an experimental Expo native module that creates and signs with the device-bound keys behind Clerk biometric credentials and manages their on-device records. It is iOS-only for now; on Android every call rejects with `not_implemented`.

If you try this out, make sure to pin your version as breaking changes can happen in minors.
10 changes: 7 additions & 3 deletions .github/workflows/expo-native-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on:
- 'integration/templates/expo-native/**'
- 'integration/tests/expo-native/**'
- 'packages/expo/**'
- 'packages/expo-biometrics/**'
- 'packages/expo-google-signin/**'
- 'packages/expo-native-components/**'
workflow_dispatch:
Expand Down Expand Up @@ -85,6 +86,7 @@ jobs:
turbo.json \
packages/clerk-js \
packages/expo \
packages/expo-biometrics \
packages/expo-google-signin \
packages/expo-native-components \
packages/react \
Expand Down Expand Up @@ -123,11 +125,12 @@ jobs:
- name: Build and pack Clerk packages
if: steps.native-build-cache.outputs.cache-hit != 'true'
run: |
pnpm --filter @clerk/expo... build
pnpm --filter @clerk/expo... --filter @clerk/expo-biometrics build
mkdir -p "$SDK_PACK_DIR"
pnpm --filter @clerk/expo pack --pack-destination "$SDK_PACK_DIR"
pnpm --filter @clerk/expo-google-signin pack --pack-destination "$SDK_PACK_DIR"
pnpm --filter @clerk/expo-native-components pack --pack-destination "$SDK_PACK_DIR"
pnpm --filter @clerk/expo-biometrics pack --pack-destination "$SDK_PACK_DIR"

- name: Install fixture dependencies
if: steps.native-build-cache.outputs.cache-hit != 'true'
Expand All @@ -138,11 +141,12 @@ jobs:
run: |
cp "package.sdk-$EXPO_SDK.json" package.json
pnpm install --no-frozen-lockfile
# [0-9] keeps this glob off the clerk-expo-google-signin and clerk-expo-native tarballs.
# [0-9] keeps this glob off the other clerk-expo-* tarballs.
SDK_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-[0-9]*.tgz)"
GOOGLE_SIGNIN_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-google-signin-*.tgz)"
NATIVE_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-native-components-*.tgz)"
pnpm add "$SDK_TARBALL" "$GOOGLE_SIGNIN_TARBALL" "$NATIVE_TARBALL" -w
BIOMETRICS_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-biometrics-*.tgz)"
pnpm add "$SDK_TARBALL" "$GOOGLE_SIGNIN_TARBALL" "$NATIVE_TARBALL" "$BIOMETRICS_TARBALL" -w
# expo-dev-client makes even release builds boot into the dev
# launcher (unreachable Metro in CI), which stalls every Maestro
# flow on a blank screen. Skip it on e2e jobs only.
Expand Down
58 changes: 58 additions & 0 deletions packages/expo-biometrics/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# OSX
#
.DS_Store

# VSCode
.vscode/
jsconfig.json

# Xcode
#
build/
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata
*.xccheckout
*.moved-aside
DerivedData
*.hmap
*.ipa
*.xcuserstate
project.xcworkspace

# Android/IJ
#
.classpath
.cxx
.gradle
.idea
.project
.settings
local.properties
android.iml
android/app/libs
android/keystores/debug.keystore

# Cocoapods
#
example/ios/Pods

# Ruby
example/vendor/

# node.js
#
node_modules/
npm-debug.log
yarn-debug.log
yarn-error.log

# Expo
.expo/*
.env
14 changes: 14 additions & 0 deletions packages/expo-biometrics/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Exclude all top-level hidden directories by convention
/.*/

# Exclude tarballs generated by `npm pack`
/*.tgz

__mocks__
__tests__

/babel.config.js
/android/src/androidTest/
/android/src/test/
/android/build/
/example/
21 changes: 21 additions & 0 deletions packages/expo-biometrics/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Clerk, Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
85 changes: 85 additions & 0 deletions packages/expo-biometrics/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<p align="center">
<a href="https://clerk.com?utm_source=github&utm_medium=clerk_expo_biometrics" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://images.clerk.com/static/logo-dark-mode-400x400.png">
<img src="https://images.clerk.com/static/logo-light-mode-400x400.png" height="64">
</picture>
</a>
<br />
<h1 align="center">@clerk/expo-biometrics</h1>
</p>

<div align="center">

[![Chat on Discord](https://img.shields.io/discord/856971667393609759.svg?logo=discord)](https://clerk.com/discord)
[![Clerk documentation](https://img.shields.io/badge/documentation-clerk-green.svg)](https://clerk.com/docs?utm_source=github&utm_medium=expo_biometrics)
[![Follow on X](https://img.shields.io/twitter/follow/clerk?style=social)](https://x.com/intent/follow?screen_name=clerk)

[Changelog](https://github.com/clerk/javascript/blob/main/packages/expo-biometrics/CHANGELOG.md)
·
[Report a Bug](https://github.com/clerk/javascript/issues/new?assignees=&labels=needs-triage&projects=&template=BUG_REPORT.yml)
·
[Request a Feature](https://feedback.clerk.com/roadmap)
·
[Get help](https://clerk.com/contact/support?utm_source=github&utm_medium=expo_biometrics)

</div>

> [!WARNING]
> This package is experimental. Pin its version, as breaking changes can happen in minor releases.

The native building block for Clerk biometric credentials in Expo apps. It creates hardware-backed signing keys, signs challenges behind a Face ID / Touch ID prompt, and stores the on-device records that link each key to a Clerk credential. It does not talk to Clerk's API; `@clerk/expo` builds the sign-in and enrollment flows on top of it.

The key and record layout is shared with the Clerk iOS SDK, so credentials enrolled by either SDK in the same app are visible to both.

### Prerequisites

- Expo SDK 54 or later, in a development build (the module is not available in Expo Go or on the web)
- iOS. Android support is not implemented yet: every call rejects with `not_implemented`.
- `NSFaceIDUsageDescription` in your `Info.plist`. The `@clerk/expo` config plugin sets it through its `faceIDPermission` option.
- A device with a Secure Enclave. The iOS Simulator has none, so `createKey()` rejects there with `secure_key_storage_unavailable`.

## Installation

```sh
npx expo install @clerk/expo-biometrics
```

Then rebuild your native app.

## API

```ts
import {
createKey,
deleteKey,
deleteRecord,
ensureInstallationMarker,
getAppIdentifier,
getAvailability,
hasKey,
listRecords,
saveRecord,
sign,
} from '@clerk/expo-biometrics';
```

| Function | Description |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `getAppIdentifier()` | The app identifier sent to Clerk as `app_identifier` (the iOS bundle identifier). |
| `getAvailability()` | The device's biometry type, whether biometrics or device owner authentication can be evaluated, and secure key storage. |
| `createKey(policy)` | Creates a Secure Enclave P-256 key and returns its `localKeyId` and public key JWK. |
| `sign(localKeyId, clientData, reason?)` | Prompts for authentication and returns an ES256 signature over `clientData` (raw `r \|\| s`, base64url without padding). |
| `hasKey(localKeyId)` / `deleteKey(localKeyId)` | Checks for or deletes a key. |
| `listRecords()` | Every stored credential record, for every app identifier. |
| `saveRecord(record, options)` | Saves a record. With `removeOtherRecordsForApp: true`, deletes the app's other records and their keys. |
| `deleteRecord(localKeyId)` | Deletes a key, then the records that reference it. |
| `ensureInstallationMarker()` | Deletes records and keys left behind by a previous installation of the app. The store functions call it for you. |

Every error is a `ClerkBiometricsError` with a stable `code`, such as `user_canceled`, `biometry_not_enrolled`, `biometry_lockout`, `key_not_found`, or `storage_failed`.

## License

This project is licensed under the **MIT license**.

See [LICENSE](https://github.com/clerk/javascript/blob/main/packages/expo-biometrics/LICENSE) for more information.
39 changes: 39 additions & 0 deletions packages/expo-biometrics/android/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
apply plugin: 'com.android.library'
// AGP 9+ registers the `kotlin` extension itself, and applying kotlin-android on top of that fails configuration.
if (project.extensions.findByName('kotlin') == null) {
apply plugin: 'kotlin-android'
}

apply plugin: 'maven-publish'

group = 'expo.modules.clerk.biometrics'
version = '1.0.0'

def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useExpoPublishing()

buildscript {
ext.safeExtGet = { prop, fallback ->
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
}

android {
namespace "expo.modules.clerk.biometrics"

compileSdkVersion safeExtGet("compileSdkVersion", 36)

defaultConfig {
minSdkVersion safeExtGet("minSdkVersion", 24)
targetSdkVersion safeExtGet("targetSdkVersion", 36)
versionCode 1
versionName "1.0.0"
}
}

dependencies {
implementation project(':expo-modules-core')
}
2 changes: 2 additions & 0 deletions packages/expo-biometrics/android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<manifest>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package expo.modules.clerk.biometrics

import expo.modules.kotlin.Promise
import expo.modules.kotlin.exception.CodedException
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition

class NotImplementedException :
CodedException("not_implemented", "@clerk/expo-biometrics is not supported on Android yet.", null)

class ClerkExpoBiometricsModule : Module() {
override fun definition() = ModuleDefinition {
Name("ClerkExpoBiometrics")

Function("getAppIdentifier") { -> notImplemented<String>() }

AsyncFunction("getAvailability") { promise: Promise -> reject(promise) }
AsyncFunction("createKey") { _: String, promise: Promise -> reject(promise) }
AsyncFunction("sign") { _: String, _: String, _: String?, promise: Promise -> reject(promise) }
AsyncFunction("hasKey") { _: String, promise: Promise -> reject(promise) }
AsyncFunction("deleteKey") { _: String, promise: Promise -> reject(promise) }
AsyncFunction("listRecords") { promise: Promise -> reject(promise) }
AsyncFunction("saveRecord") { _: Map<String, Any?>, _: Map<String, Any?>, promise: Promise -> reject(promise) }
AsyncFunction("deleteRecord") { _: String, promise: Promise -> reject(promise) }
AsyncFunction("ensureInstallationMarker") { promise: Promise -> reject(promise) }
}

private fun reject(promise: Promise) {
promise.reject(NotImplementedException())
}

private fun <T> notImplemented(): T = throw NotImplementedException()
}
9 changes: 9 additions & 0 deletions packages/expo-biometrics/expo-module.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"platforms": ["apple", "android"],
"apple": {
"modules": ["ClerkExpoBiometricsModule"]
},
"android": {
"modules": ["expo.modules.clerk.biometrics.ClerkExpoBiometricsModule"]
}
}
Loading
Loading