expo-passkey
v0.3.15
Published
Passkey authentication for Expo apps with Better Auth integration
Downloads
25,570
Maintainers
Readme
Expo Passkey
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.3.15: Documentation now tracks the current
expo-passkey-livenessalpha integration, thelivenessTokenforwarding added in v0.3.14, and the Better Auth 1.6+ session-resolution fallback introduced in v0.3.13.
📱 Example Project
Check out the companion example monorepo at
epk-example-app, which
demonstrates expo-passkey and expo-passkey-liveness end to end:
- Backend: Next.js 15 + Better Auth + Prisma, wiring both plugins in one
betterAuth()call - Web App: Browser WebAuthn registration/authentication with liveness-token enforcement and debug views
- Mobile App: Expo SDK 55 dev-client app using native passkeys,
expo-secure-store, and liveness wrappers - Audit Surface: Debug routes show
passkey.metadata.livenessandpasskeyLivenessSessionrows after ceremonies - Deployment Shape: Vercel-ready web backend plus iOS Associated Domains and Android Asset Links setup for native apps
Use it as the current integration reference when you want passkeys alone, passkeys plus liveness gating, or a full passwordless email-OTP fallback.
🎬 Video Demos
See Expo Passkey in action on different platforms:
iOS Demo
Android Demo
Cross-Platform Portability Demo
These demos show the complete passkey experience from registration to authentication using biometric verification, including cross-platform passkey portability.
📋 Table of Contents
- Overview
- Key Features
- Platform Requirements
- Installation
- Platform Setup
- Quick Start
- Optional Liveness Gating
- Complete API Reference
- Database Schema
- Custom Schema Configuration
- Cross-Platform Usage
- Client Preferences
- Database Optimizations
- Troubleshooting
- Security Considerations
- Error Handling
- Example Project
- License
Overview
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.
Key Features
- ✅ 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
- ✅ Better Auth 1.6+ Compatible: Session fallback avoids false
SESSION_REQUIREDfailures in newer Better Auth runtimes - ✅ 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
- ✅ Optional Liveness Gating: Forwards
livenessTokento the companionexpo-passkey-livenessenforcement hook - ✅ 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 Requirements
| 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 |
Installation
Client Installation
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/browserOptional Liveness Extension
If you want face presentation-attack-detection (PAD) before passkey registration or authentication, install the sibling package too:
npm install expo-passkey expo-passkey-liveness@nextexpo-passkey works without the liveness package. When the sibling
plugin is installed and configured with server enforcement,
livenessToken becomes the bridge between the two packages.
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 railServer Installation
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 zodPlatform Setup
iOS Setup
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
originarray in the expoPasskey options:expoPasskey({ rpId: "example.com", rpName: "Your App Name", origin: ["https://example.com"] // Your associated domain })
Android Setup
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
fingerprintwith 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
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.
Quick Start
- 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" }
}
})
]
});For serverless environments such as Vercel, set
cleanup.disableInterval: true so the auth handler does not keep background
timers alive.
- Migrate the Database
Run the migration or generate the schema to add the necessary fields and tables to the database.
npx @better-auth/cli migratenpx @better-auth/cli generateSee 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",
rpId: "example.com", // Recommended for native - prevents authentication errors
timeout: 60000, // Optional: WebAuthn operation timeout (default: 60000ms)
}),
// ... 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({
rpId: "example.com", // Optional - auto-detected from window.location.hostname
timeout: 60000, // Optional: WebAuthn operation timeout (default: 60000ms)
}),
// ... other plugins
],
});
export const {
isPlatformAuthenticatorAvailable,
registerPasskey,
authenticateWithPasskey,
listPasskeys,
revokePasskey,
} = authClient;Optional Liveness Gating
expo-passkey accepts an optional livenessToken on both registration
and authentication. The core package does not validate this token by
itself; it simply forwards the field so expo-passkey-liveness can
enforce it in a Better Auth hook before the passkey endpoint handler runs.
Compose expoPasskeyLiveness() after expoPasskey() in the same
betterAuth() call. Both plugins must use the same rpId.
import { betterAuth } from "better-auth";
import { expoPasskey } from "expo-passkey/server";
import {
expoPasskeyLiveness,
rekognitionProvider,
redisReplayStore,
} from "expo-passkey-liveness/server";
export const auth = betterAuth({
plugins: [
expoPasskey({
rpId: "example.com",
rpName: "Your App Name",
origin: ["https://example.com"],
}),
expoPasskeyLiveness({
rpId: "example.com",
liveness: {
required: "both", // "registration" | "authentication" | "both"
provider: rekognitionProvider({ region: "us-east-1" }),
minScore: 90,
replayStore: redisReplayStore(redis),
},
cleanup: {
disableInterval: true, // recommended in serverless runtimes
},
}),
],
});The liveness package exposes:
POST /expo-passkey/liveness/sessionPOST /expo-passkey/liveness/verify- a
hooks.beforeenforcement layer that validateslivenessTokenbefore/expo-passkey/registerand/or/expo-passkey/authenticateruns - an audit slice written into
passkey.metadata.liveness
Native clients can use the liveness wrappers:
import {
registerPasskeyWithLiveness,
authenticateWithPasskeyAndLiveness,
} from "expo-passkey-liveness/native";
const registerResult = await registerPasskeyWithLiveness(
{
userId: session.user.id,
userName: session.user.email,
displayName: session.user.name ?? session.user.email,
rpId: "example.com",
rpName: "Your App Name",
},
{ fetcher, registerPasskey }
);
const authResult = await authenticateWithPasskeyAndLiveness(
{ rpId: "example.com" },
{ fetcher, authenticateWithPasskey }
);Or pass a token manually:
const live = await verifyLiveness(
{ challenge: "registration" },
{ fetcher }
);
if (!live.error && live.data) {
await registerPasskey({
userId: session.user.id,
userName: session.user.email,
rpId: "example.com",
rpName: "Your App Name",
livenessToken: live.data.livenessToken,
});
}The liveness package's web entrypoint is currently a stub that returns
LIVENESS_NOT_SUPPORTED; the example app uses a local web-only adapter
to exercise the server pipeline with a demo customProvider.
When expo-passkey-liveness is not installed or does not gate an
operation, livenessToken is ignored and existing passkey-only flows
continue to work.
Complete API Reference
Client API
Client Options
Configure the passkey client when initializing:
interface ExpoPasskeyClientOptions {
/**
* Prefix for storage keys
* @default '_better-auth'
*/
storagePrefix?: string;
/**
* Timeout for WebAuthn operations in milliseconds
* @default 60000 (1 minute)
*/
timeout?: number;
/**
* Relying Party ID - the domain of your application
* @default window.location.hostname (web) or undefined (native)
* @example 'example.com'
*
* IMPORTANT: For native apps, this should match your server's rpId.
* Can be overridden per-operation by passing rpId to registerPasskey()
* or authenticateWithPasskey().
*/
rpId?: string;
}Native App Example:
expoPasskeyClient({
storagePrefix: "myapp",
rpId: "example.com", // Required for reliable native auth
timeout: 60000,
})Web App Example:
expoPasskeyClient({
rpId: "example.com", // Optional - auto-detected from URL
timeout: 60000,
})registerPasskey(options): Promise<RegisterPasskeyResult>
Registers a new passkey for a user with full client preference control.
⚠️ Authentication Required: User must be authenticated before calling this function. The server associates the passkey with the active session user, not a trusted client-supplied user id.
interface RegisterOptions {
userId: string; // Required client-side WebAuthn user handle/local credential key
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
};
livenessToken?: string; // Optional: forwarded to expo-passkey-liveness when installed
}
// 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;
}authenticateWithPasskey(options?): Promise<AuthenticatePasskeyResult>
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
};
livenessToken?: string; // Optional: forwarded to expo-passkey-liveness when installed
}
// 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;
}listPasskeys(options): Promise<ListPasskeysResult>
Retrieve all registered passkeys for a user.
⚠️ Authentication Required: User must be authenticated before calling this function.
interface ListPasskeysOptions {
userId: string; // Required: User ID to list passkeys for
limit?: number; // Optional: Maximum number of passkeys to return
offset?: number; // Optional: Offset for pagination
}
interface ListPasskeysResult {
data: {
passkeys: Array<{
id: string;
userId: string;
credentialId: string;
platform: string;
lastUsed: string;
status: "active" | "revoked";
createdAt: string;
metadata: Record<string, unknown>;
}>;
nextOffset?: number;
} | null;
error: Error | null;
}revokePasskey(options): Promise<RevokePasskeyResult>
Revoke a passkey, preventing it from being used for authentication.
⚠️ Authentication Required: User must be authenticated before calling this function. The server validates ownership from the active session.
🔐 Security Note: As of v0.3.0, userId is no longer accepted from the client for security. The server validates that the user owns the passkey being revoked.
interface RevokePasskeyOptions {
credentialId: string; // Required: Credential ID to revoke
reason?: string; // Optional: Reason for revocation
}
interface RevokePasskeyResult {
data: { success: boolean } | null;
error: Error | null;
}Example:
// Revoke a passkey
const result = await revokePasskey({
credentialId: "credential-id-123",
reason: "User requested removal"
});
if (result.data?.success) {
console.log("Passkey revoked successfully");
}Platform Detection Functions
// 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();
}Cross-Platform Usage
Separate Frontend Applications
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!");
}Cross-Platform Passkey Syncing
The plugin automatically supports cross-platform credential usage:
iCloud Keychain Flow
// 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
});Hardware Key Flow
// 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
});Platform-Specific Features
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();
}Unified Passkey Management
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
Client Preferences
Security Level Control
Control the security requirements for your passkeys:
High Security (Enterprise)
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
});Convenient (Consumer)
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
});Cross-Platform (Hardware Keys)
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
},
});Adaptive Security
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,
});Database Schema
The plugin uses a unified table structure that works seamlessly across all platforms.
authPasskey Table
| 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 |
passkeyChallenge Table
| 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 |
Custom Schema Configuration
You can customize the database table names to fit your existing database structure or naming conventions:
Basic Configuration
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
}
}
})
]
});Default Table Names
If no custom schema is provided, the plugin uses these default table names:
- Passkeys:
authPasskey - Challenges:
passkeyChallenge
Database Optimizations
Optimizing database performance is essential to get the best out of the Expo Passkey plugin.
Recommended Fields to Index
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.
Troubleshooting
Web Issues
- 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 Issues
- 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
Android Issues
- 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
Universal App Issues
- 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
Client Preference Issues
- 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
Security Considerations
Session-Based Validation (v0.3.0+)
Critical Security Enhancement: The plugin now validates userId from the authenticated session instead of accepting it from client requests. This prevents account takeover attacks where malicious clients could register passkeys for other users.
- Registration: Requires active session. Server extracts userId from session token, not client request.
- Revocation: Requires active session. Server validates ownership before allowing revocation.
- Authentication: Does not require session (users authenticate to create a session).
Migration from v0.2.x:
// Before v0.3.0 (VULNERABLE - don't use)
await revokePasskey({ userId: "user-123", credentialId: "cred-123" });
// After v0.3.0 (SECURE)
await revokePasskey({ credentialId: "cred-123" });
// userId is automatically validated from the sessionAdditional Security Measures
- Client Preference Enforcement: Server 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
- Relying Party ID: Configure
rpIdcorrectly to prevent cross-domain attacks - Liveness Tokens: If you use
expo-passkey-liveness, configure replay protection in production and keeprpIdidentical between both plugins - 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
Error Handling
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);
}Example Project
The current reference app lives in
epk-example-app. It is a
two-workspace monorepo:
apps/web: Next.js 15, Better Auth, Prisma, email OTP fallback, browser passkeys, liveness enforcement, debug routes, and Vercel deployment shapeapps/mobile: Expo SDK 55 dev-client app using@better-auth/expo,expo-passkey/native,expo-passkey-liveness/native, and the same backend
The web app uses a demo customProvider that auto-passes liveness so you can
exercise the server pipeline without third-party credentials. The mobile app is
the place to test the native camera ceremony after configuring a native
provider adapter such as Rekognition or iProov.
License
MIT
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
