This is an Expo module as well as a Better Auth plugin to help you create and authenticate with passkeys in your Expo apps. Now in stable release, it's ready for production applications.
Check out our comprehensive example implementation at neb-starter, which demonstrates how to use Expo Passkey across a full-stack application:
- Backend: Built with Next.js, showcasing server-side implementation
- Mobile App: Complete Expo mobile client with passkey authentication
- Working Demo: See passkey registration and authentication in action
- Best Practices: Demonstrates recommended implementation patterns
This starter kit provides a working reference that you can use as a foundation for your own projects or to understand how all the pieces fit together.
See Expo Passkey in action on different platforms:
These demos show the complete passkey experience from registration to authentication using biometric verification.
- Overview
- Key Features
- Platform Requirements
- Installation
- Platform Setup
- Quick Start
- Complete API Reference
- Database Schema
- Database Optimizations
- Troubleshooting
- Security Considerations
- Error Handling
- Bugs and Known Issues
- License
Expo Passkey bridges the gap between Better Auth's backend capabilities and native biometric authentication on mobile devices. It allows your users to authenticate securely using Face ID, Touch ID, or fingerprint recognition without passwords, providing a modern, frictionless authentication experience.
This plugin implements FIDO2-inspired passkey authentication by connecting Better Auth's backend infrastructure with Expo's client-side biometric capabilities, offering a complete end-to-end solution that you can integrate with minimal configuration.
- ✅ Seamless Integration: Works directly with Better Auth server and Expo client
- ✅ Native Biometrics: Leverages Face ID, Touch ID, and fingerprint authentication
- ✅ Cross-Platform: Full support for iOS (16+) and Android (10+)
- ✅ Complete Lifecycle Management: Registration, authentication, and revocation flows
- ✅ Type-Safe API: Comprehensive TypeScript definitions and autocomplete
- ✅ Secure Device Binding: Ensures keys are bound to specific devices
- ✅ Database Integration: Automatically creates a MobilePasskey model in your database
- ✅ Automatic Cleanup: Optional automatic revocation of unused passkeys
- ✅ Rich Metadata: Store and retrieve device-specific context with each passkey
- ✅ Custom UI Hooks: Simplifies integration in your React Native UI
Platform | Minimum Version | Biometric Requirements |
---|---|---|
iOS | iOS 16+ | Face ID or Touch ID configured |
Android | Android 10+ (API level 29+) | Fingerprint or Face Recognition configured |
In your expo app:
# Install the package
npm i expo-passkey
# Install peer dependencies (if not already installed)
npx expo install expo-application expo-local-authentication expo-secure-store expo-crypto expo-device
In your auth server:
# Install the package
npm i expo-passkey
# Install peer dependencies (if not already installed)
npm install better-auth better-fetch @simplewebauthn/server zod
To enable passkeys on iOS, you need to associate your app with a domain:
-
Host Apple App Site Association File:
Create an Apple App Site Association file at
https://<your_domain>/.well-known/apple-app-site-association
:{ "webcredentials": { "apps": ["<teamID>.<bundleID>"] } }
Replace
<teamID>
with your Apple Developer Team ID and<bundleID>
with your app's bundle identifier. -
Configure Your Expo App:
Add the associated domain to your
app.json
:{ "expo": { "ios": { "associatedDomains": ["webcredentials:your_domain"] } } }
-
Configure Server Plugin:
Add your domain to the
origin
array in the expoPasskey options:expoPasskey({ rpId: "example.com", rpName: "Your App Name", origin: ["https://example.com"] // Your associated domain })
To enable passkeys on Android:
-
Host Asset Links JSON File:
Create an asset links file at
https://<your_domain>/.well-known/assetlinks.json
:[ { "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "android_app", "package_name": "<package_name>", "sha256_cert_fingerprints": ["<sha256_cert_fingerprint>"] } } ]
You can generate this file using the Digital Asset Links Tool.
-
Get the Android Origin Value:
For Android, the origin is derived from the SHA-256 hash of the APK signing certificate. Use this Python code to convert your SHA-256 fingerprint:
import binascii import base64 fingerprint = '91:F7:CB:F9:D6:81:53:1B:C7:A5:8F:B8:33:CC:A1:4D:AB:ED:E5:09:C5' print("android:apk-key-hash:" + base64.urlsafe_b64encode(binascii.a2b_hex(fingerprint.replace(':', ''))).decode('utf8').replace('=', ''))
Replace the value of
fingerprint
with your own. -
Configure Server Plugin:
Add the android origin to your expoPasskey options:
expoPasskey({ rpId: "example.com", rpName: "Your App Name", origin: [ "https://example.com", // Your website "android:apk-key-hash:<your-base64url-encoded-hash>" // Android app signature ] })
- Add to Server:
import { betterAuth } from "better-auth";
import { expoPasskey } from "expo-passkey/server";
export const auth = betterAuth({
plugins: [
expoPasskey({
rpId: "example.com",
rpName: "Your App Name",
origin: [
"https://example.com",
"android:apk-key-hash:<your-base64url-encoded-hash>"
]
})
]
});
- Migrate the Database
Run the migration or generate the schema to add the necessary fields and tables to the database.
🚀 Migrate
npx @better-auth/cli migrate
⚙️ Generate
npx @better-auth/cli generate
See the Schema to add the models/fields manually.
- Add to Client:
import { createAuthClient } from "better-auth/react";
import { expoPasskeyClient } from "expo-passkey";
export const {
registerPasskey,
authenticateWithPasskey,
isPasskeySupported
} = createAuthClient({
plugins: [expoPasskeyClient()]
});
For a complete working example of Expo Passkey implementation, explore our neb-starter repository, which demonstrates:
- Server-side configuration in Next.js
- Client-side integration in Expo
- Registration and authentication flows
- Error handling and UI integration
Registers a new passkey for a user. This will prompt for biometric authentication.
interface RegisterOptions {
userId: string; // Required: User ID to associate with the passkey
deviceId?: string; // Optional: Override automatic device ID
metadata?: { // Optional: Additional metadata to store
deviceName?: string; // Device name (e.g. "John's iPhone")
deviceModel?: string; // Device model (e.g. "iPhone 14 Pro")
appVersion?: string; // App version
lastLocation?: string; // Context where registered (e.g. "settings-screen")
manufacturer?: string; // Device manufacturer
brand?: string; // Device brand
biometricType?: string; // Type of biometric used
[key: string]: any; // Any other custom metadata
};
}
// Return type
interface RegisterPasskeyResult {
data: {
success: boolean;
rpName: string; // Relying party name from server config
rpId: string; // Relying party ID from server config
} | null;
error: Error | null;
}
Authenticates a user with a registered passkey. This will prompt for biometric authentication.
interface AuthenticateOptions {
deviceId?: string; // Optional: Override automatic device ID
metadata?: { // Optional: Additional metadata to update
lastLocation?: string; // Context where authentication occurred
appVersion?: string; // App version
[key: string]: any; // Any other custom metadata
};
}
// Return type
interface AuthenticatePasskeyResult {
data: {
token: string; // Session token for authentication
user: { // User object
id: string; // User ID
email: string; // User email
[key: string]: any; // Any other user properties
};
} | null;
error: Error | null;
}
Lists all passkeys registered for a user. Useful for managing devices.
interface ListOptions {
userId: string; // Required: User ID
limit?: number; // Optional: Pagination limit (default: 10)
offset?: number; // Optional: Pagination offset (default: 0)
}
// Return type
interface ListPasskeysResult {
data: {
passkeys: Array<MobilePasskey>; // Array of passkey objects
nextOffset?: number; // Pagination offset for next page
} | null;
error: Error | null;
}
Revokes a passkey, preventing it from being used for authentication.
interface RevokeOptions {
userId: string; // Required: User ID
deviceId?: string; // Optional: Override automatic device ID
reason?: string; // Optional: Reason for revocation
}
// Return type
interface RevokePasskeyResult {
data: { success: boolean } | null;
error: Error | null;
}
Checks if the current device has a registered passkey for the given user.
// Return type
interface PasskeyRegistrationCheckResult {
isRegistered: boolean; // Whether device has a registered passkey
deviceId: string | null; // Device ID
biometricSupport: BiometricSupportInfo | null; // Biometric support info
error: Error | null; // Error if any
}
Checks if the device has a valid registered passkey by verifying both device ID and user ID are present in secure storage.
// Check if the current device has a registered passkey
const hasPasskey = await hasRegisteredPasskey();
if (hasPasskey) {
console.log("This device has a registered passkey");
//carry out some action eg conditionally show passkey login button
} else {
console.log("No passkey registered on this device");
}
Gets information about the device's biometric capabilities, platform, and configuration.
// Return type
interface DeviceInfo {
deviceId: string; // Unique device identifier
platform: "ios" | "android"; // Device platform
model: string | null; // Device model (e.g. "iPhone 14")
manufacturer: string | null; // Device manufacturer (e.g. "Apple")
osVersion: string; // OS version (e.g. "16.0")
appVersion: string; // App version
biometricSupport: {
isSupported: boolean; // Whether biometrics are supported
isEnrolled: boolean; // Whether biometrics are set up
availableTypes: number[]; // Available authentication types
authenticationType: string; // Human-readable type (e.g. "Face ID")
error: string | null; // Error message if any
platformDetails: { // Platform-specific details
platform: string;
version: string | number;
apiLevel?: number | null; // Android API level
manufacturer?: string | null;
brand?: string | null;
}
};
}
Checks if passkeys are supported on the current device based on platform, OS version, and biometric capabilities.
// Returns: boolean
// true if the device supports passkeys, false otherwise
Gets the storage keys used by the plugin for secure storage.
// Return type
interface StorageKeys {
DEVICE_ID: string; // Key for device ID in SecureStore
STATE: string; // Key for state in SecureStore
USER_ID: string; // Key for user ID in SecureStore
CREDENTIAL_IDS: string; // Key for credential IDs in SecureStore
}
Creates a server-side plugin for handling passkey operations.
interface ExpoPasskeyOptions {
rpId: string; // Required: Relying Party ID (domain)
rpName: string; // Required: Human-readable app name
// Optional settings
origin?: string | string[]; // Expected origins for WebAuthn verification
// For iOS: domain of the website associated with your app
// For Android: URI derived from the SHA-256 hash of the APK
// Format: "android:apk-key-hash:<sha256_hash>"
logger?: {
enabled?: boolean; // Enable logging (default: true in dev)
level?: "debug" | "info" |
E0CC
"warn" | "error"; // Log level
};
rateLimit?: {
registerWindow?: number; // Time window for rate limiting (seconds)
registerMax?: number; // Max registration attempts in window
authenticateWindow?: number; // Time window for auth attempts
authenticateMax?: number; // Max auth attempts in window
};
cleanup?: {
inactiveDays?: number; // Days after which to revoke inactive passkeys
disableInterval?: boolean; // Disable automatic cleanup (for serverless)
};
}
The plugin requires two new tables in the database to store passkey data.
Field Name | Type | Key | Description |
---|---|---|---|
id |
string |
PK | Unique identifier for each mobile passkey |
userId |
string |
FK | The ID of the user (references user.id ) |
credentialId |
string |
UQ | Unique identifier of the generated credential |
publicKey |
string |
- | Base64 encoded public key |
counter |
number |
- | For WebAuthn signature verification |
platform |
string |
- | Platform on which the passkey is registered |
lastUsed |
string |
- | Time the passkey was last used |
status |
string |
- | Status of the passkey (active/revoked) |
createdAt |
string |
- | Time when the passkey was created |
updatedAt |
string |
- | Time when the passkey was last updated |
revokedAt |
string (optional) |
- | Timestamp when the passkey was revoked (if any) |
revokedReason |
string (optional) |
- | Reason for revocation (if any) |
metadata |
string (JSON) |
- | JSON string containing metadata about the device |
aaguid |
string |
- | Authenticator Attestation Globally Unique Identifier |
Field Name | Type | Key | Description |
---|---|---|---|
id |
string |
PK | Unique identifier for each challenge |
userId |
string |
- | The ID of the user |
challenge |
string |
- | Base64url encoded challenge |
type |
string |
- | Type of challenge (registration/authentication) |
createdAt |
string |
- | Time when the challenge was created |
expiresAt |
string |
- | Time when the challenge expires |
Optimizing database performance is essential to get the best out of the Expo Passkey plugin.
-
Single field indexes:
userId
: For fast lookups of a user's passkeys.lastUsed
: For efficient sorting and cleanup operations.status
: For filtering by active/revoked status.credentialId
: For quick credential lookup during authentication.
-
Compound indexes:
(credentialId, status)
: Optimizes the authentication endpoint.(userId, status)
: Accelerates the passkey listing endpoint.(lastUsed, status)
: Improves performance of cleanup operations.
- iOS Version Requirements: Must be running iOS 16+ for passkey support
- Biometric Setup: Ensure Face ID/Touch ID is configured in device settings
- Associated Domains: Verify your apple-app-site-association file is accessible
- App Configuration: Check that associatedDomains is properly set in app.json
- Simulator Limitations: Biometric authentication in simulators requires additional setup:
- In the simulator, go to Features → Face ID/Touch ID → Enrolled
- When prompted, select "Matching Face/Fingerprint" for success testing
- Device ID Generation: iOS uses vendor ID from
expo-application
- Device Changes: If a user resets Face ID/Touch ID, passkeys need re-registration
- API Level: Must be running Android 10+ (API level 29+)
- Biometric Hardware: Device must have fingerprint or facial recognition hardware
- Asset Links: Ensure your assetlinks.json file is accessible and correctly formatted
- Signing Certificates: Make sure you're using the correct SHA-256 fingerprint
- Origin Format: Verify your android:apk-key-hash format in the server config
- Configuration: Biometric authentication must be set up in device settings
- Emulator Testing: Configure fingerprint in emulator settings (AVD Manager):
- In AVD settings, enable fingerprint
- Use "adb -e emu finger touch 1" command to simulate fingerprint
- Fragmentation: Behavior may vary across manufacturers
- Device Binding: Passkeys are bound to specific devices for security
- Biometric Data: Biometric data never leaves the device
- Token Security: Use HTTPS for all API communications
- Rate Limiting: Configure appropriate rate limits to prevent brute force attacks
- Automatic Cleanup: Enable cleanup to revoke unused passkeys periodically
- Multiple Devices: Allow users to register multiple devices for convenience
- Fallback Authentication: Always provide alternate authentication methods
- Revocation: Users should be able to revoke passkeys from all devices
- Metadata Handling: Be careful with what you store in metadata to avoid privacy concerns
The package provides specific error codes for different scenarios:
// Environment errors
ERROR_CODES.ENVIRONMENT.NOT_SUPPORTED // Device/platform not supported
ERROR_CODES.ENVIRONMENT.MODULE_NOT_FOUND // Required Expo module missing
// Biometric errors
ERROR_CODES.BIOMETRIC.NOT_SUPPORTED // Device lacks biometric hardware
ERROR_CODES.BIOMETRIC.NOT_ENROLLED // Biometrics not set up on device
ERROR_CODES.BIOMETRIC.AUTHENTICATION_FAILED // User failed/cancelled verification
// Device errors
ERROR_CODES.DEVICE.ID_GENERATION_FAILED // Could not generate device ID
// WebAuthn errors
ERROR_CODES.WEBAUTHN.NOT_SUPPORTED // WebAuthn not supported
ERROR_CODES.WEBAUTHN.CANCELED // User canceled operation
ERROR_CODES.WEBAUTHN.TIMEOUT // Operation timed out
ERROR_CODES.WEBAUTHN.OPERATION_FAILED // WebAuthn operation failed
ERROR_CODES.WEBAUTHN.NATIVE_MODULE_ERROR // Error in native module
// Server errors
ERROR_CODES.SERVER.CREDENTIAL_EXISTS // Passkey already registered
ERROR_CODES.SERVER.INVALID_CREDENTIAL // Passkey not found
ERROR_CODES.SERVER.CREDENTIAL_NOT_FOUND // Passkey doesn't exist
ERROR_CODES.SERVER.AUTHENTICATION_FAILED // Authentication failed
ERROR_CODES.SERVER.USER_NOT_FOUND // User not found
ERROR_CODES.SERVER.INVALID_ORIGIN // Invalid origin
ERROR_CODES.SERVER.VERIFICATION_FAILED // WebAuthn verification failed
Example error handling pattern:
try {
const result = await authenticateWithPasskey();
if (result.error) {
// Handle specific error types
if (result.error.code === ERROR_CODES.BIOMETRIC.AUTHENTICATION_FAILED) {
showAuthFailedMessage();
} else if (result.error.code === ERROR_CODES.SERVER.INVALID_CREDENTIAL) {
promptReregistration();
} else {
// Generic error handling
showErrorMessage(result.error.message);
}
return;
}
// Handle success
handleSuccessfulAuthentication(result.data);
} catch (error) {
// Catch unexpected errors
console.error("Unexpected error:", error);
showGenericErrorMessage();
}
This package is now in stable release, meaning it's considered production-ready and has been thoroughly tested in real-world applications. However, we still encourage you to report any issues you encounter on our Github issues page.
There are a few platform limitations to be aware of:
- Expo Go Limitations: Due to how Expo Go manages native modules, passkey functionality requires a development build or production build
- Android Compatibility: Some Android devices may not support passkeys despite meeting the API level requirements
- iOS Simulator: Biometric authentication in iOS simulators may not work consistently
- Storage Persistence: On some devices, SecureStore may be cleared when app is uninstalled
We appreciate your feedback as we continue to improve the library.
MIT
Contributions are welcome! Please feel free to submit a Pull Request.