Username + passkey
For AI agents: see llms.txt for the complete documentation index. Markdown versions are available by adding .md to a page URL or requesting Accept: text/markdown.
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.
Support for email + passkey login is coming soon.
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: {method: v.object({name: v.literal("usernamePasskey"),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.To accept several origins, pass a list. A native app that shares the
rpIdof your site reports its own origin: an iOS app reportshttps://<rpId>, and an Android app reportsandroid:apk-key-hash:<hash>, where<hash>is the unpadded base64url SHA-256 hash of the certificate that signs the app:origin: ["https://app.example.com","https://example.com","android:apk-key-hash:<hash>",],
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_LOGGED_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_LOGGED_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)}`;}}
Using passkeys in a React Native app
The passkey hooks also run in a React Native app, on a native passkey layer that
your app provides: a React Native passkey library, or your own native module.
createUsernamePasskeyHooks from
@convex-dev/auth/methods/username-passkey/react-custom makes the hooks on top
of it. The hooks take the same arguments and return the same results as the ones
of @convex-dev/auth/methods/username-passkey/react, and the backend stays the
same.
Convex Auth doesn't have a fully bundled passkey solution for React Native yet. However, you can adapt the example integration below to the third-party library of your choice.
- Set up the native passkey layer
This example uses the third-party
react-native-passkeylibrary. It is a native module, so in an Expo app it runs in a development build, not in Expo Go.npm install react-native-passkey - Add the origins of your app
Add the origins of your app to the
originlist inconvex/auth.ts, next to the origin of your site:https://<rpId>for iOS, andandroid:apk-key-hash:<hash>for Android, as the “Set up the public backend functions” step shows.convex/auth.tsorigin: ["https://app.example.com","https://example.com","android:apk-key-hash:<hash>",], - Implement the ceremonies
The hooks run every passkey ceremony through a
PasskeyCeremoniesobject:isSupported()tells whether the device can run passkey ceremonies. When it returnsfalse, the hooks returnWEBAUTHN_UNSUPPORTED.register(options)makes a new passkey, andauthenticate(options)signs in with an existing one.optionsis thePublicKeyCredentialCreationOptionsJSONorPublicKeyCredentialRequestOptionsJSONof the WebAuthn specification. Both resolve with{ success: true, response }, whereresponseis the registration or authentication response in the same JSON encoding, withtype: "public-key"and arawId. The hooks keep only the fields that the server accepts, so the response may have more.- For a failure they recognize,
registerandauthenticateresolve with{ success: false, userError }:CEREMONY_ABORTEDwhen the user dismissed the dialog or has no passkey for the app,PASSKEY_ALREADY_REGISTEREDwhen the device already holds a passkey for the account, andWEBAUTHN_UNSUPPORTED. Any other failure can throw: the hooks returnOTHER_ERROR, with the thrown value oncause.
Make the hooks once, in a module of your app:
src/passkeys.tsimport {createUsernamePasskeyHooks,type PasskeyCeremonies,type PasskeyClientError,} from "@convex-dev/auth/methods/username-passkey/react-custom";import {Passkey,type PasskeyCreateRequest,type PasskeyGetRequest,} from "react-native-passkey";// The library throws `{ error, message }` objects, where `error` is one of// its error codes.function toUserError(cause: unknown): PasskeyClientError | null {switch ((cause as { error?: unknown } | null)?.error) {case "UserCancelled":case "NoCredentials":return { error: "CEREMONY_ABORTED" };case "NotSupported":return { error: "WEBAUTHN_UNSUPPORTED" };case "CredentialAlreadyExists":return { error: "PASSKEY_ALREADY_REGISTERED" };default:return null;}}const ceremonies: PasskeyCeremonies = {isSupported: () => Passkey.isSupported(),register: async (options) => {try {// The cast narrows the transports, which the library types as an enum.const response = await Passkey.create(options as PasskeyCreateRequest);return { success: true, response: { ...response, type: "public-key" } };} catch (cause) {const userError = toUserError(cause);if (userError === null) throw cause;return { success: false, userError };}},authenticate: async (options) => {try {const response = await Passkey.get(options as PasskeyGetRequest);return {success: true,response: {...response,// In the JSON encoding, `rawId` is the same string as `id`.rawId: response.rawId ?? response.id,type: "public-key",},};} catch (cause) {const userError = toUserError(cause);if (userError === null) throw cause;return { success: false, userError };}},};export const { useSignInWithUsernamePasskey, useAddPasskey, useRemovePasskey } =createUsernamePasskeyHooks(ceremonies); - Serve the association files
iOS and Android only offer the passkeys of an
rpIdto an app that therpIdlists, inhttps://<rpId>/.well-known/apple-app-site-associationandhttps://<rpId>/.well-known/assetlinks.json. Serve both with a200, no redirect, and theapplication/jsoncontent type.The files go on the domain of the
rpId, which is usually your web host, not your deployment'sconvex.sitedomain. To serve them from Convex HTTP actions instead, have the web host rewrite (not redirect)/.well-known/*to them.On iOS, also add
webcredentials:<rpId>to the app's Associated Domains (ios.associatedDomainsin an Expoapp.json). Apple caches the file, so a change can take a while to reach devices.On Android, list the SHA-256 fingerprints of every certificate that signs the app (debug, upload, and Play app signing), the same certificates as the
android:apk-key-hash:<hash>origins.- iOS
- Android
- Convex HTTP actions
public/.well-known/apple-app-site-association{"webcredentials": {"apps": ["<TeamID>.<BundleID>"]}}public/.well-known/assetlinks.json[{"relation": ["delegate_permission/common.get_login_creds"],"target": {"namespace": "android_app","package_name": "com.example.app","sha256_cert_fingerprints": ["AB:CD:...:EF"]}}]convex/http.ts// Only serve the association files from your Convex deployment// if you're hosting a mobile-only app or if you plan to rewrite// or proxy requests to endpoints like these from your web front-end.import { httpRouter } from "convex/server";import { httpAction } from "./_generated/server";const http = httpRouter();function json(body: unknown) {return httpAction(async () =>new Response(JSON.stringify(body), {headers: { "Content-Type": "application/json" },}),);}http.route({path: "/.well-known/apple-app-site-association",method: "GET",handler: json({ webcredentials: { apps: ["<TeamID>.<BundleID>"] } }),});http.route({path: "/.well-known/assetlinks.json",method: "GET",handler: json([{relation: ["delegate_permission/common.get_login_creds"],target: {namespace: "android_app",package_name: "com.example.app",sha256_cert_fingerprints: ["AB:CD:...:EF"],},},]),});export default http; - Use the hooks
Import the hooks from the module you created above instead of
@convex-dev/auth/methods/username-passkey/react.PasskeyCeremonieshas an optionalautofillmember for passkey autofill. Without it, theautofillfield ofuseSignInWithUsernamePasskeyreportsavailable: false.import { useSignInWithUsernamePasskey } from "./passkeys";const { signIn, pending } = useSignInWithUsernamePasskey(api.auth);// ...const result = await signIn({ username });