---
title: Passkeys
description: WebAuthn passkey sign-in and registration with convex-auth.
sidebar:
  order: 13
---

`convex-auth` supports WebAuthn passkeys for passwordless sign-in and registration.

## Enable

```ts convex/auth.ts
import { components } from "./_generated/api";
import { convexAuth } from "@vortex-api/convex-auth/convex";

export const auth = convexAuth({
  component: components.convexAuth,
  passkey: {
    rpID: "example.com",
    // one origin, or every origin the app is served from
    origin: ["https://example.com", "https://app.example.com"],
    rpName: "Example",
    // optional: cap active passkeys per user (default 10)
    maxPasskeysPerUser: 10,
    // optional WebAuthn policy knobs
    attestationType: "none", // "none" | "direct" | "enterprise"
    userVerification: "required", // the default — enforces UV at verify time
    residentKey: "preferred",
    authenticatorAttachment: undefined, // "platform" | "cross-platform"
    // optional session lifetimes for passkey sign-ins (match native auth defaults)
    sessionTtlMs: 7 * 24 * 60 * 60 * 1000,
    refreshTokenTtlMs: 30 * 24 * 60 * 60 * 1000,
  },
});
```

`rpID` must match the registrable domain users authenticate from. `origin` accepts a single origin or an array — every origin must be the exact origin the platform reports (browser origins and `android:apk-key-hash:<hash>` for native Android). `maxPasskeysPerUser` caps active (non-revoked) passkeys per user, enforced at both options and verify time. `userVerification` defaults to `"required"` — verification rejects assertions without the UV flag unless you explicitly set `"preferred"` or `"discouraged"`. Origin and rpID are always taken from this server config; clients cannot override them at verify time.

Two interactions worth knowing: a UV-verified ceremony already proves possession plus user verification, so passkey sign-in satisfies 2FA on its own — but if you relax `userVerification` and the account has TOTP 2FA enabled, `signIn` resolves to the standard `twoFactorRedirect` challenge instead of a session. And revoking a passkey revokes every session that passkey created, not just the credential.

## React

Use the `usePasskeys` hook or the `PasskeyManager` component.

### `usePasskeys`

```tsx src/Passkeys.tsx
import { usePasskeys, PasskeyManager } from "@vortex-api/convex-auth/react";
import { useAuth } from "@vortex-api/convex-auth/react";

export function Passkeys() {
  const { user } = useAuth();
  const passkeys = usePasskeys({
    userId: user.id,
    identifier: user.email ?? "",
  });

  return (
    <PasskeyManager
      rpName="Example"
      passkeys={passkeys.passkeys}
      loading={passkeys.loading}
      onRegister={({ name }) => passkeys.register(name)}
      onVerifyRegistration={({ name, response }) => passkeys.register(name)}
      onAuthenticate={() => passkeys.signIn()}
      onVerifyAuthentication={({ response }) => passkeys.signIn()}
      onRevoke={({ credentialId }) => passkeys.revoke(credentialId)}
      onRename={({ credentialId, name }) => passkeys.rename(credentialId, name)}
    />
  );
}
```

`usePasskeys` handles the WebAuthn browser calls (`startRegistration` / `startAuthentication`) and the Convex mutations. `register`, `revoke`, `rename`, and `passkeys` operate on the signed-in user only — passkey management requires an authenticated session.

### Conditional UI (autofill)

Pass `autofill: true` to `usePasskeys` and add `autocomplete="username webauthn"` to your username input — the browser offers passkeys inside the autofill sheet instead of a modal:

```tsx
const passkeys = usePasskeys({ autofill: true });

<input autoComplete="username webauthn" ... />
await passkeys.signIn(); // resolves when the user picks a passkey from autofill
```

### `PasskeyManager`

```tsx src/Passkeys.tsx
import { PasskeyManager } from "@vortex-api/convex-auth/react";
import { useAuth } from "@vortex-api/convex-auth/react";

export function Passkeys() {
  const { user } = useAuth();

  return (
    <PasskeyManager
      rpName="Example"
      passkeys={[]}
      onRegister={async ({ name }) => {
        // generate and return options from your own wiring
      }}
      onVerifyRegistration={async ({ name, response }) => {
        // verify with your own wiring
      }}
      onAuthenticate={async () => {
        // generate and return auth options
      }}
      onVerifyAuthentication={async ({ response }) => {
        // verify with your own wiring
      }}
      onRevoke={async ({ credentialId }) => {
        // revoke the credential
      }}
      onRename={async ({ credentialId, name }) => {
        // optional: rename the credential
      }}
    />
  );
}
```

`PasskeyManager` is presentational: it prompts the browser for WebAuthn and invokes the callbacks you provide.

## Runtime actions

When `passkey` is configured, `auth` exposes:

- `auth.getPasskeyRegistrationOptions`
- `auth.verifyPasskeyRegistration`
- `auth.getPasskeyAuthenticationOptions`
- `auth.verifyPasskeyAuthentication`
- `auth.listPasskeys`
- `auth.revokePasskey`
- `auth.renamePasskey`

Use `useAuthActions()` to call them from React. Registration, listing, revoking, and renaming are bound to `ctx.auth.getUserIdentity()` — a caller can only manage their own passkeys. Authentication challenges generated for a specific user can only be completed by a credential belonging to that user, and a passkey whose signature counter regresses is revoked automatically (cloned-authenticator detection). All lifecycle events write `auth_audit_events` rows (`passkey_registered`, `passkey_authenticated`, `passkey_renamed`, `passkey_revoked`, `passkey_counter_regression`).

## React Native / Expo

Native passkey ceremonies run through the optional [`react-native-passkeys`](https://github.com/peterferguson/react-native-passkeys) peer dependency (ASAuthorizationController on iOS, Credential Manager on Android). Install it explicitly and import the hook from the dedicated subpath so apps that don't use passkeys never bundle it:

```sh
pnpm add react-native-passkeys
```

```tsx
import { usePasskeys } from "@vortex-api/convex-auth/react-native/passkeys";

const passkeys = usePasskeys({
  userId: user.id, // for management + registration
  identifier: user.email ?? "",
});

await passkeys.register("My iPhone");
await passkeys.signIn();
```

The hook exposes the same surface as web — `register`, `signIn`, `revoke`, `rename`, `passkeys`, `loading`, `error`, plus `supported` (false on devices without passkey capability). A user cancelling the ceremony rejects with a "cancelled" error (normalized across iOS's thrown cancellation and Android's `null` return). Server verification is identical to web: the native responses are SimpleWebAuthn-compatible JSON verified by the same actions, against the `rpID`/`origin` from `convexAuth` config — for Android include `android:apk-key-hash:<base64url-sha256-of-signing-cert>` in `passkey.origin`.

### iOS

Two sides to wire:

1. **Entitlement** — add the associated domain for your `rpID` in `app.json`/`app.config.ts`:

```json
{ "ios": { "associatedDomains": ["webcredentials:example.com"] } }
```

2. **AASA file** — serve `application/json` at `https://<rpID>/.well-known/apple-app-site-association`:

```json
{ "webcredentials": { "apps": ["TEAMID.com.example.app"] } }
```

You can host this on your own domain, or on the Convex site deployment itself — `examples/react-native/convex/http.ts` serves it from the `PASSKEY_IOS_APP_ID` env var, which lets `rpID` be `<deployment>.convex.site` with no external website. The `appID` prefix must be the **signing** team ID of the built app — check `codesign -dvvv` on the produced binary, not the team that happens to own the developer account. Passkeys require a development build; they do not work in Expo Go.

**Simulator caveat:** the iOS Simulator only fetches the AASA file when the entitlement carries `?mode=developer`, and Face ID simulation requires a recent runtime (verified working on iOS 26.x; older runtimes may lack biometric simulation). The example's `app.config.js` adds both `webcredentials:<rpID>` and `webcredentials:<rpID>?mode=developer` automatically.

### Android

Serve `https://<rpID>/.well-known/assetlinks.json` — again self-hostable or via the example's `http.ts` (`PASSKEY_ANDROID_PACKAGE` + `PASSKEY_ANDROID_SHA256_CERT_FINGERPRINT` env vars):

```json
[
  {
    "relation": [
      "delegate_permission/common.handle_all_urls",
      "delegate_permission/common.get_login_creds"
    ],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.app",
      "sha256_cert_fingerprints": ["AA:BB:CC:..."]
    }
  }
]
```

`common.get_login_creds` is required for Credential Manager passkey flows — `handle_all_urls` alone is silently insufficient.

Get the fingerprint from your signing cert:

```sh
keytool -list -v -keystore <keystore> | rg "SHA256:"
```

The assertion's `origin` arrives as `android:apk-key-hash:<base64url-sha256-of-cert>` — a base64url encoding of the raw SHA-256 bytes, **not** the colon-separated hex fingerprint. Derive it from the fingerprint with:

```sh
echo "AA:BB:CC:..." | tr -d ':' | xxd -r -p | base64 | tr '+/' '-_' | tr -d '='
# → android:apk-key-hash:<output>
```

Include it in `passkey.origin` (array form) alongside the web origin. Android caches the assetlinks lookup for up to 24 hours — reinstall or wait after changing it.

**Emulator caveat:** an Android emulator needs a screen lock and an enabled credential provider before Credential Manager offers passkeys. Google's built-in provider only appears once a Google account is signed in — on a bare emulator nothing does. For local validation, install a test provider such as the [Android Identity `MyVault` sample](https://github.com/android/identity-samples) and enable it under Settings → Security → More settings → Passwords, passkeys & accounts. A physical device with Google Play services needs none of this.
