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.getPasskeyRegistrationOptionsauth.verifyPasskeyRegistrationauth.getPasskeyAuthenticationOptionsauth.verifyPasskeyAuthenticationauth.listPasskeysauth.revokePasskeyauth.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:
- Entitlement — add the associated domain for your
rpIDinapp.json/app.config.ts:
{ "ios": { "associatedDomains": ["webcredentials:example.com"] } }
- AASA file — serve
application/jsonathttps://<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.