Passkey as a second factor

Let your users confirm a sensitive action with their fingerprint, face, or device PIN instead of a code.

Why use a passkey as a second factor?

When a user does something sensitive, such as changing a security setting, you want to be sure it’s really them. Today that usually means a one-time code sent by email or SMS: the user waits for the message, switches apps, and types the code.

With a passkey, the user confirms with the same fingerprint, face, or PIN they use to unlock their device.

  • Faster for your users. One touch, and no code to type.

  • Harder to attack. A passkey works only on your site, so users can’t be tricked into handing it over the way they can with a code.

  • No message to wait for. No email or SMS goes out for that confirmation, so there’s no delivery delay, no message lost in spam or by the carrier, and no SMS cost.

  • Nobody is locked out. Users without a passkey still get an email or SMS code, as they do today.

The rest of this page shows how it works and how to add it to your app with the API.

At a glance

What it is

A passkey used as the second factor of a step-up, next to the email and SMS codes of MFA.

When it is offered

webauthn appears in the amr list returned by POST /identity/v1/mfa/stepup. See When is the passkey offered?.

Endpoints

Result

An authorization code, exchanged for tokens whose amr is ["pwd", "inh", "mfa"] (the passkey verified the user) or ["pwd", "user", "mfa"] (the passkey proved the user’s presence only), for a user who signed in with a password.

Terminology

In the API, the passkey factor is named webauthn, in both the stepup response and the MFA credentials list.

Prerequisites

  • The WebAuthn (passkeys) and MFA features are enabled on your Purse Identity account. See Multi-factor Authentication and Passkeys.

  • Passkeys are enabled as a second factor on your account: turn on the Webauthn switch under MFA  Settings in the Purse Identity Console. See MFA settings. If you don’t see the switch, contact your Purse Identity representative.

  • The origin you send is listed in the allowed origins under Settings > WebAuthn in the Purse Identity Console. The domain name in origin becomes the Relying Party ID.

  • The client that starts the step-up is a first-party identity client.

  • The user has at least one registered passkey. See FIDO2 web guide.

  • The user signed in with a first factor that is not a passkey, for example a password.

When is the passkey offered?

Purse Identity adds webauthn to the amr list of the stepup response only when all of these are true:

  1. The WebAuthn and MFA features are enabled on the account.

  2. Passkeys are enabled as a second factor on the account.

  3. The user has at least one registered passkey.

  4. The user did not already sign in with a passkey.

passkey 2nd factor flow

How it works

passkey 2nd factor howitworks

Currently, the Core SDK isn’t updated to support passkeys as a second factor, so call the API as shown in Complete a step-up with a passkey via API. We’ll update this page when the SDK is updated.

Passkey step-up flow table
Step Core JS SDK method API operation Notes

1

loginWithPassword

User logs in

The user signs in with a first factor that is not a passkey.

2

getMfaStepUpToken

Initiate stepup flow

Returns the available amr list and the step-up token. Continue only if amr contains webauthn.

3

Start a passkey step-up

Send the step-up token as step_up_token and your origin. Returns the WebAuthn request options for the user’s passkeys.

4

Your page calls navigator.credentials.get() with the options. The user unlocks the passkey with biometrics or a device PIN.

5

Complete a passkey step-up

Send the signed assertion. On success, the user is redirected to your redirect_uri with an authorization code.

6

exchangeAuthorizationCodeWithPkce

Retrieve access token

Exchanges the code for tokens.

7

Decode the token

Confirms "mfa" is part of the user’s "amr" array.

Complete a step-up with a passkey

  1. Start the step-up.

    You can start the step-up with an SSO session cookie, a tkn or an access token. See Stepup options. Keep the code_verifier that matches your code_challenge: you need it in step 5.

    Request

    POST https://YOUR_DOMAIN/identity/v1/mfa/stepup

    {
      "client_id": "nvR2y0ks...EgCW2dqy",
      "response_type": "code",
      "scope": "openid phone address profile mfa",
      "redirect_uri": "https://redirect_uri.com/",
      "code_challenge": "...",
      "code_challenge_method": "S256",
      "tkn": "xhNGxw...70zx32"
    }

    Response

    {
      "amr": ["sms", "webauthn"],
      "token": "cf21S_kHIzh3cF_CHWHGYcL77Fl29D9ALon2etU....C2PQhSAhbVk9FJVt_bVO7ombTt58Dju7Ic"
    }
  2. Get the WebAuthn options.

    Send the step-up token as step_up_token. allow_credentials in the response contains the passkeys registered on the user’s profile. The step-up token expires and can be used a limited number of times. Each call to this endpoint counts as one use.

    Request

    POST https://YOUR_DOMAIN/identity/v1/webauthn/stepup/options

    {
      "step_up_token": "cf21S_kHIzh3cF_CHWHGYcL77Fl29D9ALon2etU....C2PQhSAhbVk9FJVt_bVO7ombTt58Dju7Ic",
      "origin": "https://example.com"
    }

    Response

    {
      "public_key": {
        "allow_credentials": [{ "id": "X9FrwMfmzj...", "type": "public-key" }],
        "challenge": "kYhXBWX0HO5GstIS02yPJVhiZ0jZLH7PpC4tzJI-ZcA=",
        "rp_id": "demo.reachfive.com",
        "timeout": 120000,
        "user_verification": "preferred"
      }
    }
  3. Ask the user to use the passkey.

    The challenge and the credential id values in the options are URL-safe Base64 strings. Decode them to ArrayBuffer values, then pass the options to navigator.credentials.get() in the browser. The browser shows the passkey prompt, and the user unlocks the passkey.

  4. Send the signed assertion.

    The assertion can be used only once. Send the request from the page so the browser follows the redirect to your redirect_uri. If your code does not follow redirects, read the Location header instead. If the original step-up request used display=popup or response_mode=web_message, the endpoint returns an HTML page that hands the result back to the opener instead of redirecting. The client_id and redirect_uri in the body are only used to redirect with an error when the step-up cannot be resolved. The redirect after a successful step-up comes from the original step-up request.

    Request

    POST https://YOUR_DOMAIN/identity/v1/webauthn/stepup/verify

    {
      "id": "X9FrwMfmzj...",
      "raw_id": "X9FrwMfmzj...",
      "type": "public-key",
      "response": {
        "authenticator_data": "o2NmbXRoZmlk...",
        "client_data_json": "eyJjaGFsbGVuZ...",
        "signature": "aGFsbGVu...",
        "user_handle": "ZGfssGfu..."
      },
      "client_id": "nvR2y0ks...EgCW2dqy",
      "redirect_uri": "https://redirect_uri.com/",
      "trust_device": false
    }

    Response

    HTTP/1.1 303 See Other
    Location: https://redirect_uri.com/?code=...&iss=...
  5. Exchange the code.

    Exchange the authorization code with the code_verifier from step 1. See Authorization code grant with PKCE.

The result

After the code exchange, the access token’s amr shows how the user authenticated.

amr Meaning

["pwd", "inh", "mfa"]

The passkey verified the user.

["pwd", "user", "mfa"]

The passkey proved the user’s presence only.

These examples are for a user who signed in with a password. See Decode tokens to read the token.

When the step-up succeeds, Purse Identity emits a login user event.

Trust the device

You can send trust_device set to true when you complete the step-up. It requires risk-based authentication to be enabled on the account. If you send it while risk-based authentication is disabled, the request fails. See Errors and fallback. When it applies, the response sets the __Host-trusted-device-id cookie.

Errors and fallback

Call Situation What you get

options

The step-up token is invalid or expired.

400, error_message_key error.authn.mfa.stepup.invalidToken.

options

webauthn is not an available factor for this step-up.

400, error_message_key error.authn.mfa.unavailable.

options

The client is not a first-party identity client.

403, error_message_key error.client.notFirstParty.

options, verify

The WebAuthn or MFA feature is not enabled.

403, error_message_key error.feature.notAvailable.

verify

The signature is invalid.

Redirect with error_message_key error.webauthn.invalidCredential, to the client’s login URL if one is set, otherwise to the redirect_uri of the step-up request. With display=popup or response_mode=web_message, the result is the HTML page instead.

verify

The credential is unknown or is not one of the user’s passkeys, or the challenge was already used.

Redirect with error_message_key error.webauthn.initNotFound. The target is the redirect_uri you send in the verify body, else the client’s login URL, else the Referer. If none exists, the response is a 400.

verify

trust_device is sent while risk-based authentication is disabled.

Redirect with error_message_key error.feature.notAvailable, to the same target as the previous row.

options, verify

Too many requests.

429.

If the passkey step fails, you can call options again with the same step-up token while it is still valid and has uses left. Users can also fall back to an email or SMS code with the same token. See Retry MFA step-up.

Manage the passkeys

The GET /identity/v1/mfa/credentials endpoint response includes the user’s passkeys as items with:

  • type as webauthn

  • id as the passkey’s identifier, a URL-safe Base64 string

  • created_at as the date and time the passkey was created

  • friendly_name as the passkey’s name

To delete a passkey, use the passkey endpoints. The MFA credentials endpoints only delete email and SMS credentials.

Who How

The user

Call DELETE /identity/v1/webauthn/registration/{id} with the passkey’s id. If the user already has an MFA factor, they must first go through the stepup flow. Otherwise, a fresh access token (less than 5 minutes old) is enough.

An administrator

Use the Passkeys tab in the Purse Identity Console. See Manage MFA credentials from console. You can also use the list and delete operations of the Management API.

Deleting a passkey emits the webauthn_credential_deleted user event, which includes is_mfa_credential.

AI Assistant

Confirm Deletion