Passkey
The passkey component lets you implement passkey-based login.
A passkey replaces the password with a key pair that stays on the user's device: users can store the passkey on their device (protected by a fingerprint, a face scan, or PIN), or use an external hardware security key. Your app never handles a secret that can be phished or reused.
The usernamePasskey method implements an identifier-first flow:
your login page has a single username field. A free username creates a new
account with a new passkey; an existing username asks for a passkey of that
account.
Setting up username + passkey login in your app
- Follow the “Getting Started” guide
First, install Convex Auth and set up the core as described in the Getting Started guide.
- Install the components
Register the passkey component and the username component alongside the core in
convex/convex.config.ts. The username component stores which user each username identifies:convex/convex.config.tsimport { defineApp } from "convex/server";import auth from "@convex-dev/auth/convex.config.js";import authPasskey from "@convex-dev/auth/components/passkey/convex.config.js";import authUsername from "@convex-dev/auth/components/username/convex.config.js";const app = defineApp();app.use(auth);app.use(authPasskey);app.use(authUsername);export default app; - Add a
userstable to the schemaAdd an empty
userstable to your schema inconvex/schema.ts. The username component keeps the username, so the table does not need a field for it.Convex Auth will create a row for each account. You can also add application-specific fields to this table if necessary.
convex/schema.tsimport { defineSchema, defineTable } from "convex/server";export default defineSchema({users: defineTable({}),}); - Set up user creation
Write a
createUsermutation that creates a row in youruserstable and returns its id. The method calls it the first time it sees an account.You can also attach an optional
onSignInmutation, called with the resolveduserIdon every sign-in, the first one included. Anything you want to happen each time someone signs in belongs there, socreateUseronly has to handle what is true at creation time. Leave it out if there is nothing to do.convex/users.tsimport { internalMutation } from "./_generated/server";import { v } from "convex/values";/*** Create the user row for a new passkey account and return its id. This* example keeps no data in the row, but your app can put a profile here.*/export const createUser = internalMutation({args: {provider: v.object({name: v.literal("passkey"),accountId: v.string(),profile: v.object({}),}),},returns: v.id("users"),handler: async (ctx) => {return await ctx.db.insert("users", {});},}); - Set up the public backend functions
Set up the
usernamePasskeymethod withsetupUsernamePasskeyand export the mutations it hands back once you attach the callback that creates your user rows, inconvex/auth.ts.rpIdandoriginbind the passkeys to your app:rpIdis the relying party ID: the domain the passkeys belong to, without a scheme or a port (localhostin development,example.comin production). Browsers only offer a passkey to a page whose domain is therpIdor a subdomain of it.originis the full origin the login page is served from, including the scheme and the port (http://localhost:5173for the Vite dev server). The component rejects a passkey sign-in that comes from any other origin.
warningPasskeys are permanently bound to their
rpId: a passkey created withrpId: "localhost"can never be used on your production domain. Set both values to the production domain before you deploy.convex/auth.tsimport { components, internal } from "./_generated/api";import { setupConvexAuth } from "@convex-dev/auth/server";import { setupUsernamePasskey } from "@convex-dev/auth/methods/username-passkey/server";const auth = setupConvexAuth({ component: components.auth });export const { signOut, refreshSession, isAuthenticated } = auth;export const {startSignInWithUsernamePasskey,startAutofillSignInWithUsernamePasskey,finishSignUpWithUsernamePasskey,finishSignInWithUsernamePasskey,} = setupUsernamePasskey(auth, {component: components.authPasskey,usernameComponent: components.authUsername,// The relying party ID and the origin of the Vite dev server. A deployed// app uses its own domain here.rpId: "localhost",origin: "http://localhost:5173",}).attachUserCallbacks({ createUser: internal.users.createUser }); - Set up the provider on the client
Wrap your app in
ConvexAuthProvider, passing it therefreshSessionandsignOutmutations and theisAuthenticatedquery insrc/main.tsx:src/main.tsximport { ConvexAuthProvider } from "@convex-dev/auth/react";import { ConvexReactClient } from "convex/react";import { StrictMode } from "react";import { createRoot } from "react-dom/client";import { BrowserRouter } from "react-router-dom";import { api } from "../convex/_generated/api";import { App } from "./App";const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL!);createRoot(document.getElementById("root")!).render(<StrictMode><ConvexAuthProviderclient={convex}api={{refreshSession: api.auth.refreshSession,signOut: api.auth.signOut,isAuthenticated: api.auth.isAuthenticated,}}><BrowserRouter><App /></BrowserRouter></ConvexAuthProvider></StrictMode>,); - Set up the authentication UI
In your front-end, set up a route for logging in. Because the flow is identifier-first, the same page and the same field serve account creation and login.
While the hook is mounted, it also lets the browser offer the passkeys it has stored in the autocompletion list of the username field. The field must carry
autoComplete="username webauthn"for the browser to do that.warningThe browser allows only one of these autofill requests at a time, so you should not mount the
useSignInWithUsernamePasskeyhook multiple times simultaneously.Copy the following code block inside of your app:
src/routes/logIn.tsximport {UsernamePasskeyAutofillError,SignInWithUsernamePasskeyResult,useSignInWithUsernamePasskey,} from "@convex-dev/auth/methods/username-passkey/react";import { useEffect, useState } from "react";import { api } from "../../convex/_generated/api";export function LogIn() {// While this hook is mounted, the browser also offers stored passkeys in// the autocompletion list of the username field below (the field carries// autoComplete="username webauthn"). Picking one signs in directly.const { signIn, pending, autofill } = useSignInWithUsernamePasskey(api.auth);const [username, setUsername] = useState("");const [error, setError] = useState<string | null>(null);// Show autofill failures in the same error area as the modal flow. The// most recent failure from either flow wins.const { lastError } = autofill;useEffect(() => {if (lastError !== null) {setError(errorMessage(lastError));}}, [lastError]);return (<formonSubmit={async (e) => {e.preventDefault();setError(null);const result = await signIn({ username });if (result.status === "complete") {return;}setError(errorMessage(result.userError));}}><h1>Log in</h1><p>Enter your username. A free username creates a new account with apasskey; an existing username asks for its passkey.</p><label>Username<inputtype="text"value={username}onChange={(e) => setUsername(e.target.value)}autoComplete="username webauthn"requireddisabled={pending}/></label>{error ? (<p role="alert"><strong>{error}</strong></p>) : null}<button type="submit" disabled={pending}>{pending ? "Waiting for your passkey…" : "Continue with a passkey"}</button></form>);}function errorMessage(userError:| Extract<SignInWithUsernamePasskeyResult, { status: "error" }>["userError"]| UsernamePasskeyAutofillError,): string | null {switch (userError.error) {case "ALREADY_PENDING":// A second submit while the first attempt still runs. That attempt// keeps its passkey dialog, so there is nothing to tell the user.return null;case "USERNAME_TOO_SHORT":return `The username must be at least ${userError.minimumLength} characters.`;case "USERNAME_TOO_LONG":return `The username must be at most ${userError.maximumLength} characters.`;case "USERNAME_HAS_SURROUNDING_WHITESPACE":return "The username can't start or end with whitespace.";case "USERNAME_HAS_INVALID_CHARACTERS":return "The username contains characters that aren't allowed.";case "USERNAME_TAKEN":// Only returned when someone claimed the username between the two// steps of the ceremony.return "That username was just taken. Try another one.";case "CHALLENGE_EXPIRED":return "The sign-in attempt took too long. Please try again.";case "UNKNOWN_CREDENTIAL":return "This passkey is not registered here.";case "PROTOCOL_ERROR":// The browser sent something that violates the protocol.// This might be caused by a misbehaving client, or by a configuration error.// The Convex logs contain more information about the source of the error.return "This passkey request could not be verified. Please try again, or contact support if the problem persists.";case "PASSKEY_ALREADY_REGISTERED":// The authenticator refused to make a second passkey (usually// because `excludeCredentials` names one it already holds). The// message stays account-neutral: this switch also serves the sign-up// flow, where no account exists yet.return "This device already holds a passkey for this site. Try signing in with it instead.";case "CEREMONY_ABORTED":// The most common failure: the user closed the passkey dialog.return "Sign-in was cancelled. Please try again.";case "WEBAUTHN_UNSUPPORTED":return "This browser does not support passkeys.";case "OTHER_ERROR":// The mutation threw unexpectedly; the original error is available// on `cause` if you want to log or inspect it.console.error("Sign-in failed:", userError.cause);return "Something went wrong. Please try again.";default:userError satisfies never;return `Unknown error: ${JSON.stringify(userError)}`;}}
Going further
Allowing users to manage their passkeys
When your app supports passkeys, it is highly recommended to allow users to add multiple passkeys. This is especially useful for users who use multiple devices, or hardware authenticators.
- Make sure the Convex functions for passkey managements are exposed
In
convex/auth.ts, make sure the following functions are exported:listPasskeys,renamePasskey,startAddPasskey,verifyAddPasskey,finishAddPasskey,startRemovePasskey, andfinishRemovePasskey.convex/auth.tsimport { components, internal } from "./_generated/api";import { setupConvexAuth } from "@convex-dev/auth/server";import { setupUsernamePasskey } from "@convex-dev/auth/methods/username-passkey/server";const auth = setupConvexAuth({ component: components.auth });export const { signOut, refreshSession, isAuthenticated } = auth;export const {startSignInWithUsernamePasskey,startAutofillSignInWithUsernamePasskey,finishSignUpWithUsernamePasskey,finishSignInWithUsernamePasskey,listPasskeys,renamePasskey,startAddPasskey,verifyAddPasskey,finishAddPasskey,startRemovePasskey,finishRemovePasskey,} = setupUsernamePasskey(auth, {component: components.authPasskey,usernameComponent: components.authUsername,// The relying party ID and the origin of the Vite dev server. A deployed// app uses its own domain here.rpId: "localhost",origin: "http://localhost:5173",}).attachUserCallbacks({ createUser: internal.users.createUser }); - Build the passkey management page
In your app’s settings page, add the UI code for adding, renaming, and removing passkeys. This code uses the
useAddPasskeyanduseRemovePasskeyhooks provided by Convex Auth, and therenamePasskeymutation.We recommend copying the following component into your app, adapting it to your app’s design system, and rendering it on your settings page:
src/PasskeySettings.tsximport {AddPasskeyResult,RemovePasskeyResult,RenamePasskeyResult,useAddPasskey,useRemovePasskey,} from "@convex-dev/auth/methods/username-passkey/react";import { useMutation, useQuery } from "convex/react";import { useState } from "react";import { api } from "../convex/_generated/api";export function PasskeySettings() {const query = useQuery(api.auth.listPasskeys, {});const { addPasskey, pending: adding } = useAddPasskey(api.auth);const [error, setError] = useState<string | null>(null);if (query !== undefined && !query.success) {query.userError.error satisfies "NOT_SIGNED_IN";return <p role="alert">You are not signed in.</p>;}if (query === undefined) {return <p>Loading your passkeys…</p>;}const passkeys = query.passkeys;// A different passkey must authorize each removal, thus a user with one// passkey must add a second one before they can remove either.const canRemove = passkeys.length > 1;return (<section><h2>Your passkeys</h2><ul>{passkeys.map((passkey) => (<PasskeyRowkey={passkey.passkeyId}passkey={passkey}canRemove={canRemove}onError={setError}/>))}</ul>{error ? (<p role="alert"><strong>{error}</strong></p>) : null}<buttondisabled={adding}onClick={async () => {setError(null);const result = await addPasskey();if (!result.success) {setError(errorMessage(result.userError));}}}>Add a passkey</button></section>);}function PasskeyRow({passkey,canRemove,onError,}: {passkey: {name?: string | undefined;passkeyId: string;createdAt: number;};canRemove: boolean;onError: (message: string | null) => void;}) {// Each row has its own remove hook, so `pending` drives only the spinner// of this row.const { removePasskey, pending: removing } = useRemovePasskey(api.auth);const renamePasskey = useMutation(api.auth.renamePasskey);return (<li><em>{passkey.name ?? "Unnamed passkey"}</em>, added{" "}{new Date(passkey.createdAt).toLocaleString()}{" "}<buttononClick={async () => {const name = window.prompt("New name for this passkey?");if (name === null) {return;}onError(null);try {const result = await renamePasskey({passkeyId: passkey.passkeyId,name,});if (!result.success) {onError(errorMessage(result.userError));}} catch (cause) {console.error("Passkey rename failed:", cause);onError("Something went wrong. Please try again.");}}}>Rename</button>{" "}<buttondisabled={removing || !canRemove}onClick={async () => {if (!window.confirm("Remove this passkey?")) {return;}onError(null);const result = await removePasskey(passkey.passkeyId);if (!result.success) {onError(errorMessage(result.userError));}}}>{removing ? "Waiting for your passkey…" : "Remove"}</button></li>);}function errorMessage(userError:| Extract<AddPasskeyResult, { success: false }>["userError"]| Extract<RemovePasskeyResult, { success: false }>["userError"]| Extract<RenamePasskeyResult, { success: false }>["userError"],): string | null {switch (userError.error) {case "CEREMONY_ABORTED":return "The passkey dialog was closed.";case "ALREADY_PENDING":// A second submit while the first attempt still runs. That attempt// keeps its passkey dialog, so there is nothing to tell the user.return null;case "PASSKEY_ALREADY_REGISTERED":return "This device already has a passkey for your account.";case "PROTOCOL_ERROR":// The browser sent something that violates the protocol.// This might be caused by a misbehaving client, or by a configuration error.// The Convex logs contain more information about the source of the error.return "This passkey request could not be verified. Please try again, or contact support if the problem persists.";case "LAST_PASSKEY":return "You can’t remove your only passkey. Add another one first.";case "TOO_MANY_PASSKEYS":return "You have too many passkeys. Remove one before you add another.";case "PASSKEY_NOT_FOUND":return "This passkey no longer exists.";case "INVALID_NAME":return "A passkey name must be 1 to 50 characters on one line.";case "CHALLENGE_EXPIRED":return "The passkey dialog stayed open for too long. Please try again.";case "UNKNOWN_CREDENTIAL":return "This passkey is not registered here.";case "NOT_SIGNED_IN":return "Your session has ended. Please log in again.";case "WEBAUTHN_UNSUPPORTED":return "This browser does not support passkeys.";case "OTHER_ERROR":// The mutation threw unexpectedly; the original error is available// on `cause` if you want to log or inspect it.console.error("Passkey management failed:", userError.cause);return "Something went wrong. Please try again.";default:userError satisfies never;return `Unknown error: ${JSON.stringify(userError)}`;}}