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
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 anhttps://origin on the RP ID or one of its subdomains (e.g. RP IDathero.comallowshttps://accounts.athero.com). - 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
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
/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 tokenFull contract + key rotation: 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.