Platform Step-Up (WebAuthn / Passkeys)

The in-app device flow assumes a mobile app with secure hardware. Web platforms don't have that — the user is in a browser. WebAuthn / passkeys give the same property on the web: a phishing-resistant, hardware-bound credential with user verification (Touch ID / Windows Hello / a security key) backing each high-value action. It's an additive alternative to the mobile device_signal — both land on the exact same approval.

How it works

You host the ceremony on your own domain

The passkey is created and used on your web origin — the user never leaves your site. You register the resulting public credential with INVO and we verify every assertion against your configured relying-party ID and allowed origins. The private key stays on the user's device and is never transmitted.

There are two ceremonies, mirroring the mobile flow:

  • Enrollment (once per browser/device) — create a passkey and register its public key.
  • Approval (per high-value action) — sign a challenge bound to the specific transfer or send.

Prerequisite — relying-party config

Your domain must be configured first

WebAuthn is offered to a platform only once its relying-party ID (your registrable domain, e.g.accounts.example.com) and allowed web origins are configured for your tenant. Until then every endpoint below returns 403 WEBAUTHN_NOT_ENABLED_FOR_TENANT. This is a one-time, ownership-verified setup with your INVO contact.

1. Enroll a passkey

POST

/api/sdk/webauthn/register/begin

Returns PublicKeyCredentialCreationOptions for the browser

Authenticated with the player token

Authorization: Bearer <player token>

Mint the player token server-side exactly as for the mobile flow — see Device Enrollment.

Pass the returned options straight to navigator.credentials.create(). The challenge is single-use and short-lived.

POST

/api/sdk/webauthn/register/complete

Verifies the attestation and stores the credential

Request Body

ParameterTypeRequiredDescription
credentialobjectYesThe serialized result of navigator.credentials.create() (base64url-encoded fields)

One passkey per identity (anti-takeover)

The first passkey for a player enrolls freely; re-registering the same passkey is idempotent. Enrolling a different new passkey while one already exists is blocked with 409 ENROLLMENT_REQUIRES_PROOF — the same takeover protection as mobile key rotation. (Adding more passkeys is handled out-of-band today.)

Client (browser)

// options came from /register/begin. Convert base64url fields to ArrayBuffers
// (challenge, user.id) before calling create(), then base64url-encode the
// result's ArrayBuffers (rawId, attestationObject, clientDataJSON) to send back.
const options = await (await fetch('/api/sdk/webauthn/register/begin', {
  method: 'POST', headers: { Authorization: 'Bearer ' + playerToken }
})).json();

const cred = await navigator.credentials.create({ publicKey: toBuffers(options) });

await fetch('/api/sdk/webauthn/register/complete', {
  method: 'POST',
  headers: { Authorization: 'Bearer ' + playerToken, 'Content-Type': 'application/json' },
  body: JSON.stringify({ credential: toJSON(cred) })   // base64url-encoded
});

toBuffers/toJSON are the standard WebAuthn base64url ⇄ ArrayBuffer converters (libraries like @simplewebauthn/browser do this for you with startRegistration).

2. Approve an action with the passkey

Replace the SMS PIN (or the mobile device_signal) with a passkey assertion. First get a challenge bound to the specific transaction, then submit the assertion to the same approval endpoint you'd use otherwise.

POST

/api/sdk/transfers/{id}/approve/webauthn/begin

Returns PublicKeyCredentialRequestOptions bound to this transfer

Companion begin endpoints exist for the send flow: /api/sdk/send/{id}/approve/webauthn/begin and /api/sdk/send/{id}/confirm-receipt/webauthn/begin.

POST

/api/sdk/transfers/{id}/approve

Submit a webauthn_assertion instead of a device_signal

Request Body

ParameterTypeRequiredDescription
webauthn_assertionobjectYesThe serialized result of navigator.credentials.get()

Client (browser)

const options = await (await fetch(`/api/sdk/transfers/${id}/approve/webauthn/begin`, {
  method: 'POST', headers: { Authorization: 'Bearer ' + playerToken }
})).json();

const assertion = await navigator.credentials.get({ publicKey: toBuffers(options) });

const res = await fetch(`/api/sdk/transfers/${id}/approve`, {
  method: 'POST',
  headers: { Authorization: 'Bearer ' + playerToken, 'Content-Type': 'application/json' },
  body: JSON.stringify({ webauthn_assertion: toJSON(assertion) })
});
// 200 { status: "approved", next: "pending_claim", claim_code: "…" }

On success the action moves to exactly the same state as the SMS / mobile path — the rest of the lifecycle (claim, completion, webhooks) is unchanged. A failed or unverifiable assertion returns 401; an expired/missing challenge requires a fresh /begin.

Why it's strong

User verification required

Every assertion requires a biometric or device PIN — it's a true step-up, not a silent token.

Bound to the action

The challenge is tied to the specific transaction, single-use — an assertion for one action can't approve another.

Phishing-resistant

The credential is bound to your origin; it can't be used from a look-alike site.

Hardware-bound + clone detection

The private key never leaves the authenticator, and a signature counter detects cloned credentials.