Skip to content

Commit 0505abb

Browse files
mikepitreclaude
andcommitted
feat(expo-biometrics): add biometric credential native module
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 03f95ab commit 0505abb

31 files changed

Lines changed: 2880 additions & 3 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/expo-biometrics': minor
3+
---
4+
5+
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`.
6+
7+
If you try this out, make sure to pin your version as breaking changes can happen in minors.

‎.github/workflows/expo-native-build.yml‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ on:
1010
- 'integration/templates/expo-native/**'
1111
- 'integration/tests/expo-native/**'
1212
- 'packages/expo/**'
13+
- 'packages/expo-biometrics/**'
1314
- 'packages/expo-google-signin/**'
1415
- 'packages/expo-native/**'
1516
workflow_dispatch:
@@ -85,6 +86,7 @@ jobs:
8586
turbo.json \
8687
packages/clerk-js \
8788
packages/expo \
89+
packages/expo-biometrics \
8890
packages/expo-google-signin \
8991
packages/expo-native \
9092
packages/react \
@@ -123,11 +125,12 @@ jobs:
123125
- name: Build and pack Clerk packages
124126
if: steps.native-build-cache.outputs.cache-hit != 'true'
125127
run: |
126-
pnpm --filter @clerk/expo... build
128+
pnpm --filter @clerk/expo... --filter @clerk/expo-biometrics build
127129
mkdir -p "$SDK_PACK_DIR"
128130
pnpm --filter @clerk/expo pack --pack-destination "$SDK_PACK_DIR"
129131
pnpm --filter @clerk/expo-google-signin pack --pack-destination "$SDK_PACK_DIR"
130132
pnpm --filter @clerk/expo-native pack --pack-destination "$SDK_PACK_DIR"
133+
pnpm --filter @clerk/expo-biometrics pack --pack-destination "$SDK_PACK_DIR"
131134
132135
- name: Install fixture dependencies
133136
if: steps.native-build-cache.outputs.cache-hit != 'true'
@@ -138,11 +141,12 @@ jobs:
138141
run: |
139142
cp "package.sdk-$EXPO_SDK.json" package.json
140143
pnpm install --no-frozen-lockfile
141-
# [0-9] keeps this glob off the clerk-expo-google-signin and clerk-expo-native tarballs.
144+
# [0-9] keeps this glob off the other clerk-expo-* tarballs.
142145
SDK_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-[0-9]*.tgz)"
143146
GOOGLE_SIGNIN_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-google-signin-*.tgz)"
144147
NATIVE_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-native-*.tgz)"
145-
pnpm add "$SDK_TARBALL" "$GOOGLE_SIGNIN_TARBALL" "$NATIVE_TARBALL" -w
148+
BIOMETRICS_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-biometrics-*.tgz)"
149+
pnpm add "$SDK_TARBALL" "$GOOGLE_SIGNIN_TARBALL" "$NATIVE_TARBALL" "$BIOMETRICS_TARBALL" -w
146150
# expo-dev-client makes even release builds boot into the dev
147151
# launcher (unreachable Metro in CI), which stalls every Maestro
148152
# flow on a blank screen. Skip it on e2e jobs only.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# OSX
2+
#
3+
.DS_Store
4+
5+
# VSCode
6+
.vscode/
7+
jsconfig.json
8+
9+
# Xcode
10+
#
11+
build/
12+
*.pbxuser
13+
!default.pbxuser
14+
*.mode1v3
15+
!default.mode1v3
16+
*.mode2v3
17+
!default.mode2v3
18+
*.perspectivev3
19+
!default.perspectivev3
20+
xcuserdata
21+
*.xccheckout
22+
*.moved-aside
23+
DerivedData
24+
*.hmap
25+
*.ipa
26+
*.xcuserstate
27+
project.xcworkspace
28+
29+
# Android/IJ
30+
#
31+
.classpath
32+
.cxx
33+
.gradle
34+
.idea
35+
.project
36+
.settings
37+
local.properties
38+
android.iml
39+
android/app/libs
40+
android/keystores/debug.keystore
41+
42+
# Cocoapods
43+
#
44+
example/ios/Pods
45+
46+
# Ruby
47+
example/vendor/
48+
49+
# node.js
50+
#
51+
node_modules/
52+
npm-debug.log
53+
yarn-debug.log
54+
yarn-error.log
55+
56+
# Expo
57+
.expo/*
58+
.env
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Exclude all top-level hidden directories by convention
2+
/.*/
3+
4+
# Exclude tarballs generated by `npm pack`
5+
/*.tgz
6+
7+
__mocks__
8+
__tests__
9+
10+
/babel.config.js
11+
/android/src/androidTest/
12+
/android/src/test/
13+
/android/build/
14+
/example/

‎packages/expo-biometrics/LICENSE‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Clerk, Inc.
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

‎packages/expo-biometrics/README.md‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<p align="center">
2+
<a href="https://clerk.com?utm_source=github&utm_medium=clerk_expo_biometrics" target="_blank" rel="noopener noreferrer">
3+
<picture>
4+
<source media="(prefers-color-scheme: dark)" srcset="https://images.clerk.com/static/logo-dark-mode-400x400.png">
5+
<img src="https://images.clerk.com/static/logo-light-mode-400x400.png" height="64">
6+
</picture>
7+
</a>
8+
<br />
9+
<h1 align="center">@clerk/expo-biometrics</h1>
10+
</p>
11+
12+
<div align="center">
13+
14+
[![Chat on Discord](https://img.shields.io/discord/856971667393609759.svg?logo=discord)](https://clerk.com/discord)
15+
[![Clerk documentation](https://img.shields.io/badge/documentation-clerk-green.svg)](https://clerk.com/docs?utm_source=github&utm_medium=expo_biometrics)
16+
[![Follow on X](https://img.shields.io/twitter/follow/clerk?style=social)](https://x.com/intent/follow?screen_name=clerk)
17+
18+
[Changelog](https://github.com/clerk/javascript/blob/main/packages/expo-biometrics/CHANGELOG.md)
19+
·
20+
[Report a Bug](https://github.com/clerk/javascript/issues/new?assignees=&labels=needs-triage&projects=&template=BUG_REPORT.yml)
21+
·
22+
[Request a Feature](https://feedback.clerk.com/roadmap)
23+
·
24+
[Get help](https://clerk.com/contact/support?utm_source=github&utm_medium=expo_biometrics)
25+
26+
</div>
27+
28+
> [!WARNING]
29+
> This package is experimental. Pin its version, as breaking changes can happen in minor releases.
30+
31+
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.
32+
33+
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.
34+
35+
### Prerequisites
36+
37+
- Expo SDK 54 or later, in a development build (the module is not available in Expo Go or on the web)
38+
- iOS. Android support is not implemented yet: every call rejects with `not_implemented`.
39+
- `NSFaceIDUsageDescription` in your `Info.plist`. The `@clerk/expo` config plugin sets it through its `faceIDPermission` option.
40+
41+
## Installation
42+
43+
```sh
44+
npx expo install @clerk/expo-biometrics
45+
```
46+
47+
Then rebuild your native app.
48+
49+
## API
50+
51+
```ts
52+
import {
53+
createKey,
54+
deleteKey,
55+
deleteRecord,
56+
ensureInstallationMarker,
57+
getAppIdentifier,
58+
getAvailability,
59+
hasKey,
60+
listRecords,
61+
saveRecord,
62+
sign,
63+
} from '@clerk/expo-biometrics';
64+
```
65+
66+
| Function | Description |
67+
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
68+
| `getAppIdentifier()` | The app identifier sent to Clerk as `app_identifier` (the iOS bundle identifier). |
69+
| `getAvailability()` | The device's biometry type and whether biometrics or device owner authentication can be evaluated. |
70+
| `createKey(policy)` | Creates a Secure Enclave P-256 key and returns its `localKeyId` and public key JWK. |
71+
| `sign(localKeyId, clientData, reason?)` | Prompts for authentication and returns an ES256 signature over `clientData` (raw `r \|\| s`, base64url without padding). |
72+
| `hasKey(localKeyId)` / `deleteKey(localKeyId)` | Checks for or deletes a key. |
73+
| `listRecords()` | Every stored credential record, for every app identifier. |
74+
| `saveRecord(record, options)` | Saves a record. With `removeOtherRecordsForApp: true`, deletes the app's other records and their keys. |
75+
| `deleteRecord(localKeyId)` | Deletes a key, then the records that reference it. |
76+
| `ensureInstallationMarker()` | Deletes records and keys left behind by a previous installation of the app. The store functions call it for you. |
77+
78+
Every error is a `ClerkBiometricsError` with a stable `code`, such as `user_canceled`, `biometry_not_enrolled`, `biometry_lockout`, `key_not_found`, or `storage_failed`.
79+
80+
## License
81+
82+
This project is licensed under the **MIT license**.
83+
84+
See [LICENSE](https://github.com/clerk/javascript/blob/main/packages/expo-biometrics/LICENSE) for more information.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
apply plugin: 'com.android.library'
2+
// AGP 9+ registers the `kotlin` extension itself, and applying kotlin-android on top of that fails configuration.
3+
if (project.extensions.findByName('kotlin') == null) {
4+
apply plugin: 'kotlin-android'
5+
}
6+
7+
apply plugin: 'maven-publish'
8+
9+
group = 'expo.modules.clerk.biometrics'
10+
version = '1.0.0'
11+
12+
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
13+
apply from: expoModulesCorePlugin
14+
applyKotlinExpoModulesCorePlugin()
15+
useCoreDependencies()
16+
useExpoPublishing()
17+
18+
buildscript {
19+
ext.safeExtGet = { prop, fallback ->
20+
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
21+
}
22+
}
23+
24+
android {
25+
namespace "expo.modules.clerk.biometrics"
26+
27+
compileSdkVersion safeExtGet("compileSdkVersion", 36)
28+
29+
defaultConfig {
30+
minSdkVersion safeExtGet("minSdkVersion", 24)
31+
targetSdkVersion safeExtGet("targetSdkVersion", 36)
32+
versionCode 1
33+
versionName "1.0.0"
34+
}
35+
}
36+
37+
dependencies {
38+
implementation project(':expo-modules-core')
39+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
<manifest>
2+
</manifest>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package expo.modules.clerk.biometrics
2+
3+
import expo.modules.kotlin.Promise
4+
import expo.modules.kotlin.exception.CodedException
5+
import expo.modules.kotlin.modules.Module
6+
import expo.modules.kotlin.modules.ModuleDefinition
7+
8+
class NotImplementedException :
9+
CodedException("not_implemented", "@clerk/expo-biometrics is not supported on Android yet.", null)
10+
11+
class ClerkExpoBiometricsModule : Module() {
12+
override fun definition() = ModuleDefinition {
13+
Name("ClerkExpoBiometrics")
14+
15+
Function("getAppIdentifier") { -> notImplemented<String>() }
16+
17+
AsyncFunction("getAvailability") { promise: Promise -> reject(promise) }
18+
AsyncFunction("createKey") { _: String, promise: Promise -> reject(promise) }
19+
AsyncFunction("sign") { _: String, _: String, _: String?, promise: Promise -> reject(promise) }
20+
AsyncFunction("hasKey") { _: String, promise: Promise -> reject(promise) }
21+
AsyncFunction("deleteKey") { _: String, promise: Promise -> reject(promise) }
22+
AsyncFunction("listRecords") { promise: Promise -> reject(promise) }
23+
AsyncFunction("saveRecord") { _: Map<String, Any?>, _: Map<String, Any?>, promise: Promise -> reject(promise) }
24+
AsyncFunction("deleteRecord") { _: String, promise: Promise -> reject(promise) }
25+
AsyncFunction("ensureInstallationMarker") { promise: Promise -> reject(promise) }
26+
}
27+
28+
private fun reject(promise: Promise) {
29+
promise.reject(NotImplementedException())
30+
}
31+
32+
private fun <T> notImplemented(): T = throw NotImplementedException()
33+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"platforms": ["apple", "android"],
3+
"apple": {
4+
"modules": ["ClerkExpoBiometricsModule"]
5+
},
6+
"android": {
7+
"modules": ["expo.modules.clerk.biometrics.ClerkExpoBiometricsModule"]
8+
}
9+
}

0 commit comments

Comments
 (0)