Changelog
Guides

Add, rename, and remove passkeys

Let a signed-in user add a passkey to their account, list passkeys, rename them, and remove them.

A signed-in user manages passkeys under /account/passkeys. Every change requires a CSRF token in the RequestVerificationToken header and a fresh ReAuth proof. Send credentials: 'include' on every request. To create a new account with a passkey, see Register users instead.

Signed-in passkey management works on 3.1.0. The 3.1.0 bug affects only anonymous passkey registration and sign-in when JWT is not turned on. Upgrade to 3.1.1 to fix those. See Register users.
TaskEndpointReAuthCSRF
Get creation optionsPOST /account/passkeys/creationOptionsYesYes
Add a passkeyPOST /account/passkeys/YesYes
List passkeysGET /account/passkeys/NoNo
Rename a passkeyPATCH /account/passkeys/YesYes
Remove a passkeyDELETE /account/passkeys/{credentialIdUrl}YesYes

Add a passkey

  1. Complete step-up. See Require step-up before sensitive actions.
  2. Get creation options from POST /account/passkeys/creationOptions. The options are for the signed-in user.
  3. Call navigator.credentials.create.
  4. Send POST /account/passkeys/ with { "credentialJson", "name"? }. AuthEndpoints trims name. A name longer than 200 characters returns a 400 validation problem with the key Name.
    const headers = { 'Content-Type': 'application/json', 'RequestVerificationToken': csrfToken };
    
    const options = await fetch('/account/passkeys/creationOptions', {
      method: 'POST', credentials: 'include', headers
    }).then(r => r.json());
    
    const credential = await navigator.credentials.create({
      publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options)
    });
    
    const added = await fetch('/account/passkeys/', {
      method: 'POST', credentials: 'include', headers,
      body: JSON.stringify({ credentialJson: JSON.stringify(credential), name: 'Work laptop' })
    }).then(r => r.json()); // { credentialId, displayName, createdAt }
    
  5. Check the response:
    • 200 { "credentialId", "displayName", "createdAt" }: the passkey is stored.
    • 400 validation problem UserMismatch: the passkey belongs to a different user.
    • 400 validation problem InvalidPasskeyState: no ceremony was underway. Start again from step 2.
    • 400 with a detail that starts with "Could not add the passkey": the attestation failed.

List passkeys

Send GET /account/passkeys/. The response is { "passkeys": [{ "credentialId", "displayName", "createdAt" }] }. credentialId is Base64Url.

Rename a passkey

Complete step-up, then send PATCH /account/passkeys/ with { "id": "<credentialId>", "newName": "..." }. The response is 200 with an empty body. An unknown id returns 404. An id that is not valid Base64Url returns a 400 validation problem InvalidCredentialId.

Remove a passkey

Complete step-up, then send DELETE /account/passkeys/<credentialId>. The response is 200 with an empty body. An unknown id returns 404.

This endpoint does not check whether the passkey is the user's last sign-in method. Before you remove a passkey from an account with no password and no external login, warn the user.