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
Passkeys run on INVO’s hosted page: nothing to configure
The passkey ceremony runs on INVO’s hosted approval page, for every title. There is no domain to set up and no key to register on your side. INVO verifies every assertion, and the private key stays on the player’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 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.
Passkeys are on for your title; each player still has to enrol
These are the two most common points of confusion, so to be explicit:
- Nothing to switch on for the tenant: INVO’s hosted page serves every title.
- Enrolling a passkey happens per player, on their device, and you trigger it.
The in-client passkey ceremony needs a verified passkey domain on the title, and new partner domains are no longer added. So for a new title, /begin answers 403 WEBAUTHN_NOT_ENABLED_FOR_TENANT first, before any per-player check: use INVO’s hosted approval or QR device approval. On a title that does have a verified domain, a player who has not enrolled yet gets 400 WEBAUTHN_NO_CREDENTIAL. Neither is a fault, and the action continues through in-app or QR device 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.
Games that verified their own passkey domain before October 2026 keep working until they remove it. New domains can’t be added, and there is nothing to configure.
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
// temporary hold on money out (the 403 carries retry_after).
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.
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 by email
Player token. The code is short-lived and goes to one channel: the email on file first ({ "status": "sent", "channels": ["email"] }); channels says where it went. The optional body field channel accepts the literals email and sms; sms is deprecated, so do not send it. Errors: 400 INVALID_INPUT (any other channel value), 422 no_channel_on_file, 429 rate_limited (codes are rate limited; the 429 tells you when to retry).
/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. After too many wrong tries a new code is needed. |
On a match the response is { "status": "verified" } and a grant is minted server-side. It is single-use, short-lived, 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 → 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 by email
Player token; same optional channel field. Same delivery, 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, sends your backend the identity.passkey_reset webhook with reset_via: "sdk" (the same event the hosted pages send), 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 → email on file
await client.recoveryComplete(codeFromUser); // → { status: "recovered", deactivatedCount }
await client.enrollPasskey(); // same call, now succeeds
}Money out may pause 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 for a while after a recovery. The response carries a retry_after timestamp that says until when. Show it as “transfers are paused for a while after an account recovery”, with that time, 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
Use a passkey assertion in place of the mobile device_signal. 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 mobile and QR device approval paths. 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. Only reached on a title that has a verified passkey domain; a title without one gets the 403 below first. Not a failure. The action falls back to in-app or QR device 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_domainmeans use the hosted approval or QR device approval instead;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.