Expo Passkey
Expo Passkey is a cross-platform (iOS, Android, Web) module designed for Expo applications, providing comprehensive FIDO2/WebAuthn passkey authentication. It seamlessly integrates with the Better Auth ecosystem, offering an end-to-end solution for modern, frictionless biometric and platform authenticator-based login experiences. Currently at version 0.3.12, the package maintains an active development pace, demonstrated by recent critical security patches and breaking changes introduced in October 2025. Key differentiators include its unified passkey table structure that operates consistently across all supported platforms, seamless synchronization with iCloud Keychain and Google Password Manager for cross-device portability, and granular client-controlled WebAuthn security preferences, all built upon a robust framework emphasizing session-validated security for critical operations like registration and revocation.
Common errors
-
401 Unauthorized error when attempting passkey authentication, even for unauthenticated users.
cause The backend's authentication challenge endpoint is incorrectly requiring an authenticated session before `v0.3.6`.fixUpgrade `expo-passkey` to `v0.3.6` or later, and ensure your server-side endpoint for initiating passkey authentication does not enforce a prior user session. -
Security vulnerabilities or 'Invalid userId' warnings reported for passkey registration/revocation.
cause Server-side code is accepting `userId` from client requests for sensitive operations, making it susceptible to manipulation (pre-`v0.3.0`).fixUpgrade `expo-passkey` to `v0.3.0` or higher. On the backend, always obtain `userId` for passkey registration and revocation from the authenticated user's session, ignoring any `userId` sent from the client. -
TypeError: revokePasskey is not a function or Argument of type '{ userId: string; credentialId: string; }' is not assignable to parameter of type '{ credentialId: string; }'.cause Attempting to pass the `userId` parameter to `revokePasskey` after it was removed in `v0.3.0`.fixRemove the `userId` parameter from `revokePasskey` calls in your client-side code. Ensure an authenticated session is established before calling `revokePasskey`. -
Module not found: Can't resolve '@better-auth/expo' in '...' or similar peer dependency resolution errors.
cause A required peer dependency, such as `@better-auth/expo`, `expo-secure-store`, or `@simplewebauthn/server`, is either not installed or its version is incompatible.fixInstall all specified peer dependencies manually using your package manager (e.g., `npm install @better-auth/expo expo-secure-store @simplewebauthn/server`). Check the `package.json` for exact version requirements and ensure compatibility.
Warnings
- breaking The `revokePasskey()` function no longer accepts a `userId` parameter. Additionally, both registration and revocation operations now strictly require an authenticated session on your backend. Attempting these operations without a valid session will result in a `401 Unauthorized` error.
- breaking A critical account takeover vulnerability was present in versions prior to `v0.3.0`. The server was validating the `userId` from client requests for critical operations, which could be manipulated. This has been patched to enforce server-side validation of `userId` exclusively from the authenticated session.
- gotcha In versions prior to `v0.3.6`, authentication challenge endpoints sometimes incorrectly failed with a `401 Unauthorized` status for unauthenticated users. This bug prevented a smooth login flow where a user might initiate authentication without being logged in first.
- gotcha `expo-passkey` relies on a substantial list of peer dependencies, including core Expo modules (e.g., `expo-secure-store`, `expo-local-authentication`), `better-auth` components, `@simplewebauthn/server` (for server integration), and `zod`. Missing or incompatible versions of these dependencies can lead to runtime errors, build failures, or unexpected behavior.
Install
-
npm install expo-passkey -
yarn add expo-passkey -
pnpm add expo-passkey
Imports
- registerPasskey, authenticatePasskey, revokePasskey
const { registerPasskey } = require('expo-passkey');import { registerPasskey, authenticatePasskey, revokePasskey } from 'expo-passkey'; - PasskeyStatus
import PasskeyStatus from 'expo-passkey';
import { PasskeyStatus } from 'expo-passkey'; - RegisterPasskeyOptions, AuthenticatePasskeyOptions
import { RegisterPasskeyOptions, AuthenticatePasskeyOptions } from 'expo-passkey';import type { RegisterPasskeyOptions, AuthenticatePasskeyOptions } from 'expo-passkey';
Quickstart
import { registerPasskey, authenticatePasskey, PasskeyStatus } from 'expo-passkey';
import * as WebBrowser from 'expo-web-browser';
import { Platform } from 'react-native';
const API_BASE_URL = 'https://your-backend.com/api'; // Replace with your backend URL
async function handlePasskeyRegistration(userId: string) {
try {
console.log('Initiating passkey registration...');
const response = await fetch(`${API_BASE_URL}/passkey/register/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId }), // Send userId to backend for challenge generation
});
const { challengeOptions } = await response.json();
const registrationResult = await registerPasskey({
challenge: challengeOptions,
openWebBrowserAsync: Platform.OS === 'web' ? WebBrowser.openBrowserAsync : undefined,
});
const verificationResponse = await fetch(`${API_BASE_URL}/passkey/register/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(registrationResult),
});
if (verificationResponse.ok) {
console.log('Passkey registered successfully!');
return true;
} else {
const error = await verificationResponse.json();
console.error('Passkey registration failed on server:', error);
return false;
}
} catch (error) {
console.error('Error during passkey registration:', error);
return false;
}
}
async function handlePasskeyAuthentication() {
try {
console.log('Initiating passkey authentication...');
const response = await fetch(`${API_BASE_URL}/passkey/authenticate/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// No userId sent for auth challenge since v0.3.6
});
const { challengeOptions } = await response.json();
const authenticationResult = await authenticatePasskey({
challenge: challengeOptions,
openWebBrowserAsync: Platform.OS === 'web' ? WebBrowser.openBrowserAsync : undefined,
});
const verificationResponse = await fetch(`${API_BASE_URL}/passkey/authenticate/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(authenticationResult),
});
if (verificationResponse.ok) {
const { user } = await verificationResponse.json(); // Backend might return user info
console.log('Passkey authenticated successfully for user:', user?.id || 'unknown');
return user;
} else {
const error = await verificationResponse.json();
console.error('Passkey authentication failed on server:', error);
return null;
}
} catch (error) {
console.error('Error during passkey authentication:', error);
return null;
}
}
// Example of checking passkey support (e.g., in a useEffect hook)
// async function checkPasskeySupport() {
// const supported = await PasskeyStatus.isSupported();
// console.log('Passkey authentication supported:', supported);
// }
// checkPasskeySupport();