Skip to content

Passkeys

Passkey authentication uses the WebAuthn standard to let users sign in with biometrics (Touch ID, Face ID) or hardware security keys. It's the most secure sign-in method – no shared secrets leave the device.

INFO

Passkeys are available for login only. Users must first create an account via email OTP or Google OAuth, then register a passkey from their account settings. The sign-up form does not show the passkey option.

Server Configuration

The passkey plugin is configured in apps/api/lib/auth.ts:

ts
passkey({
  rpID,         // Domain name (e.g., "example.com" or "localhost")
  rpName: env.APP_NAME,  // Human-readable name shown in browser prompts
  origin: env.APP_ORIGIN,
}),

The rpID (Relying Party ID) is extracted from APP_ORIGIN:

ts
const appUrl = new URL(env.APP_ORIGIN);
const rpID = appUrl.hostname;

This means passkeys are bound to the domain – a passkey registered on example.com won't work on staging.example.com. The rpName appears in the browser's passkey dialog (e.g., "Sign in to My App").

Database Table

Passkey credentials are stored in db/schema/passkey.ts:

ColumnDescription
publicKeyWebAuthn public key
credentialIDUnique credential identifier
counterSignature counter (replay protection)
deviceType"singleDevice" or "multiDevice"
backedUpWhether the credential is synced across devices
transportsCommunication methods (USB, BLE, NFC, internal)
deviceNameUser-friendly label (e.g., "MacBook Pro")
platform"platform" (built-in) or "cross-platform" (USB key)

Client Component

The PasskeyLogin component in apps/app/components/auth/passkey-login.tsx handles two modes:

Explicit Login

When the user clicks "Log in with passkey", the component checks for WebAuthn support and triggers the browser's credential picker:

ts
const handlePasskeyLogin = async () => {
  if (!window.PublicKeyCredential) {
    onError(authConfig.errors.passkeyNotSupported);
    return;
  }

  const result = await auth.signIn.passkey();

  if (result.data) {
    onSuccess();
  } else if (result.error) {
    const errorCode = "code" in result.error ? result.error.code : undefined;
    if (errorCode === "AUTH_CANCELLED") {
      onError("Passkey authentication was cancelled.");
    } else {
      onError(result.error.message || authConfig.errors.genericError);
    }
  }
};

Conditional UI (Autofill)

When enabled, passkey autofill shows saved credentials in the browser's autocomplete dropdown – similar to how password managers work. This runs passively on mount:

ts
useEffect(() => {
  if (!authConfig.passkey.enableConditionalUI) return;

  const setupConditionalUI = async () => {
    if (!window.PublicKeyCredential?.isConditionalMediationAvailable) return;

    const isAvailable =
      await window.PublicKeyCredential.isConditionalMediationAvailable();
    if (!isAvailable) return;

    const result = await auth.signIn.passkey({ autoFill: true });
    if (result.data && !aborted) {
      onSuccessRef.current();
    }
  };

  setupConditionalUI();
}, []);

Conditional UI is controlled by the authConfig.passkey.enableConditionalUI flag (default: true). Errors from conditional UI are silently ignored since the user hasn't explicitly requested authentication.

Client Configuration

Passkey behavior is configured in apps/app/lib/auth-config.ts:

ts
passkey: {
  enableConditionalUI: true,
  timeout: 60_000,          // 60 seconds for user interaction
  userVerification: "preferred",
},
SettingDefaultDescription
enableConditionalUItrueShow passkeys in browser autocomplete
timeout60000Max time (ms) for user to interact with the WebAuthn dialog
userVerification"preferred"Request biometric/PIN when available, but don't require it

Error Handling

ErrorCauseBehavior
AUTH_CANCELLEDUser dismissed the WebAuthn prompt or it timed outShows cancellation message
passkeyNotSupportedwindow.PublicKeyCredential is undefinedShows browser support message
Network errorOffline or DNS failureShows network error message
Server errorNo passkey found, invalid credentialShows server error message

Browser Support

Passkeys require WebAuthn support. All modern browsers support it:

  • Chrome 67+, Edge 18+, Firefox 60+, Safari 13+
  • iOS 16+ (synced via iCloud Keychain)
  • Android 9+ (synced via Google Password Manager)

The component checks window.PublicKeyCredential before attempting authentication and shows a clear message on unsupported browsers.