Skip to content
v3.0.1 — MCP OAuth, organizations, passkeys, SSR adaptersChangelog
Convex Auth
Esc
↑↓navigate↵open⌘Jpreview
On this page

Passkeys

WebAuthn passkey sign-in and registration with convex-auth.

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

Enable

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

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:

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

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

PasskeyManager

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 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:

pnpm add react-native-passkeys
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:
{ "ios": { "associatedDomains": ["webcredentials:example.com"] } }
  1. AASA file — serve application/json at https://<rpID>/.well-known/apple-app-site-association:
{ "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):

[
  {
    "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:

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:

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 and enable it under Settings → Security → More settings → Passwords, passkeys & accounts. A physical device with Google Play services needs none of this.

Was this page helpful?