Skip to content

Passkey authentication

Similar to passkey registration, authentication is a three-step process:

  1. Authorised in your backend
  2. Authenticated in your frontend
  3. Verified in your backend
sequenceDiagram
  participant Frontend
  participant Backend
  participant Browser as @passlock/browser
  participant Server as @passlock/server

  Frontend->>Backend: authorize authentication
  Backend->>Server: authorizePasskeyAuthentication({ rpId, discoverable: true })
  Server-->>Backend: authenticationToken
  Backend-->>Frontend: authenticationToken

  Frontend->>Browser: authenticatePasskey({ authenticationToken })
  Browser-->>Frontend: id_token, code

  Frontend->>Backend: code

  Backend->>Server: exchangeCode(code)
  Server-->>Backend: userId

  Backend->>Backend: lookupUser(userId)

Note: this could be in response to a request from your frontend. i.e. a user clicking a “sign in” button, resulting in a fetch call to an API endpoint:

backend/authenticate.ts
import { Passlock } from "@passlock/server";
const passlock = new Passlock({
tenancyId: "MyTenancyId",
apiKey: "MyApiKey"
});
// userId and allowCredentials are optional
// and only make sense if the userId is known (see options)
const result = await passlock.authorizePasskeyAuthentication({
rpId: "example.com", // or localhost
userId: user.id,
allowCredentials: user.passkeys,
discoverable: true,
userVerification: "preferred",
timeout: 5000,
mediation: "conditional"
});
if (result.success) {
// provide this to your frontend code
// see frontend/authenticate-passkey.ts below
return result.authenticationToken;
}
rpIdThe Relying Party ID
allowCredentialsOptional list of passkey IDs to pre-select for the known account. The device will limit authentication to one of those passkeys.
userIdShortcut for allowCredentials. Passlock will look up the user’s known credentials (passkeys)
discoverableIf you don’t know the user ID, set this to true. The device will present all passkeys for the given Relying Party ID
userVerificationSet to preferred, required, or discouraged to control the user verification behavior
timeoutPeriod the user has to complete the authentication process in milliseconds. Important: don’t set this when using autofill
mediationSet to true to enable autofill

If you cannot use the passlock server library, use the v2 REST endpoint to obtain an authentication token:

POST /v2/{tenancyId}/passkey/authentication/authorize HTTP/1.1
Host: api.passlock.dev
Accept: application/json
Content-Type: application/json
Authorization: Bearer {apiKey}
{
"rpId": "example.com",
"discoverable": true,
"userVerification": "preferred"
}

Your frontend code will obtain a passkeyAuthenticationToken from your backend, and use it to trgger passkey authentication on the device:

frontend/authenticate.ts
import { Passlock } from "@passlock/browser";
const passlock = new Passlock({ tenancyId: "MyTenancyId" });
loginButton.addEventListener("click", async () => {
// see backend/initiate-authentication.ts above
const authenticationToken = await fetchTokenFromBackend();
const result = await passlock.authenticatePasskey({
authenticationToken
});
if (result.success) {
// see backend/verify-authentication.ts below
await submitCodeToBackend(result.value.code);
// alternatively use the id_token (JWT)
await submitIdTokenToBackend(result.value.id_token);
}
});

Exchange the code returned from the frontend for a verified authentication result:

backend/authenticate.ts
import { Passlock } from "@passlock/server";
const passlock = new Passlock({
tenancyId: "MyTenancyId",
apiKey: "MyApiKey"
});
const result = await passlock.exchangeCode({ code });
// alternatively verify the id_token (JWT)
const result = await passlock.verifyIdToken({ id_token });
if (result.success) {
const user = await lookupUserById(result.value.userId);
// alternatively identify the user by their passkey id
const user = await lookupUserByPasskeyId(principal.authenticatorId);
};

If you cannot use the passlock server library, use the v2 REST endpoint:

GET /v2/{tenancyId}/principal/{code} HTTP/1.1
Host: api.passlock.dev
Accept: application/json
Authorization: Bearer {apiKey}

The response is an ExtendedPrincipal. As with the server-library flow, verify that the returned userId matches the local user who started registration, then store the returned authenticatorId as the Passlock passkey ID for that user.

See error handling to understand how to handle Passlock errors.

If you didn’t first check whether the user’s device supports passkeys you could run into a PasskeyUnsupportedError error, tagged as @error/PasskeyUnsupported:

import { Passlock, isPasskeyUnsupportedError } from "@passlock/browser";
const result = await passlock.authenticatePasskey({ ... });
if (!result.success) {
if (isPasskeyUnsupportedError(result.error)) {
alert("Passkeys not supported on this device");
}
}

authenticatePasskey can return an OrphanedPasskeyError error, tagged as @error/OrphanedPasskey:

import {
Passlock,
isOrphanedPasskeyError,
} from "@passlock/browser";
const result = await passlock.authenticatePasskey({ ... });
if (result.failure) {
if (isOrphanedPasskeyError(result.error)) {
alert("This passkey is no longer valid");
}
}

This error occurs when a user uses a passkey on their device that no longer exists in the vault.

Tell the user the passkey is stale and give them a way to remove it from their password manager. For passkeys that still have a known vault record, use the prepared deletePasskeys flow described in handling an OrphanedPasskeyError.

You could also encounter another (unexpected) error, OtherPasskeyError tagged as @error/OtherPasskey. This error includes a code, indicating the specific error along with a message.

backend/authenticate.ts
const result = await passlock.authorizePasskeyAuthentication({
rpId: "example.com", // .com
discoverable: true
});

Code running on example.co.uk:

frontend/authenticate.ts
import { Passlock, isOtherPasskeyError } from "@passlock/browser";
const result = await passlock.authenticatePasskey({ ... });
if (!result.success) {
if (isOtherPasskeyError(result.error)) {
if (result.error.code === "ERROR_INVALID_RP_ID") {
alert("Expected example.com but running on example.co.uk")
}
}
}

We’ve made passkey authentication flexible. Feel free to implement it in a way that suits your needs and technical stack. These are just some recommendations:

Don’t assume that because the user registered a passkey they are able to authenticate with it. They might be using a different device which does not support passkeys. Always test for device support before attempting to authenticate.

Unless you’re starting out with a green field project, you’ll likely have existing users. Some of these users will not yet have registered a passkey. In this case, you can implement a two-step login flow.

The user first enters their account identifier (username/email). You check if that account has a passkey registered, and if so prompt them to authenticate with it in the second step.

Alternatively browser autofill aka conditional mediation allows you to present a “legacy” username/password form, however the browser will prompt the user to use their passkey if they have one on their device.

Passkeys are often used to re-authenticate logged in users. You might choose to re-authenticate a user for a security sensitive operation such as changing their email address or PIN. In this scenario setting user verification to required usually makes sense.

Passkeys are comprised of two components - a private key, stored on the user’s device, and a public key, stored in your Passlock vault. If either component is missing the user will receive an error. Learn how to handle missing passkeys gracefully.

Passkeys are resistant to phishing attacks because they are bound to a specific domain (Relying Party ID). You can’t simply instruct users to sign in with their old-domain.com passkey on new-domain.com. Learn how Passlock’s domain migration tooling makes this possible.