Device token and challenges

Add the device script to your pages, send its token with each sensitive event, and report the outcome of a step-up challenge.

The fraud prevention API reads more than the fields your server sends. A small script on your pages gives each sensitive action a fresh device token, and when a decision asks for proof (step_up), you tell your instance how the proof went. This page covers both.

Include the script

openshield serves the script, a copy of openfraud's sdk/device/device.js at version 0.10.0, from its own address. Every version has a path of its own that never changes:

<script
  type="module"
  src="https://openshield.dev/sdk/device/v0.10.0/device.js"
  integrity="sha384-my0AS6e8mdAdZ2QK46oa1ECvbPpPt2QTxiSVeBYFaLmhi+Pa9lAomhJ4WpE0jxLK"
  crossorigin="anonymous"
></script>

The integrity attribute is the Subresource Integrity hash of that exact file: the browser refuses a script that differs by one byte. A versioned file is served with cache-control: public, max-age=31536000, immutable and with CORS open, so the hash can be checked from your site.

https://openshield.dev/sdk/device/latest/device.js redirects to the newest version. It answers a temporary redirect (302) that browsers do not cache. A redirect cannot carry the CORS header that an integrity check needs, so use latest without integrity, for a quick try, and pin a version with its hash in production.

To import it from your own module instead:

import { deviceToken } from "https://openshield.dev/sdk/device/v0.10.0/device.js"

The script keeps a random 128-bit id in your site's localStorage, under the key openfraud_device. It reads nothing from the canvas, the fonts, the audio or the hardware. Storing the id is subject to the ePrivacy rules in the EU (Article 5(3)): decide whether it is strictly necessary for security, or ask for consent. forgetDevice() removes it when a user withdraws consent.

Send a token with each sensitive event

Call deviceToken() just before each sensitive request: a login, a password change, a payment. It returns a new token every time, made of the device id, a random nonce, the time, the time zone, the platform, touch support and the automation flag. Send it to your server with the form, and your server puts it in the event as device_token:

// In the browser
import { deviceToken } from "https://openshield.dev/sdk/device/v0.10.0/device.js"

await fetch("/login", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ email, password, device_token: deviceToken() }),
})
// On your server, with the end user's context (see "Send the end user's context")
const decision = await fraud.decide({
  type: "session.login",
  actor_id: user.id,
  email,
  ip: endUserIp,
  user_agent: endUserAgent,
  country: endUserCountry,
  device_token: body.device_token,
})

Make a new token for each submit. A token is single use: sending the same one twice for the same event type is a replay.

What your instance reads

Your instance checks the token against the request and keeps the result in the event, as event.device_token:

ValueMeaningSignal
okrecent, not used before for this event type, and readablenone
staleolder than 120 secondsinvalid_device_data
replayedits nonce was used before for the same event typereplayed_device_data
invalidit cannot be readinvalid_device_data
missingno token, and the setting below is onmissing_device_data

From a readable token it fills event.device_info: timezone, platform (windows, macos, ios, android, linux, linux_arm, chromeos or other), touch and webdriver. It compares the platform with your user_agent (spoofed_device) and the time zone with the country (timezone_area_mismatch), and a webdriver flag marks an automated browser (headless_browser). The device id in the token becomes the event's device when the event has none. The token itself is not stored.

The script holds no secret, so a token proves freshness and single use, not authenticity: a determined attacker can forge one. It stops the replay of a captured request and catches automated browsers that do not hide.

Turn on require_token

Once every page that sends a sensitive event sends a token, turn on Require the device token in the dashboard, under Fraud settings. From then on an event without a token counts as missing and raises missing_device_data. Turn it on before every page sends one and your own legitimate events are flagged. The signals show in the dashboard under Events, in plain words with the raw name beside.

The challenge loop

A step_up decision means: ask for proof before you go on (MFA, a confirmation email, a CAPTCHA). Your instance learns from how the proof went, and a passed proof earns the device some trust.

  1. Decide. A login answers step_up.
  2. Show your challenge and report that you did, with challenge.requested.
  3. Report the outcome, challenge.succeeded or challenge.failed, with the account and the device.
const decision = await fraud.decide({ type: "session.login", actor_id: user.id, device_token, ip, user_agent, country })

if (decision.decision === "step_up") {
  await fraud.track({ type: "challenge.requested", actor_id: user.id, device: deviceId })
  const passed = await askForProof(user) // your own MFA, email code or CAPTCHA
  await fraud.track({
    type: passed ? "challenge.succeeded" : "challenge.failed",
    actor_id: user.id,
    device: deviceId,
  })
  if (!passed) return refuse()
}

deviceId is the id of the device (dev_...), which your instance read from the token of the login. Send device on challenge.succeeded: it is the device that becomes trusted.

Your server sends the outcome, after it checked the proof itself (the code matched, the MFA answer verified). Never pass on an outcome your page reports: a page can claim anything, so an event marked "reported_by": "client" loses its account and trusts no device (see events a page reports).

What the instance does with it:

  • A pass trusts the pair for 14 days. challenge.succeeded puts the account and the device on the trusted_devices list for 14 days. The next logins from that device are allowed without asking for proof again, and each new pass restarts the 14 days. A device is trusted for one account only: on another account it is a new device.
  • Three failures in an hour are a hold. Three challenge.failed on an account within an hour fire the rule ato_failed_challenges, which holds the account's next sensitive actions until the owner confirms.
  • A lost device is archived. When the owner reports a lost device, archive the pair on trusted_devices with a fraud key, or in the dashboard under Lists: POST /fraud/v1/lists/trusted_devices/items/archive with {"items": [{"value": "u_123", "second": "dev_8f2c41"}]}, the values as the list shows them.

These rules start in shadow like every rule that is not a block: until they are promoted (see when rules start to act), the answers list what they would have done. challenge.requested and the outcomes themselves are recorded from the first call.

Sandbox mock tokens

Coming soon. A sandbox instance will read a mock token such as test|device:d1|verdict:deny|score:80|ip:BE and answer from it, so your tests can drive every branch of this loop (allow, step up, block) without writing a rule. openshield does not offer a sandbox yet: a test| token sent to a regular instance is invalid.