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.
Two separate switches — verifying your domain does not enrol anyone
These are the two most common points of confusion, so to be explicit:
- Verifying your domain turns passkeys on for the tenant. Once, by you.
- Enrolling a passkey happens per player, on their device, and you trigger it.
A freshly verified tenant has zero enrolled players. Asking for approval before a player has enrolled returns 400 WEBAUTHN_NO_CREDENTIAL — that is expected, not a fault, and the action automatically falls back to SMS / in-app approval. Nothing is blocked and no money movement is lost. Run the enrolment ceremony below for a player before expecting a Face ID / Windows Hello prompt.
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 setup on the dashboard's Platform & Keys page, and you can prove control either by DNS record or by serving a file.
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
Prove you control the domain — pick either method
On submit, the console issues a one-off token and shows both options. Satisfy either one — they carry the same token and are equally valid.
Option A — DNS TXT record
Add this at your DNS provider:
Host: _invo-webauthn.<your-rp-id> e.g. _invo-webauthn.accounts.athero.com Type: TXT Value: invo-verify=<token from the console>
Option B — file on your site
Serve the token as plain text at this exact path, over
https:URL: https://<your-rp-id>/.well-known/invo-webauthn-challenge.txt Contents: invo-verify=<token from the console>
The file must be served by the RP ID host itself. We follow no redirects and read plain text only — a redirect, a non-200, or an HTML page is treated as unverified.
- 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.
Hosted on *.web.app, *.netlify.app, *.vercel.app or *.pages.dev? Use Option B
On a platform-provided hostname you have a hostname inside your host's DNS zone, not a zone of your own — so there is no DNS provider where you could add_invo-webauthn.<your-subdomain>. Option A can never succeed there; it isn't a propagation delay. Use the file method instead.
Two things that silently break the file on static hosts:
- SPA rewrites. A catch-all rewrite (
"source": "**"→/index.html) answers the challenge URL with your app's HTML and a200. That reads as unverified. Add an explicit rule so/.well-known/*is served as a real file, ahead of the catch-all. - Dotfiles excluded from deploys. Firebase Hosting's default
"ignore": ["**/.*"]infirebase.jsondrops the whole.well-knowndirectory before upload, so the file 404s even though it exists locally. Remove or narrow that pattern.
Verify with curl https://<your-rp-id>/.well-known/invo-webauthn-challenge.txt — you should see the token as plain text, not HTML.
Choose the domain you intend to keep
Passkeys are bound to the RP ID permanently — a credential enrolled under one RP ID cannot be moved to another. If you expect to move from a platform-provided hostname to your own domain later, set your own domain as the RP ID now and point the origins at it; otherwise every player has to re-enrol after the move. The same RP ID also backs native iOS/Android builds via associated domains, so it needs to be a domain you can serve .well-known files from long-term.
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.
Handling /begin errors
400 WEBAUTHN_NO_CREDENTIAL— this player has no passkey yet. Not a failure. The action falls back to SMS / in-app approval on its own. Offer enrolment and carry on; don't surface it as an error. The response carriesreason,enrollment(the two endpoints to call) andfallback.403 WEBAUTHN_NOT_ENABLED_FOR_TENANT— no verified domain on the tenant. Checkreason:no_verified_domainis self-serve (see above);unavailable_on_this_environmentmeans contact INVO.400 WEBAUTHN_BEGIN_FAILED— an unexpected server-side fault. This one is worth surfacing and reporting.
Read code and message from the JSON body rather than treating any non-2xx as a network problem — the body always says which of the above it is.
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.