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.
A passkey only runs in-client on mobile & web
This flow works wherever the client can invoke a platform authenticator in-process: mobile app & mobile web (iOS 16+ / Android 9+, using Face ID / Touch ID / fingerprint) and desktop web (Touch ID / Windows Hello). It does not work on consoles (no browser, no WebAuthn) or in native Steam / desktop game clients. Even though macOS and Windows support passkeys, the embedded game client cannot invoke the OS authenticator from inside the engine. On those platforms, use the QR device-approval flow instead: Device Approval (Consoles & TVs). The per-platform mapping lives there.
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.
Partner passkey domains are frozen: new titles have nothing to configure
INVO now runs the passkey ceremony on its own domain, for every platform
Passkey step-up is delivered by the hosted approval flow: your client starts a device approval grant and the player enrols and approves on INVO’s own page: QR on consoles, a popup on the web (the web SDK’s approveHosted()), the system browser on iOS / Android. Because the credential lives on INVO’s relying party, there is no partner domain to verify, and new per-partner domains are no longer accepted. See Device Approval. That page is now the primary reference for step-up on every platform.
- New tenants: do nothing.
POST .../webauthn/rp-idfor a title without a live domain returns409 PARTNER_RP_FROZENwith astep_upblock and ahosted_flowpointer. The in-appwebauthn/register/*andwebauthn_assertioncalls below return403 WEBAUTHN_NOT_ENABLED_FOR_TENANTfor you. That is the expected state, not a setup gap; the body’shosted_flowsays to usedevice_codeinstead. Console step-up, passkeys on the web and on mobile all work with nothing to configure. - Tenants that verified a domain before the freeze keep it. A passkey is bound to its relying-party ID for life, so your enrolled players keep approving in-app exactly as documented below, and you may still re-submit (change origins, re-verify) or verify a pending submission. A live partner domain remains authoritative for your title (
step_up.via: "partner_rp"). - Removing a domain is irreversible.
DELETE .../webauthn/rp-idanswers{ partner_rp_frozen: true, irreversible: true, step_up }: once cleared, a partner domain can never be re-added, credentials minted under it stop being usable, and the hosted flow serves the title from then on. Your title keeps working; the door is one-way. - The “no web surface” waiver is deprecated.
PUT .../webauthn/no-web-surfacestill accepts{ "no_web_surface": true|false }and records your attestation, but there is nothing left to waive. The go-live gate is satisfied by INVO’s relying party for every title. The response carriesdeprecated: true; readstep_up.readyinstead. It will be removed once every console readsstep_up.
What the step-up status reports
The game detail (GET /api/dev/partner/games/<game_id>) now carries a webauthn.step_up object beside the partner-domain status. It reports what is true, and enables nothing:
"webauthn": {
"status": "disabled", // the PARTNER domain only: active | pending_verification | disabled
"step_up": {
"ready": true, // some relying party can run a passkey ceremony for this game
"via": "platform_rp", // "partner_rp" (a live verified domain wins) | "platform_rp" | null
"platform_rp_id": "invo.network",
"partner_rp_frozen": true // always true: do not render a "set up your domain" call to action
},
"step_up_ready": true, // flat mirror of step_up.ready
"no_web_surface": true, // TRANSITIONAL: true whenever the platform RP serves the game
"no_web_surface_attested": false // the raw waiver column — what you declared, for audit
}Gate go-live on step_up.ready, not on status === "active". no_web_surface is reported true whenever the platform relying party serves the title so that older consoles keyed on it still open; it collapses back to the raw attestation once they read step_up. The games list mirrors the same values as step_up_ready / step_up_via.
If your players enrol on your domain, you must implement linkDevice()
This is the one thing a tenant with its own verified passkey domain has to build, and the one thing that strands players if it is missing. It applies to every title whose players hold a passkey minted on the partner’s origin, and the symptom is always the same: the player scans the QR, or the popup opens, and INVO’s hosted approval page refuses to go any further.
A passkey is bound to the site that created it
A credential whose relying-party ID is your domain cannot be asserted on INVO’s hosted page, whatever the server is willing to accept. That is how WebAuthn works, not a policy INVO could relax. Only your own site can prove that credential. So the hosted page cannot fall back to the passkey the player already has; something on your origin has to vouch for the phone first.
What the player hits: 409 ENROLLMENT_REQUIRES_PROOF
When an identity already holds an active approval method and nothing has vouched for a new one, the hosted page refuses with 409, code ENROLLMENT_REQUIRES_PROOF, and next: "link". That refusal is the anti-takeover gate doing its job: a stolen game session must not be able to enrol an attacker’s key quietly beside the owner’s. An identity with no method at all still enrols freely.
HTTP/1.1 409 Conflict
{
"code": "ENROLLMENT_REQUIRES_PROOF",
"next": "link",
"error": "You already approve with a passkey on the game's own website. ..."
} The way through: your site calls linkDevice()
The web SDK (@invonetwork/web-sdk) wraps POST /api/sdk/device/link/webauthn/begin then /complete. Run it on your own origin, where the passkey lives. It asserts the credential the player already has, which mints a single-use grant, and then the player scans the QR again and the phone enrols.
- Hosted page refuses with
ENROLLMENT_REQUIRES_PROOF. - On your site,
await invo.linkDevice(): Face ID / Windows Hello on the passkey they already hold. - Send the player straight back to the QR and tell them to scan again now. The grant is short-lived.
- The phone enrols, the approval settles, and nothing else in your integration changes.
linkDevice() takes no arguments
As of 3.10.1 the call is await invo.linkDevice(): the server mints the link id and echoes it through the ceremony, so there is nothing for you to supply. Code written against 3.10 and earlier, which had to pass a placeholder, still compiles and still works; the argument is accepted and ignored. New code should simply leave it out.
Recovery is not the answer to ENROLLMENT_REQUIRES_PROOF
Say this one out loud, because at least one integration got it wrong and would have locked a working account out. Recovery answers a different verdict: 409 PASSKEY_ALREADY_ENROLLED, or a player who tells you the credential is gone.
Running recovery on a player who still has a working passkey deactivates the credential they are still using and imposes a 24 hour hold on moving money out (403 PASSKEY_RECOVERY_COOLDOWN, section 3). Linking costs the player one biometric prompt and keeps both methods. Recovery burns the one they have. They are not interchangeable fallbacks.
Branch on the server’s verdict, never on a guess
Read the error code and next from the body and dispatch on those. Do not try to infer the right path from which credentials your front end can see: a browser cannot enumerate a player’s passkeys, still less the methods that live on another device, so any guess you make there will be wrong for somebody.
import { InvoError } from '@invonetwork/web-sdk';
async function enrolWithFallback(invo, onLostCredential) {
try {
await invo.enrollPasskey();
} catch (e) {
if (!(e instanceof InvoError)) throw e;
// ENROLLMENT_REQUIRES_PROOF / next: "link"
// They still HAVE a method: prove it here, on your origin, then retry.
if (e.isEnrollmentProofRequired) {
await invo.linkDevice(); // no arguments: the server mints the link id
await invo.enrollPasskey(); // retry; the single-use grant is consumed for you
return;
}
// PASSKEY_ALREADY_ENROLLED, or the player says the credential is gone.
// ONLY here. Recovery deactivates the existing passkey and starts a
// 24-hour hold on money out.
if (e.code === 'PASSKEY_ALREADY_ENROLLED') {
await onLostCredential(); // recoveryBegin + recoveryComplete, then enrol
return;
}
throw e;
}
}Make it friendly on the front end
The player should never see a status code. What they should see is one obvious action, and a clearly secondary escape hatch that spells out its cost before they commit to it.
One plain action
Offer it on the same screen the player is already on: “Link this phone”, or “Use the passkey you already have”. No error codes, no jargon, no support article.
Recovery stays secondary
Word it as “I no longer have that passkey”, visibly below the main action, and state the roughly 24 hour pause on sending before the player commits. Note that receiving still works throughout.
Send them straight back
The grant is short-lived, so do not end on a success toast. Return the player to the QR, or reopen the popup, and tell them to scan again now.
The same rule and the same refusal are described from the hosted page’s side on Device Approval.
Relying-party config (existing partner domains only)
Applies only to a tenant that already holds a verified domain
The in-app ceremonies on this page run on your web origin, so they are offered only where a relying-party ID (your registrable domain, e.g.accounts.example.com) and allowed web origins were configured and verified for your tenant before the freeze. Everywhere else the endpoints below return 403 WEBAUTHN_NOT_ENABLED_FOR_TENANT and the hosted flow is the path. Because an RP ID is a security-critical binding, a change to an existing domain still requires you to prove you control it before the config goes live, on the dashboard’s Platform & Keys page, either by DNS record or by serving a file. The steps below are kept for those tenants; a new title cannot submit a first domain.
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 title → 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 title’s secret key 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. To add a second method on purpose (another passkey, or the INVO app’s device key alongside a passkey), prove the method you already have first: POST /api/sdk/device/link/approve with a signature from an enrolled device key, or /api/sdk/device/link/webauthn/begin + /complete with an assertion from the existing passkey. Either mints a single-use link grant that the next register call consumes. (/api/sdk/device/link/request optionally pings the existing device so the player can approve the link there.) The web SDK wraps this as linkDevice(), which takes no arguments from 3.10.1 on. If the existing passkey is gone rather than merely elsewhere, use recovery (section 3). It is also the call that unblocks a player who meets INVO’s hosted approval page, so if your players enrol on your own domain you have to implement it. The section above, If your players enrol on your domain, has the full branch and the player-facing wording.
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. First enrolment when your tenant requires authorisation
By default a player’s first passkey enrols freely. The player token your server minted is the proof. A tenant can opt in to a stronger gate: the first enrolment must also prove the player controls the email or phone the identity was built from. With that switch on, a first /webauthn/register/complete (or mobile /device/register) with no grant on file returns 409 ENROLLMENT_REQUIRES_AUTHORIZATION. It is off unless you turn it on, so an integration that has not added these two calls is unaffected.
/api/sdk/device/enrollment/begin
Sends a one-time 6-digit code: email first, a text on request
Player token. The code lives 10 minutes and goes to one channel: the email on file first ({ "status": "sent", "channels": ["email"] }). It goes by text only when the client asks ({ "channel": "sms" } in the body, a “text me instead” tap), when there is no email on file, or when the email could not be handed over; channels says which went out. Errors: 400 INVALID_INPUT (a channel other than email / sms), 422 no_channel_on_file, 429 rate_limited (at most 3 codes per identity per 15 minutes).
/api/sdk/device/enrollment/verify
Exchanges the code for a single-use enrolment grant
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
| code | string | Yes | The 6-digit code the player received. Five attempts, then a new code is needed. |
On a match the response is { "status": "verified" } and a grant is minted server-side. It is single-use, valid for 30 minutes, and consumed automatically by the next /webauthn/register/complete or /device/register. You never see or pass it. A wrong or expired code returns 400 ENROLLMENT_CODE_INVALID.
With the web SDK
Catch the 409, run the two calls, then retry the same enrol call. The grant is consumed for you:
try {
await client.enrollPasskey();
} catch (e) {
if (e instanceof InvoError && e.isEnrollmentAuthorizationRequired) {
await client.enrollmentBegin(); // code → phone + email on file
await client.enrollmentVerify(codeFromUser); // 6-digit code
await client.enrollPasskey(); // retry — grant auto-consumed
} else throw e;
}3. Lost or replaced passkey (recovery)
Deleting a passkey on the device never tells the server the key is gone, so the next enrolment is blocked by the anti-takeover gate with 409 ENROLLMENT_REQUIRES_PROOF, and the player has nothing left to prove with. Recovery is the possession-proofed way out: offer a “Lost or replaced your passkey?” action that runs these two calls, then the normal enrolment ceremony again.
Do not reach for this on a 409 alone
ENROLLMENT_REQUIRES_PROOF on its own does not mean the passkey is gone. It usually means the passkey is simply elsewhere, on your own domain, and the answer is linkDevice(). Only run recovery when the player tells you the credential is gone, or when the verdict is 409 PASSKEY_ALREADY_ENROLLED. Running it on a player who still has a working passkey deactivates the credential they are using and starts the 24 hour money-out hold below, for nothing.
/api/sdk/device/recover/begin
Sends a one-time 6-digit code: email first, a text on request
Player token; optional { "channel": "sms" }. Same delivery order, limits and errors as /device/enrollment/begin above. A player who lost the phone their INVO passkey was on can also recover from the hosted approval or claim page with no in-app call at all. See Recovering a lost passkey.
/api/sdk/device/recover/complete
Verifies the code, retires the stale passkey, and clears the way for a new one
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
| code | string | Yes | The 6-digit code the player received |
Possession is proven before anything is touched. On success INVO deactivates the identity’s stale passkey(s) for your tenant, mints the grant a fresh enrolment needs, sends the owner an out-of-band “your passkey was reset” notice, and answers { "status": "recovered", "deactivated_count": 1 }. Then run /webauthn/register/begin + /complete as in section 1. Errors: 400 ENROLLMENT_CODE_INVALID (wrong or expired, so let the player retry or resend), 500 RECOVERY_FAILED (transient).
// web SDK
if (e instanceof InvoError && e.isEnrollmentProofRequired) {
await client.recoveryBegin(); // code → phone + email on file
await client.recoveryComplete(codeFromUser); // → { status: "recovered", deactivatedCount }
await client.enrollPasskey(); // same call — now succeeds
}24-hour money-out cooldown after a recovery
A recovered passkey signs in and receives funds immediately, but every money-out approval (transfer or send, by any factor) returns 403 PASSKEY_RECOVERY_COOLDOWN with a retry_after timestamp for 24 hours. That is deliberate. It gives the reset notice time to reach the real owner before anything leaves the balance. Show it as “transfers are paused for 24 hours after a passkey reset”, not as a generic failure, and do not retry-loop it. Receiving, pending-collect and confirm-receipt are unaffected.
Still have the other method, and only want to add this one? That is linking, not recovery. See the note under section 1.
4. 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.