This is a cross-platform Expo module and Better Auth plugin that brings passkey authentication to your Expo apps on web, iOS, and Android. Features a unified passkey table structure that works seamlessly across all platforms, making it perfect for both universal apps using react-native-web and projects with separate mobile and web frontends.
🚀 v0.2.0: Now includes web support, unified table structure, client-controlled WebAuthn preferences, and enhanced cross-platform passkey syncing!
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
- Web App: Full web implementation using the same codebase
- Working Demo: See passkey registration and authentication in action across platforms
- 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, including cross-platform passkey portability.
- Overview
- Key Features
- Platform Requirements
- Installation
- Platform Setup
- Quick Start
- Complete API Reference
- Database Schema
- Custom Schema Configuration
- Cross-Platform Usage
- Client Preferences
- Database Optimizations
- Troubleshooting
- Security Considerations
- Error Handling
- License
Expo Passkey bridges the gap between Better Auth's backend capabilities and cross-platform authentication on web, mobile, and native platforms. It allows your users to authenticate securely using Face ID, Touch ID, fingerprint recognition, or platform authenticators in web browsers, providing a modern, frictionless authentication experience.
This plugin implements a comprehensive FIDO2/WebAuthn passkey solution that connects Better Auth's backend infrastructure with platform-specific authentication capabilities, offering a complete end-to-end solution that works seamlessly across web, iOS, and Android with client-controlled security preferences and cross-platform credential syncing.
- ✅ Cross-Platform Support: Works on web browsers, iOS (16+), and Android (10+)
- ✅ Unified Table Structure: Single table works across web, mobile, and all platforms
- ✅ Custom Schema Configuration: Customize database table names to fit your existing structure
- ✅ Universal App Ready: Perfect for Expo + react-native-web projects and separate frontend architectures
- ✅ Platform-Specific Optimization: Native biometrics on mobile, WebAuthn in browsers
- ✅ Client-Controlled Preferences: Specify attestation, user verification, and authenticator requirements
- ✅ Enterprise-Ready Security: Support for direct attestation and required user verification
- ✅ Cross-Platform Syncing: Automatic support for iCloud Keychain, Google Password Manager, and hardware keys
- ✅ Seamless Integration: Works directly with Better Auth server and client
- ✅ 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/platforms
- ✅ Automatic Cleanup: Optional automatic revocation of unused passkeys
- ✅ Rich Metadata: Store and retrieve device-specific context with each passkey
- ✅ Portable Passkeys: Supports iCloud Keychain, Google Password Manager, and hardware keys
Platform | Minimum Version | Authentication Requirements |
---|---|---|
Web | Modern browsers with WebAuthn | Platform authenticator or security key |
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
# For web support, also install:
npm install @simplewebauthn/browser
Import Strategy: The package uses platform-specific entry points to prevent import conflicts:
// ✅ Correct imports
import { expoPasskeyClient } from "expo-passkey/native"; // Mobile
import { expoPasskeyClient } from "expo-passkey/web"; // Web
import { expoPasskey } from "expo-passkey/server"; // Server
// ❌ Avoid this - will show helpful error
import { expoPasskeyClient } from "expo-passkey"; // Guard rail
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 ] })
Web setup is automatic when using the plugin in a browser environment. Ensure your site is served over HTTPS (required for WebAuthn) and that your server configuration includes your web domain in the origin
array.
- 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>"
]
// Optional settings
logger: {
enabled: true, // Enable detailed logging (default: true in dev)
level: "debug", // Log level: "debug", "info", "warn", "error"
},
rateLimit: {
registerWindow: 300, // Time window in seconds for rate limiting
registerMax: 3, // Max registration attempts in window
authenticateWindow: 60, // Time window for auth attempts
authenticateMax: 5, // Max auth attempts in window
},
cleanup: {
inactiveDays: 30, // Auto-revoke passkeys after 30 days of inactivity
disableInterval: false, // Set to true in serverless environments
},
schema: {
authPasskey: { modelName: "user_passkeys" },
passkeyChallenge: { modelName: "auth_challenges" }
}
})
]
});
- 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:
For Mobile App (React Native/Expo):
import { createAuthClient } from "better-auth/react";
import { expoClient } from "@better-auth/expo/client";
import { expoPasskeyClient } from "expo-passkey/native";
import * as SecureStore from "expo-secure-store";
export const authClient = createAuthClient({
baseURL: process.env.EXPO_PUBLIC_AUTH_BASE_URL,
plugins: [
expoClient({
scheme: "your-app",
storagePrefix: "your_app",
storage: SecureStore,
}),
expoPasskeyClient({
storagePrefix: "your_app",
}),
// ... other plugins
],
});
export const {
registerPasskey,
authenticateWithPasskey,
listPasskeys,
revokePasskey,
isPasskeySupported,
getBiometricInfo,
getDeviceInfo
} = authClient;
For Web App (Next.js/React):
import { createAuthClient } from "better-auth/react";
import { expoPasskeyClient } from "expo-passkey/web";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL,
plugins: [
expoPasskeyClient(),
// ... other plugins
],
});
export const {
isPlatformAuthenticatorAvailable,
registerPasskey,
authenticateWithPasskey,
listPasskeys,
revokePasskey,
} = authClient;
Registers a new passkey for a user with full client preference control.
interface RegisterOptions {
userId: string; // Required: User ID to associate with the passkey
userName: string; // Required: User name for the passkey
displayName?: string; // Optional: Display name (defaults to userName)
rpId?: string; // Optional: Relying Party ID (auto-detected on web)
rpName?: string; // Optional: Relying Party name
attestation?: "none" | "indirect" | "direct" | "enterprise";
authenticatorSelection?: { // Optional: Authenticator selection criteria
authenticatorAttachment?: "platform" | "cross-platform";
residentKey?: "required" | "preferred" | "discouraged";
requireResidentKey?: boolean;
userVerification?: "required" | "preferred" | "discouraged";
};
timeout?: number; // Optional: Timeout in milliseconds
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
manufacturer?: string; // Device manufacturer
brand?: string; // Device brand
biometricType?: string; // Type of biometric used
[key: string]: unknown; // 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. Works across all platforms.
interface AuthenticateOptions {
userId?: string; // Optional: User ID (for targeted authentication)
rpId?: string; // Optional: Relying Party ID (auto-detected on web)
timeout?: number; // Optional: Timeout in milliseconds
userVerification?: "required" | "preferred" | "discouraged";
metadata?: { // Optional: Additional metadata to update
lastLocation?: string; // Context where authentication occurred
appVersion?: string; // App version
[key: string]: unknown; // 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;
}
// Check if passkeys are supported on current platform
const isSupported = await isPasskeySupported();
// Get platform-specific device information (mobile only)
if (Platform.OS !== 'web') {
const deviceInfo = await getDeviceInfo();
const biometricInfo = await getBiometricInfo();
}
// Check platform authenticator availability (web only)
if (Platform.OS === 'web') {
const isAvailable = await isPlatformAuthenticatorAvailable();
}
For projects with separate mobile and web frontends that share the same backend, both applications can use the expoPasskeyClient()
plugin:
Mobile App Example:
// Mobile app (React Native/Expo)
const { data, error } = await registerPasskey({
userId: "user123",
userName: "[email protected]",
displayName: "John Doe",
rpId: "example.com",
rpName: "My App"
});
if (data) {
console.log("Passkey registered on mobile!");
}
Web App Example:
// Web app (Next.js/React)
const { data, error } = await registerPasskey({
userId: "user123",
userName: "[email protected]",
displayName: "John Doe",
rpId: "example.com",
rpName: "My App"
});
if (data) {
console.log("Passkey registered on web!");
}
The plugin automatically supports cross-platform credential usage:
// 1. User registers on iPhone (native app)
await registerPasskey({
userId: "user123",
userName: "[email protected]",
authenticatorSelection: {
authenticatorAttachment: "platform", // Uses Face ID/Touch ID
userVerification: "required"
}
// Automatically syncs to iCloud Keychain
});
// 2. User opens web app on Mac (same iCloud account)
await authenticateWithPasskey({
// No userId needed - discovers credentials automatically
// Can access same passkey from iCloud Keychain
});
// 1. Register YubiKey on mobile
await registerPasskey({
userId: "user123",
userName: "[email protected]",
authenticatorSelection: {
authenticatorAttachment: "cross-platform", // YubiKey/Security key
userVerification: "preferred"
}
});
// 2. Use same YubiKey on web
await authenticateWithPasskey({
userId: "user123", // Optional - can discover automatically
// Same YubiKey works across platforms
});
You can check the current platform and access platform-specific features:
import { Platform } from 'react-native';
// Mobile-specific features
if (Platform.OS !== 'web') {
const biometricInfo = await getBiometricInfo();
const deviceInfo = await getDeviceInfo();
}
// Web-specific features
if (Platform.OS === 'web') {
const isAvailable = await isPlatformAuthenticatorAvailable();
}
With the unified table structure, passkeys work seamlessly across platforms:
- A user can register a passkey on mobile and use it with iCloud Keychain on web
- Security keys work across all platforms
- The same API manages passkeys regardless of where they were created
- Single database table handles all platform variations
- Enhanced metadata tracks cross-platform usage and original platform
Control the security requirements for your passkeys:
await registerPasskey({
userId: "executive123",
userName: "[email protected]",
displayName: "CEO",
// High security preferences
attestation: "direct", // Request device attestation for verification
authenticatorSelection: {
authenticatorAttachment: "platform", // Require biometric authenticator
userVerification: "required", // Always require biometric verification
residentKey: "required", // Create discoverable credentials
},
timeout: 120000, // 2 minutes for complex security flows
});
await registerPasskey({
userId: "user123",
userName: "[email protected]",
// Convenient preferences
attestation: "none", // No attestation needed
authenticatorSelection: {
authenticatorAttachment: "platform", // Prefer platform but allow cross-platform
userVerification: "preferred", // Prefer but don't require
residentKey: "preferred", // Prefer discoverable but allow non-discoverable
},
timeout: 60000, // 1 minute
});
await registerPasskey({
userId: "user123",
userName: "[email protected]",
// Cross-platform preferences
attestation: "indirect", // Some attestation for verification
authenticatorSelection: {
authenticatorAttachment: "cross-platform", // Allow hardware keys
userVerification: "required", // Still require verification
residentKey: "discouraged", // Hardware keys often don't support resident keys
},
});
Automatically adjust security based on device capabilities:
// Check device capabilities first
const deviceInfo = await getDeviceInfo();
const biometricInfo = await getBiometricInfo();
// Adapt preferences based on device
let preferences = {
attestation: "none" as const,
authenticatorSelection: {
authenticatorAttachment: "platform" as const,
userVerification: "preferred" as const,
residentKey: "preferred" as const,
}
};
// High-end devices get stricter requirements
if (biometricInfo?.isEnrolled && deviceInfo.platform === 'ios') {
preferences.authenticatorSelection.userVerification = "required";
preferences.authenticatorSelection.residentKey = "required";
}
// Enterprise environments might require attestation
if (process.env.EXPO_PUBLIC_ENVIRONMENT === 'enterprise') {
preferences.attestation = "direct";
}
await registerPasskey({
userId: "user123",
userName: "[email protected]",
...preferences,
});
The plugin uses a unified table structure that works seamlessly across all platforms.
Field Name | Type | Key | Description |
---|---|---|---|
id |
string |
PK | Unique identifier for each 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 and client preferences |
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 |
registrationOptions |
string (optional) |
- | JSON string containing client registration preferences |
You can customize the database table names to fit your existing database structure or naming conventions:
import { betterAuth } from "better-auth";
import { expoPasskey } from "expo-passkey/server";
export const auth = betterAuth({
plugins: [
expoPasskey({
rpId: "example.com",
rpName: "Your App Name",
// ✨ Custom schema configuration
schema: {
authPasskey: {
modelName: "user_passkeys" // Custom table name for passkeys
},
passkeyChallenge: {
modelName: "auth_challenges" // Custom table name for challenges
}
}
})
]
});
If no custom schema is provided, the plugin uses these default table names:
- Passkeys:
authPasskey
- Challenges:
passkeyChallenge
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.(userId, type)
: Improves challenge lookup performance.
- HTTPS Required: WebAuthn only works over HTTPS in production
- Browser Support: Ensure the browser supports WebAuthn and platform authenticators
- Same-Origin Policy: Ensure your RP ID matches your domain
- Platform Authenticator: Some browsers may not have platform authenticators available
- 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
- 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
- Platform Detection: The plugin automatically detects the platform, but you can manually check using
Platform.OS
- Import Issues: The plugin uses platform-specific entry points to avoid importing incompatible modules
- Metro Bundler: Ensure your Metro configuration supports the export conditions in package.json
- Preference Enforcement: If client preferences aren't being respected, check server logs for stored registration options
- Attestation Requirements: Direct attestation may not be available on all devices or platforms
- Hardware Key Support: Some authenticator selection criteria may not apply to hardware keys
- Client Preference Enforcement: Server now enforces client-specified security requirements
- Cross-Platform Security: Passkeys maintain the same security properties across platforms
- Domain Verification: Ensure proper domain verification for both web and mobile
- Portable Passkeys: iCloud Keychain and Google Password Manager sync passkeys securely
- Hardware Keys: Support for hardware security keys across all platforms
- Attestation Handling: Proper support for enterprise attestation requirements
- Token Security: Use HTTPS for all API communications
- Rate Limiting: Configure appropriate rate limits to prevent brute force attacks
The package provides comprehensive error codes for all platforms:
// Platform-agnostic error handling with preference validation
try {
const result = await registerPasskey({
userId: "user123",
userName: "[email protected]",
attestation: "direct",
authenticatorSelection: {
userVerification: "required"
}
});
if (result.error) {
if (result.error.code === ERROR_CODES.WEBAUTHN.NOT_SUPPORTED) {
showPlatformNotSupportedMessage();
} else if (result.error.code === ERROR_CODES.BIOMETRIC.AUTHENTICATION_FAILED) {
showAuthFailedMessage();
} else if (result.error.code === ERROR_CODES.SERVER.VERIFICATION_FAILED) {
showPreferenceValidationError();
}
return;
}
handleSuccessfulRegistration(result.data);
} catch (error) {
console.error("Unexpected error:", error);
}
MIT
Contributions are welcome! Please feel free to submit a Pull Request.