methods/username-passkey/server
Type Aliases
FinishSignInResult
type FinishSignInResult = Infer<typeof finishSignInResult>;
The result of finishSignInWithUsernamePasskey.
When complete the minted session tokens and the username of the account,
otherwise a user-facing userError.
FinishSignUpResult
type FinishSignUpResult = Infer<typeof finishSignUpResult>;
The result of finishSignUpWithUsernamePasskey.
When complete the minted session tokens, otherwise a user-facing userError.
StartAutofillSignInResult
type StartAutofillSignInResult = Infer<typeof startAutofillSignInResult>;
The result of startAutofillSignInWithUsernamePasskey: an unbound challenge for a
conditional-mediation (passkey autofill) navigator.credentials.get() call.
StartSignInResult
type StartSignInResult = Infer<typeof startSignInResult>;
The result of startSignInWithUsernamePasskey: which ceremony the client must run, with
the data for the WebAuthn call, or a user-facing userError.
UsernamePasskeyConfig
type UsernamePasskeyConfig = Required<UsernamePasskeyOptions> & object;
UsernamePasskeyOptions with the defaults applied, plus the core
component (for getAuthUserId). The functions of the method, including the
passkey-management ones, close over this value.
Type Declaration
coreComponent
coreComponent: CoreComponentApi;
UsernamePasskeyOptions
type UsernamePasskeyOptions = object;
Options for setupUsernamePasskey.
Properties
| Property | Type | Description |
|---|---|---|
component | ComponentApi | The mounted passkey component (components.authPasskey). The recipe drives its registration and authentication ceremonies. |
origin | string | The exact origin of the ceremonies (for example, "https://app.example.com" or "http://localhost:5173"). |
rpId | string | The relying party ID: usually the registrable domain at which the app is served (for example, "example.com" or "localhost"). Only web pages on the same domain (or its subdomains) can use the passkeys. See https://web.dev/articles/webauthn-rp-id |
rpName? | string | The human-readable relying party name that the browser shows in the passkey dialog. WebAuthn requires it on the client. When not set, the rpId is used. |
usernameComponent | UsernameComponentApi | The mounted username component (components.authUsername). The recipe uses it to map a username onto the app user id: it stores the username at sign-up and reads it back at sign-in. |
Functions
setupUsernamePasskey()
function setupUsernamePasskey<UsersTable>(core, options): object;
An identifier-first username + passkey recipe: the user types a username; when the username has an account, the user authenticates with a passkey of that account; when the username is free, the user registers a new account immediately.
Logged in users can add or remove passkeys.
Wire it up in convex/auth.ts:
const auth = setupConvexAuth({ component: components.auth });
export const { signOut, refreshSession, isAuthenticated } = auth;
const {
startSignInWithUsernamePasskey,
startAutofillSignInWithUsernamePasskey,
finishSignUpWithUsernamePasskey,
finishSignInWithUsernamePasskey,
listPasskeys,
renamePasskey,
startAddPasskey,
verifyAddPasskey,
finishAddPasskey,
startRemovePasskey,
finishRemovePasskey,
} = setupUsernamePasskey(auth, {
component: components.authPasskey,
usernameComponent: components.authUsername,
rpId: "localhost",
origin: "http://localhost:5173",
}).attachUserCallbacks({ createUser: internal.users.createUserPasskey });
Type Parameters
| Type Parameter |
|---|
UsersTable extends string |
Parameters
| Parameter | Type |
|---|---|
core | AuthCore<UsersTable> |
options | UsernamePasskeyOptions |
Returns
attachUserCallbacks()
attachUserCallbacks(__namedParameters): object;
Supply the app's user callbacks (see UserCallbacks for how their args must be declared) and get the functions of this method to export.
Parameters
| Parameter | Type |
|---|---|
__namedParameters | UserCallbacks<"passkey", Record<string, never>, UsersTable> |
Returns
finishAddPasskey
finishAddPasskey: RegisteredMutation<"public", {
response: {
clientExtensionResults: {
};
id: string;
rawId: string;
response: {
attestationObject: string;
clientDataJSON: string;
transports?: string[];
};
type: "public-key";
};
}, Promise<
| {
passkeyId: string;
success: true;
}
| {
success: false;
userError: | {
error: "CHALLENGE_EXPIRED";
}
| {
error: "PROTOCOL_ERROR";
}
| {
error: "NOT_LOGGED_IN";
}
| {
error: "TOO_MANY_PASSKEYS";
};
}>>;
finishRemovePasskey
finishRemovePasskey: RegisteredMutation<"public", {
passkeyId: string;
response: {
clientExtensionResults: {
};
id: string;
rawId: string;
response: {
authenticatorData: string;
clientDataJSON: string;
signature: string;
userHandle?: string;
};
type: "public-key";
};
}, Promise<
| {
success: true;
}
| {
success: false;
userError: | {
error: "PROTOCOL_ERROR";
}
| {
error: "UNKNOWN_CREDENTIAL";
}
| {
error: "CHALLENGE_EXPIRED";
}
| {
error: "NOT_LOGGED_IN";
}
| {
error: "PASSKEY_NOT_FOUND";
};
}>>;
finishSignInWithUsernamePasskey
finishSignInWithUsernamePasskey: RegisteredMutation<"public", {
response: {
clientExtensionResults: {
};
id: string;
rawId: string;
response: {
authenticatorData: string;
clientDataJSON: string;
signature: string;
userHandle?: string;
};
type: "public-key";
};
}, Promise<
| {
status: "complete";
tokens: {
accessToken: string;
refreshToken: string;
refreshTokenExpiresAt: number;
userId: string;
};
username: string | null;
}
| {
status: "error";
userError: | {
error: "PROTOCOL_ERROR";
}
| {
error: "UNKNOWN_CREDENTIAL";
}
| {
error: "CHALLENGE_EXPIRED";
};
}>>;
Verify a passkey assertion and, on success, mint a session.
The function serves both ceremonies that produce an assertion: the
username-first branch (startSignInWithUsernamePasskey → step: "authenticate", a
challenge bound to the user) and passkey autofill
(startAutofillSignInWithUsernamePasskey, an unbound challenge where the credential
identifies the user).
finishSignUpWithUsernamePasskey
finishSignUpWithUsernamePasskey: RegisteredMutation<"public", {
response: {
clientExtensionResults: {
};
id: string;
rawId: string;
response: {
attestationObject: string;
clientDataJSON: string;
transports?: string[];
};
type: "public-key";
};
username: string;
}, Promise<
| {
status: "complete";
tokens: {
accessToken: string;
refreshToken: string;
refreshTokenExpiresAt: number;
userId: string;
};
}
| {
status: "error";
userError: | {
error: "USERNAME_TOO_SHORT";
minimumLength: number;
}
| {
error: "USERNAME_TOO_LONG";
maximumLength: number;
}
| {
error: "USERNAME_HAS_SURROUNDING_WHITESPACE";
}
| {
error: "USERNAME_HAS_INVALID_CHARACTERS";
}
| {
error: "USERNAME_TAKEN";
}
| {
error: "CHALLENGE_EXPIRED";
}
| {
error: "PROTOCOL_ERROR";
};
}>>;
Create a new account: the user, the username, the account, the session, and the passkey, all in ONE mutation. When any step fails, nothing is committed.
The order of the steps gives that guarantee:
- Checks that write nothing: the username format, the username
conflict, and
checkRegistrationForNewUser(the full WebAuthn verification as a query). Each failure, correctable or not, returns auserErrorhere, before anything exists. - Writes that cannot fail after the checks:
completeSignUpmints the user, the account, and the session,setUsernamestores the username, andfinishRegistrationForNewUserstores the passkey. None of them can fail after step 1 inside the same transaction, so a failure throws and rolls everything back.
listPasskeys
listPasskeys: RegisteredQuery<"public", {
}, Promise<
| {
passkeys: object[];
success: true;
}
| {
success: false;
userError: {
error: "NOT_LOGGED_IN";
};
}>>;
renamePasskey
renamePasskey: RegisteredMutation<"public", {
name: string;
passkeyId: string;
}, Promise<
| {
success: true;
}
| {
success: false;
userError: | {
error: "NOT_LOGGED_IN";
}
| {
error: "PASSKEY_NOT_FOUND";
}
| {
error: "INVALID_NAME";
};
}>>;
startAddPasskey
startAddPasskey: RegisteredMutation<"public", {
}, Promise<
| {
options: {
allowCredentials: object[];
challenge: string;
rpId: string;
timeout: number;
userVerification: "required";
};
success: true;
}
| {
success: false;
userError: | {
error: "NOT_LOGGED_IN";
}
| {
error: "TOO_MANY_PASSKEYS";
};
}>>;
startAutofillSignInWithUsernamePasskey
startAutofillSignInWithUsernamePasskey: RegisteredMutation<"public", {
}, Promise<{
options: {
allowCredentials: object[];
challenge: string;
rpId: string;
timeout: number;
userVerification: "required";
};
}>>;
Start a conditional-mediation (passkey autofill) ceremony: an authentication challenge that is not bound to a user. The passkey that the user selects identifies the account.
startRemovePasskey
startRemovePasskey: RegisteredMutation<"public", {
passkeyId: string;
}, Promise<
| {
options: {
allowCredentials: object[];
challenge: string;
rpId: string;
timeout: number;
userVerification: "required";
};
success: true;
}
| {
success: false;
userError: | {
error: "NOT_LOGGED_IN";
}
| {
error: "PASSKEY_NOT_FOUND";
}
| {
error: "LAST_PASSKEY";
};
}>>;
startSignInWithUsernamePasskey
startSignInWithUsernamePasskey: RegisteredMutation<"public", {
username: string;
}, Promise<
| {
options: {
allowCredentials: object[];
challenge: string;
rpId: string;
timeout: number;
userVerification: "required";
};
step: "authenticate";
success: true;
}
| {
options: {
attestation: "none";
authenticatorSelection: {
requireResidentKey: true;
residentKey: "required";
userVerification: "required";
};
challenge: string;
excludeCredentials: object[];
extensions: {
};
pubKeyCredParams: object[];
rp: {
id: string;
name: string;
};
timeout: number;
user: {
displayName: string;
id: string;
name: string;
};
};
step: "register";
success: true;
}
| {
success: false;
userError: | {
error: "USERNAME_TOO_SHORT";
minimumLength: number;
}
| {
error: "USERNAME_TOO_LONG";
maximumLength: number;
}
| {
error: "USERNAME_HAS_SURROUNDING_WHITESPACE";
}
| {
error: "USERNAME_HAS_INVALID_CHARACTERS";
}
| {
error: "USERNAME_TAKEN";
};
}>>;
The identifier-first entry point: look the username up and start the matching ceremony.
- The username has an account: start an authentication ceremony
that is bound to that user (
step: "authenticate"). - The username is free: start a registration ceremony for a new
account (
step: "register"). The account is not created here;finishSignUpWithUsernamePasskeycreates everything in one transaction.
Note: this flow lets an attacker enumerate which usernames exist. That is intrinsic to identifier-first passkey sign-in (and account existence is also observable through sign-up), so it is accepted.
verifyAddPasskey
verifyAddPasskey: RegisteredMutation<"public", {
response: {
clientExtensionResults: {
};
id: string;
rawId: string;
response: {
authenticatorData: string;
clientDataJSON: string;
signature: string;
userHandle?: string;
};
type: "public-key";
};
}, Promise<
| {
options: {
attestation: "none";
authenticatorSelection: {
requireResidentKey: true;
residentKey: "required";
userVerification: "required";
};
challenge: string;
excludeCredentials: object[];
extensions: {
};
pubKeyCredParams: object[];
rp: {
id: string;
name: string;
};
timeout: number;
user: {
displayName: string;
id: string;
name: string;
};
};
success: true;
}
| {
success: false;
userError: | {
error: "PROTOCOL_ERROR";
}
| {
error: "UNKNOWN_CREDENTIAL";
}
| {
error: "CHALLENGE_EXPIRED";
}
| {
error: "NOT_LOGGED_IN";
};
}>>;