Frontend device-fingerprinting integration
What this covers / who it's for. The SPA-side contract for device-risk screening (item 11 / D102): which vendor agent the frontend loads, how it collects a per-action device token, which request fields carry it on which endpoints, and the response/UX contract (fraud-block, screening-required, step-up, rate-limit). The tenant client SPA and the back-office SPA live in separate repos (D12), so this page — not a shared codebase — is the integration contract between them and the backend. For backend internals (the guard, stores, verdict mapping) defer to the fraud README; this page links down and does not restate it.
The device-risk feature is deploy-gated and default-disabled (D102): the backend always
accepts the device fields below, but screening only runs once ops enables the
fraud.device-risk feature for a tenant (and, for the central operator/sign-up surfaces, sets
fraud.central_provider). You can and should ship the collection code now — before enablement
it is a harmless no-op; after enablement it is what makes screening work. Until you send a token,
an enabled tenant's screening has nothing to assess.
Why the frontend has to do anything
Device intelligence identifies the device behind a request (bots, emulators, tampering, VPN/proxy, account-takeover velocity). That signal can only be collected in the browser/app, by a vendor JavaScript agent, which returns an opaque handle. The SPA sends that handle to the backend, which pulls the authoritative risk verdict server-side (the client is never trusted to report risk). No agent on the frontend ⇒ no token ⇒ nothing to screen. The three screened moments are sign-up, login, and payout.
Which vendor agent to load — an OPEN integration decision
The fraud provider is routed server-side per tenant jurisdiction (the D102 grant: Fingerprint primary, SEON permitted across all six baseline jurisdictions). Today there is no endpoint that tells the SPA which fraud provider its tenant is routed to — provider routing is a backend concern the SPA cannot currently see. That leaves an open decision for the integrating team:
- Option A — expose the routed fraud provider in the bootstrap payload. Extend the abilities feed
(
GET /api/v1/me/abilities, also embedded asmeta.abilitieson/me— see auth & RBAC and the authentication README) with the tenant's fraud provider + its public browser key + region, so the SPA loads the correct agent dynamically. This is the general solution and the natural home for the data, but it is not built and would need a backend change + a security review (it publishes the provider choice and a public key to the browser). - Option B — ship Fingerprint-only collection. Because Fingerprint is the sole primary in every jurisdiction today (SEON is permitted but primary nowhere), a frontend can integrate Fingerprint only and stay correct for the current grant. Simplest path to shipping; must be revisited if SEON is ever made primary for a jurisdiction.
This decision is tracked in
11-fraud-device-login.md §7 and
points back at this page. Pick per your rollout; the collection flows for both vendors are below so
either option is ready.
Collection flows per vendor
⚠ Verify at integration. The spike (
spike/11-fraud-device-login.md§3) froze the process model ("client agentget()→ tokens"; "JS SDK → encryptedsessionblob") but not the exact SDK package names / method signatures. Treat every code snippet below as illustrative and confirm the precise API against current vendor docs when you wire it up. Flags are marked ⚠ inline.
Fingerprint (Pro / Smart Signals) — the primary
- Load the Pro agent in the browser with two values:
- the public browser API key — ⚠ this is Fingerprint's public key, meant to ship in
client-side JS. It is distinct from the backend
FINGERPRINT_SECRET_API_KEY(the Server-API bearer secret, which must never reach the browser — seeconfig/fingerprint.php). Do not confuse the two. - the region endpoint, which must match the Fingerprint account's pinned data-residency region
(region is fixed at account creation): global
api.fpjs.io· EUeu.api.fpjs.io· APAC (Mumbai)ap.api.fpjs.io. The backend's region is set byFINGERPRINT_REGION; the agent's endpoint must agree.
- the public browser API key — ⚠ this is Fingerprint's public key, meant to ship in
client-side JS. It is distinct from the backend
- Call
get()per screened action. It returns{ requestId, visitorId }:requestId— a per-event token (one identification). This goes indevice_token.visitorId— the stable device id. This may go indevice_hint(see semantics below).
- Freshness (load-bearing): a
requestIdidentifies one event. Collect a fresh one immediately before each screened submit — never cache or reuse arequestIdacross attempts. Reuse either fails to screen or (worse) re-screens a stale event.
// ⚠ ILLUSTRATIVE — confirm the @fingerprintjs/fingerprintjs-pro package + init signature at integration.
const fp = await FingerprintJS.load({ apiKey: PUBLIC_BROWSER_KEY, endpoint: REGION_ENDPOINT });
const { requestId, visitorId } = await fp.get(); // call once, per screened action
// → device_token: requestId ; device_hint (optional): visitorId
SEON (Fraud API v2) — permitted secondary
- Configure + start the SEON JS SDK in the browser (module toggles are applied server-side
in
config/seon.php; the browser SDK just collects). - Collect the encrypted session blob — a
getSession()-style call ⚠ (confirm the exact SEON SDK method name at integration) returns an encrypted base64sessionstring. That blob goes indevice_token. The backend POSTs it to SEON, which decrypts it server-side and returns the device details + verdict in one synchronous call. device_hintis generally n/a for SEON. SEON's stable device id (true_device_id) is only known server-side after the vendor call — it is not exposed to the browser — so there is no client-side stable id to send as a hint under the SEON path in v1.
// ⚠ ILLUSTRATIVE — confirm the SEON JS SDK config + session-collection method at integration.
seon.config({ /* … */ });
const session = await seon.getSession(); // encrypted base64 blob
// → device_token: session
The endpoint × field matrix
Two fields, both optional (nullable), never required by validation. The backend always
derives ip/user_agent itself from the request ($request->ip() / $request->userAgent()) — the
SPA must never send IP or user-agent claims; only the opaque vendor handle is read from the body.
| Endpoint | device_token | device_hint | Purpose |
|---|---|---|---|
POST /api/v1/register | ✅ max:8192 | ✅ max:256 | sign_up |
POST /api/v1/login (client) | ✅ max:8192 | ✅ max:256 | login |
POST /api/v1/mfa/challenge (client) | ✅ max:8192 | ✅ max:256 | login (completes an MFA login) |
POST /api/v1/backoffice/login (operator) | ✅ max:8192 | ✅ max:256 | login (central) |
POST /api/v1/payouts (initiate) | ✅ max:8192 | ❌ — ignored by design | payout |
POST /api/v1/payouts/{payout}/approve (vote) | ✅ max:8192 | ❌ — ignored by design | payout |
⚠ Load-bearing:
device_token/device_hintare TOP-LEVEL request params, not JSON:API attributes. Every endpoint above is a plain-params request body —device_tokenanddevice_hintsit flat alongsidepassword/code, exactly like this:{ "email": "…", "password": "…", "device_token": "<requestId>", "device_hint": "<visitorId>" }Do not nest them under a JSON:API
data.attributesenvelope — that envelope shape applies to responses in this app (see API conventions), not to these request bodies. Mis-nesting is silent on login/sign-up: both fields arenullableand login is fail-open, so a mis-nested (effectively missing) token just means screening never runs — no error, nothing to notice. On payout it surfaces only once the feature is enabled, and only as403 FRAUD_SCREENING_REQUIREDwith no indication that nesting was the cause — budget debugging time for this if a payout integration reports unexplained screening-required errors.
Notes grounded in the controllers:
device_hintis auth-surfaces-only. It is accepted on the four auth surfaces above and not on either payout endpoint (the maker/checker path readsdevice_tokenonly). The operator MFA challenge (POST /api/v1/backoffice/mfa/challenge) carries no device fields either — an operator login's screening happens on the password step.- MFA-enrolled client logins screen twice. The password step and the
POST /api/v1/mfa/challengeverify step both carry the device fields and both screen; the login completes on the verify request, so send a freshdevice_tokenon the challenge request too (do not reuse the login one). The backend's dedup window keeps this from double-charging the meter. device_tokensize ismax:8192everywhere (SEON's encryptedsessionblob is large — hence the generous ceiling);device_hintismax:256.
What device_hint actually does
device_hint is an optimisation hook, not a security input, and it is inert by default. It is
consumed only behind the default-OFF fraud.hint_reuse_enabled flag, and only on
login/sign-up (never payout): when enabled, the backend may match the hint against a same-tenant
cached Allow/Review verdict to skip a fresh vendor call. It is never persisted as a device
id, a cached Deny is never reused, and it changes nothing while the flag ships off. Sending
visitorId as the hint is safe and forward-useful; omitting it is equally fine. See the
fraud README "Client device-hint reuse" section.
Response contract + required UX
All device-risk outcomes are 403s in the standard JSON:API error envelope (see
API conventions); the discriminator is the error code. The
authoritative code table is in the foundation README.
| Status / code | Meaning | Required SPA behaviour |
|---|---|---|
403 FRAUD_BLOCKED | The device was denied — a real fraud verdict (terminal). Opens an operator-visible, appealable fraud case server-side. | Do NOT auto-retry. Show neutral, non-accusatory messaging ("we couldn't complete this right now; contact support") — never reveal a fraud signal or a device score. The action is over for this attempt. |
403 FRAUD_SCREENING_REQUIRED | Payout only — screening could not complete (token missing, vendor outage, misconfig) and there was no fresh step-up. Recoverable — not a deny. | Trigger the existing step-up MFA flow (POST /api/v1/mfa/step-up), then retry the same payout. A fresh step-up is the compensating control that lets it through. |
403 STEP_UP_REQUIRED | The existing sudo-window gate (e.g. the payout vote requires a fresh second factor). | Same as today: run POST /api/v1/mfa/step-up, then retry. This is the ordinary step-up flow, not device-specific. |
429 + Retry-After | A login rate-limit bucket tripped (see below). | Back off for Retry-After seconds and show a cooldown; do not hammer-retry. |
The POST /api/v1/mfa/step-up route shown above is the client route; operator/back-office
flows use the /api/v1/backoffice/ prefix instead (e.g. POST /api/v1/backoffice/mfa/step-up).
A passing screen is invisible to the SPA: there is no screening field in a success response to read or surface — a screened-and-allowed login/sign-up/payout just returns the normal 200/201 response as if screening never happened.
The two login limiter buckets (429)
POST /login (client + operator) and POST /api/v1/mfa/challenge are throttled by throttle:login,
which returns an array of two limits — whichever trips first 429s
(authentication README):
- a tight
(email|ip)bucket at 5/min; - an IP-independent identity-alone companion at 20/min (closes distributed IP-rotation).
Both return 429 with Retry-After (plus standard X-RateLimit-* headers). This limiter is
load-bearing precisely because an enabled device-risk feature makes each login a metered/paid
vendor call — respect the cooldown.
Silent fail-open on login vs. the payout posture
- Login / sign-up = fail-OPEN. If
device_tokenis omitted (or the agent was blocked, or the vendor is down), the login/sign-up proceeds with reduced protection — no error, no friction. A missing token never bricks a login. (This is why you can ship the fields before enablement.) - Payout = never-silent, fail-CLOSED-to-step-up. On the money path a screening that can't complete
is not silently ignored: it is durably recorded and the request is blocked with
FRAUD_SCREENING_REQUIREDunless a fresh step-up is present. So on a payout, omitting the token routes into the screening-required posture once the feature is enabled — collect a real token for payout submits.
Operational notes
- CSP
connect-src. The vendor agent phones home from the browser, so add the agent host(s) to your Content-Security-Policyconnect-src(andscript-srcif you load the SDK from the vendor CDN rather than bundling):- Fingerprint: the region endpoint you loaded —
api.fpjs.iooreu.api.fpjs.ioorap.api.fpjs.io(match the account region). - SEON: the SEON JS SDK / collection host ⚠ (confirm the exact host at integration).
- Fingerprint: the region endpoint you loaded —
- Ad-blockers are a real failure mode. Fingerprinting agents are commonly blocked by ad/tracker
blockers →
get()/getSession()fails → no token → the documented postures apply (login proceeds fail-open; a payout routes to screening-required). Handle a failed collection gracefully: submit without the token on login/sign-up; on a payout, surface the step-up path. Vendors offer a first-party proxy / custom-subdomain option to reduce blocking ⚠ (verify availability + setup against vendor docs); it is not wired server-side today. - Never persist or log
device_tokenclient-side. It is a single-use, per-event opaque handle — do not write it tolocalStorage/sessionStorage, application logs, or analytics. Collect a fresh one immediately before each submit (see the freshness note above) rather than storing one for reuse. - Privacy / consent. IP, user-agent, and device data are DLP-classified as Pii and encrypted at rest server-side (fraud README, DLP section). The frontend surface that collects device signals should align with the tenant's privacy policy / cookie-consent posture — flag this for legal review at enablement time, per jurisdiction.
- Sandbox testing. Both vendors offer sandbox modes / test keys ⚠ (confirm current setup at
integration). Because the feature is deploy-gated, end-to-end verification needs the
fraud.device-riskfeature enabled for a test tenant (andfraud.central_providerset for the operator/sign-up central surfaces). Note two known go-live gates from D102: the Fingerprint plan must include Smart Signals (an identification-only plan silently bands everything toAllow), and Fingerprint's per-second rate-limit ceiling is plan-scoped and unpublished (sandbox-confirm).
Where to read the backend side
- fraud README — the guard, the two stores, verdict mapping, dedup, the webhook, retention.
- authentication README — the device fields on the auth
commands, the
429login-limiter contract, the abilities feed. - foundation README — the
ErrorCodetable (FRAUD_BLOCKED/FRAUD_SCREENING_REQUIRED/STEP_UP_REQUIRED) and the bus gate order. - D102 — the go-live decision, deploy gates, and guard postures.
11-fraud-device-login.md— the program tracking file, including §7 where the "expose routed provider to the SPA" decision is tracked.