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
/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.
/api/sdk/webauthn/register/complete
Verifies the attestation and stores the credential
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
| credential | object | Yes | The 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.
/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.
/api/sdk/transfers/{id}/approve
Submit a webauthn_assertion instead of a device_signal
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
| webauthn_assertion | object | Yes | The 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.