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 (self-serve)

Your domain must be verified 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. Because an RP ID is a security-critical binding, you must prove you control the domain before the config goes live — it's a self-serve, DNS-verified setup on the dashboard's Platform & Keys page.

Configure it on the Platform & Keys page

  1. 1

    Submit your domain

    Sign in to the developer dashboard (console.invo.network, or dev.console.invo.network for sandbox), open the user menu (top-right) → Platform & Keys → pick your game → Passkey step-up. Enter your RP ID — a bare registrable domain, no https://, path, or port — and your allowed origins, one per line. Each origin must be an https:// origin on the RP ID or one of its subdomains (e.g. RP ID athero.com allows https://accounts.athero.com).

  2. 2

    Publish the DNS TXT record

    On submit, the console shows a one-off DNS challenge. Add it at your DNS provider exactly as shown:

    Host:   _invo-webauthn.<your-rp-id>     e.g. _invo-webauthn.accounts.athero.com
    Type:   TXT
    Value:  invo-verify=<token from the console>
  3. 3

    Verify

    Back in the console, click Verify domain. We do a live TXT lookup; on a match the config is promoted to live and the endpoints below light up for your tenant immediately. If the record isn't visible yet, the console shows the exact host and value it looked for — DNS propagation can take a few minutes, so try again shortly. While a submission is pending, any existing live config keeps working unchanged; you can also change the domain or turn step-up off from the same card at any time.

1. Enroll a passkey

POST

/api/sdk/webauthn/register/begin

Returns PublicKeyCredentialCreationOptions for the browser

Authenticated with the player token

Authorization: Bearer <player token>

Every WebAuthn call uses a short-lived player token, minted server-side(your game secret never touches the browser). Mint it with:

POST /api/sdk/player-token
  Header: X-Game-Secret-Key: <your game secret>     // server-to-server ONLY
  Body:   { "player_email": "user@example.com" }

→ { "token": "<player token>",                       // use as Authorization: Bearer
    "expires_at": "2026-01-01T12:15:00Z",            // ~15-minute lifetime, no refresh
    "identity_id": "id_9f3c…" }
// 404 player_not_found · 403 sdk_not_enabled_for_tenant · on 401 mint a fresh token

Full contract + key rotation: 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.

Related: mint the player token server-side · approve/confirm contracts in Approve in App and Confirm Receipt · verify our outbound webhooks (X-Invo-Signature, HMAC-SHA256).