Device risk
A visitor ID for every browser on your sign-in, sign-up, password-reset and approval pages, like Fingerprint's visitorId, and the risk engine's view of it. The page identifies with your public site key and gets { visitorId, requestId, confidence }; your server reads the trusted result by requestId and passes it to sign-ins. A paid add-on, off until you turn it on (Device risk in the dashboard).
| Piece | Rule |
|---|---|
visitorId | tqv_…, this browser in your workspace only: another Touchque customer sees another one for the same browser. It survives cleared site data, private windows where the browser shows its traits, and browser updates. |
confidence | 0.99 sealed by the browser's device-bound key, 0.95 its stored id, up to 0.9 a match of its traits (lower for identical fleet laptops), 0.5 to 0.6 a first sight. |
requestId | dre_…, one identification: for one account, for 15 minutes. Another account gets device_report_replayed. |
network | The address of the identification as the risk engine saw it: country, region and city (never coordinates), the operator (ASN), the kind of network (isp, hosting, tor) and whether the browser's clock fits the address (timezoneMismatch: a VPN or proxy in another country). |
velocity | This visitor's counts: accounts in 24 h and 30 days, sign-ups and countries in 24 h, stored-id resets in 7 days (the risk engine's), identifications in the last hour and day. |
bot | One answer to "a bot?": detected (the browser says it is driven: webdriver, a headless browser), suspected (the pointer moved like software, a zero-size window, software graphics) or not_detected, with the reasons. A summary of the signals: the risk engine still decides. |
| Site key and origins | The site key (tqpk_…) is public; Touchque answers only pages on your organization's verified domains (Organization → Domains) and your listed origins. identify is a text/plain POST, so no CORS preflight: allow your Touchque API host in connect-src. |
The script tag (script.js, with your site key and its version's integrity hash from Device risk → Settings; a page that loads …/device.js, its first name, keeps working with the same hash) identifies the browser when the page loads and puts the requestId into a hidden touchque_request_id field of every form, forms added later too; it takes a new one after 10 minutes and after each submit. On submit it adds how the pointer and the fingers moved, after the requestId and a dot: 18 characters of counts the browser computed (presses on a jumped pointer and how long they lasted, how straight long strokes and swipes were, how long taps lasted), never a position or a path; data-pointer="off" leaves it out. Your server passes that field to deviceRisk.check as it came. Single-page apps read window.TouchqueDevice (ready, value(), requestId, refresh()); the 1.6.0 tag set window.TouchQueDevice.
<script src="https://cdn.touchque.com/device/v2.0.0/script.js" data-site-key="tqpk_…" data-purpose="sign_in"
integrity="sha384-a69uLWQn3eccd/RGOZIKDLN16dOQIXvX2eGDlOUYpmMMq4QVyqiTcuoRgT17JGLG" crossorigin="anonymous" async></script>report: missing, unknown or expired) with device_identification_missing, which only ever asks for a number-matched step-up and starts in shadow like the others.login.request, risk.check with deviceRiskRequestId) and for pages before an account (POST /device-risk/check). Every signal only ever asks for more: a number-matched approval or a passkey, on a sign-up your own extra check. None blocks on its own, none lets a check be skipped, and a sign-in with one is never silent. Signals start in shadow (reported, enforced: false); a visitor your admin marked "Step up always" asks at once.| code | Meaning | Asks for |
|---|---|---|
device_tampered | Browser functions replaced by script, or traits that contradict each other (an anti-detect browser, a spoofing extension). | number_match |
device_virtual | A virtual machine, software graphics, a headless or automated browser. | number_match |
device_geo_mismatch | The browser's clock is 2 h or more off the address's time zone (a proxy, VPN or remote access). | passkey |
device_many_accounts | 3 accounts in 24 h, or 6 in 30 days, on one visitor (not counted for identical fleet laptops). | number_match |
device_signup_burst | 3 sign-ups in 24 h from one visitor. | number_match |
device_id_resets | The stored id cleared twice in 7 days and back as the same visitor (private windows not counted). | number_match |
device_many_countries | Addresses in 3 countries in 24 h for one visitor. | number_match |
device_wrong_site | The identification was made on a site that is not yours (a look-alike or proxy page). | passkey |
device_report_replayed | An identification used for another account, or a report sent twice. | passkey |
device_private_window | A private window: context only, never asks on its own. | none |
device_step_up_by_admin | Your admin marked the visitor "Step up always": enforced at once. | number_match |
device_identification_missing | Your server checked a page that sent no identification, or one that does not verify (unknown, expired, another workspace's): the tag was removed or blocked. Ad blockers do that to honest people too. | number_match |
device_pointer_synthetic | The pointer or a finger moved like software, not a hand: every press landed where the pointer had just jumped and lasted under 10 ms, taps of under 10 ms, or ruler-straight strokes or swipes (Playwright, Puppeteer, Selenium, AI browser agents). From the counts the tag sends with the form, never a position or a path. No movement at all (a keyboard, a screen reader) is never a sign; Voice Control and Switch Control move like this too, so it only asks. | number_match |
The page reads coarse traits (OS, browser family and major version, time zone, languages, screen size, cores, memory class, touch points, the graphics card's name), short hashes of how the browser draws a test text, plays a test sound, which of 25 common fonts it has, a few Math results and its WebGL limits, and findings (replaced browser functions, a virtual machine, a headless browser, a private window where detectable). Touchque keeps no raw trait: keyed hashes with a key of your workspace alone, coarse labels, the account, the country and a keyed hash of the address's network, for 30 to 90 days as you set; the address itself for a day at most. Never a MAC address, a serial number or a cross-customer device graph.
Ad blockers and browser privacy lists drop scripts and calls to known third-party fingerprinting hosts, honest visitors' too (they then get Identification missing, a step-up at most). Large sites serve the tag and its identify call from their own domain instead, like Fingerprint's proxy integrations: GET /_tq/device/vX.Y.Z/script.js goes to https://cdn.touchque.com/device/vX.Y.Z/script.js (the same bytes, so the same integrity hash) and POST /_tq/device-risk/identify to https://api.touchque.com/device-risk/identify. The tag then points at your domain, and your Content-Security-Policy needs only 'self':
<script src="https://www.example.com/_tq/device/v2.0.0/script.js" data-site-key="tqpk_…" data-purpose="sign_in" data-api="https://www.example.com/_tq"
integrity="sha384-a69uLWQn3eccd/RGOZIKDLN16dOQIXvX2eGDlOUYpmMMq4QVyqiTcuoRgT17JGLG" crossorigin="anonymous" async></script>The proxy secret: behind your proxy every visitor reaches Touchque from your proxy's address. Send the visitor's address as X-Touchque-Client-IP with your workspace's proxy secret as X-Touchque-Proxy-Secret (tqps_…, Device risk → Settings for owners and admins; GET /device-risk/settings with the API key). Touchque then records and scores the visitor's address and counts the per-address limit (60 identifications a minute) on it, not on your proxy. Keep the secret on the server like the API key, never in a page or in git. A wrong one is 403 proxy_secret_invalid; replacing the site key replaces it. Without it the proxy still works, but every visitor counts against the proxy's one address: small sites only. Drop your own cookies on the way, pass Origin on as it came (Touchque checks it against your domains and origins), and pass the visitor's address as clientIp to deviceRisk.check on your server.
location /_tq/device/ {
proxy_pass https://cdn.touchque.com/device/;
proxy_ssl_server_name on;
proxy_set_header Cookie "";
proxy_set_header Authorization "";
}
location = /_tq/device-risk/identify {
limit_except POST { deny all; }
proxy_pass https://api.touchque.com/device-risk/identify;
proxy_ssl_server_name on;
proxy_set_header Cookie "";
proxy_set_header Authorization "";
proxy_set_header X-Forwarded-For "";
proxy_set_header X-Touchque-Proxy-Secret "tqps_…"; # Device risk → Settings; keep this file out of git
proxy_set_header X-Touchque-Client-IP $remote_addr; # behind a load balancer: set_real_ip_from + real_ip_header first
}// Route: www.example.com/_tq/* Secret: wrangler secret put TOUCHQUE_PROXY_SECRET
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (request.method === 'GET' && url.pathname.startsWith('/_tq/device/')) {
return fetch('https://cdn.touchque.com/device/' + url.pathname.slice('/_tq/device/'.length));
}
if (request.method === 'POST' && url.pathname === '/_tq/device-risk/identify') {
return fetch('https://api.touchque.com/device-risk/identify', {
method: 'POST',
headers: {
'Content-Type': 'text/plain',
Origin: request.headers.get('Origin') || '',
'X-Touchque-Proxy-Secret': env.TOUCHQUE_PROXY_SECRET,
'X-Touchque-Client-IP': request.headers.get('CF-Connecting-IP') || '',
},
body: await request.text(),
});
}
return new Response('Not found', { status: 404 });
},
};// middleware.ts (Next.js 16: proxy.ts, export function proxy). TOUCHQUE_PROXY_SECRET in the environment.
import { NextRequest, NextResponse } from 'next/server';
export const config = { matcher: '/_tq/:path*' };
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
if (req.method === 'GET' && pathname.startsWith('/_tq/device/')) {
return NextResponse.rewrite(new URL('https://cdn.touchque.com/device/' + pathname.slice('/_tq/device/'.length)));
}
if (req.method === 'POST' && pathname === '/_tq/device-risk/identify') {
const r = await fetch('https://api.touchque.com/device-risk/identify', {
method: 'POST',
headers: {
'Content-Type': 'text/plain',
Origin: req.headers.get('origin') ?? '',
'X-Touchque-Proxy-Secret': ({}).TOUCHQUE_PROXY_SECRET ?? '',
// On Vercel the first x-forwarded-for entry is the visitor; behind your own load balancer, its header.
'X-Touchque-Client-IP': (req.headers.get('x-forwarded-for') ?? '').split(',')[0].trim(),
},
body: await req.text(),
});
return new Response(r.body, { status: r.status, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } });
}
return new Response('Not found', { status: 404 });
}{
"rewrites": [
{ "source": "/_tq/device/:path*", "destination": "https://cdn.touchque.com/device/:path*" },
{ "source": "/_tq/device-risk/identify", "destination": "https://api.touchque.com/device-risk/identify" }
]
}Limits: a visitor's address gets 60 identifications a minute; a site key, and a workspace's calls to deviceRisk.check, get 1,200 a minute each. Above a limit the call answers 429 with Retry-After. Device risk → Settings shows your number (ratePerMinute in GET /device-risk/settings); a bigger site asks support (support@touchque.com) with its expected traffic and Touchque raises it for the workspace.
Vercel rewrites cannot add the proxy secret: use them for small sites, or the Next.js middleware (also on Vercel) for the identify path at volume. With a verified Touchque Login custom domain there is nothing to proxy: Touchque serves both paths on it, and Device risk → Settings shows the tag with data-api="https://login.example.com/_tq".
A busy site has millions of identifications, so nothing lists them all: GET /device-risk/events (deviceRisk.searchEvents in every SDK, the Identifications tab in the dashboard) answers one page, newest first, for a date range of at most 90 days within your retention (7 by default), filtered by signal, stepUp, page (kind), origin, ip (an address or a CIDR range like 203.0.113.0/24; never returned), account, visitorId or requestId, each on an index. Pass nextCursor back as cursor until it is null (1 to 100 a page); do not page by moving from/to. A search that would read too much answers 422 search_too_broad: narrow the range or add a filter. For everything at once: a data request (Organization → Data requests, JSON or CSV, one row per identification, no address), the webhooks and the log stream.
Device risk → Overview shows the last 30 days, 90 days or 12 months per day (identifications with the step-ups, signals with the enforced part; from daily counts kept 400 days, longer than the identifications themselves) and the recent identifications, updated every 10 seconds while the page is open. Alerts (Device risk → Settings → Alerts) tell your people when something is going on; each rule has an on/off and a threshold and alerts at most once an hour. They go to your webhook and by email to your organization's owners and admins (once a mail sender is set up). An alert never changes a sign-in: the risk engine decides those.
| Alert | Goes off when | Default |
|---|---|---|
signup_burst | One visitor signed up this many times in 24 h | 5 |
many_accounts | One visitor on this many accounts in 24 h | 5 |
tamper_spike | This many identifications with a tampered or virtual browser in an hour | 20 |
Your webhook (Developers → Webhooks) gets DEVICE_RISK_SIGNAL when an identification or a check raises a signal that asks for more (enforced or in shadow; page loads of the same visitor with the same signals once in 10 minutes, every check), and DEVICE_RISK_ALERT when an alert goes off. Signed like every event. They carry the requestId, visitorId, page, account when known, confidence, signals and the engine's verdict with its case code; never a trait. For an SSO organization with a log stream (Splunk, Datadog, Sentinel, webhook, S3), the same events also go to that stream, category device_risk.
| Endpoint | Who |
|---|---|
POST /device-risk/identify | The page (site key) |
GET /device-risk/events/{requestId} | Your server: the trusted identification and the engine's verdict |
POST /device-risk/check | Your server: an identification for an account (sign-up, reset) |
login.request, risk.check | deviceRiskRequestId |
GET /device-risk/events | Your server or the dashboard: search identifications (filters, an opaque cursor) |
GET /device-risk/trend | The Overview's chart over 30, 90 or 365 days |
GET /device-risk/recent | The latest identifications (the Overview's live feed) |
DEVICE_RISK_SIGNAL, DEVICE_RISK_ALERT | Webhook events to your server |
// The page: when the sign-up, sign-in, reset or approval form is sent (without the script tag)
import { deviceRisk, REQUEST_ID_FIELD } from '@touchque/web';
const id = await deviceRisk.identify({ siteKey: 'tqpk_…', purpose: 'sign_up' }); // { visitorId, requestId, confidence }
form.elements[REQUEST_ID_FIELD].value = id?.requestId ?? ''; // the hidden touchque_request_id field, the one the tag fills
// Your server (Node): never trust the page's copy; missing or empty is an answer too
const r = await tq.deviceRisk.check(req.body.touchque_request_id, { event: 'sign_up', externalUsername: email, clientIp: req.ip });
if (r.stepUp) askForEmailCode(); // ask for more, never refuse
if (r.requestId) {
const event = await tq.deviceRisk.getEvent(r.requestId); // the trusted identification
}
// For sign-ins, instead of check: pass the field to login.request as it came
await tq.login.request({ externalUsername, type: 'LOGIN', clientIp: req.ip, deviceRiskRequestId: req.body.touchque_request_id });<!-- One line on your sign-in, sign-up, password-reset and approval pages -->
<script src="https://cdn.touchque.com/device/v2.0.0/script.js" data-site-key="tqpk_…" data-purpose="sign_in"
integrity="sha384-a69uLWQn3eccd/RGOZIKDLN16dOQIXvX2eGDlOUYpmMMq4QVyqiTcuoRgT17JGLG" crossorigin="anonymous" async></script>
<!-- It fills a hidden touchque_request_id field in every form; a form's own purpose wins: -->
<form method="post" action="/signup" data-touchque-purpose="sign_up">…</form>
<!-- Your server (one line, the field as it came):
const r = await tq.deviceRisk.check(req.body.touchque_request_id, { event: 'sign_up', externalUsername: email });
if (r.stepUp) askForEmailCode(); -->field = request.form.get('touchque_request_id')
r = tq.device_risk.check(field, event='sign_up', external_username=email, client_ip=ip)
if r['stepUp']:
ask_for_email_code()
if r['requestId']:
event = tq.device_risk.get_event(r['requestId'])
# For sign-ins, instead of check:
tq.login.request(user, client_ip=ip, device_risk_request_id=field)$field = $_POST['touchque_request_id'] ?? null;
$r = $tq->deviceRisk->check($field, 'sign_up', $email, $ip);
if ($r['stepUp']) { askForEmailCode(); }
if ($r['requestId']) { $event = $tq->deviceRisk->getEvent($r['requestId']); }
// For sign-ins, instead of check:
$tq->login->request($user, clientIp: $ip, deviceRiskRequestId: $field);field := req.FormValue("touchque_request_id")
r, err := client.DeviceRisk.Check(ctx, touchque.DeviceRiskCheckOptions{RequestID: field, Event: "sign_up", ExternalUsername: email, ClientIP: ip})
if err == nil && r.StepUp { askForEmailCode() }
if err == nil && r.RequestID != "" {
event, _ := client.DeviceRisk.GetEvent(ctx, r.RequestID)
_ = event
}
// For sign-ins, instead of check:
_, err = client.Login.RequestWithOptions(touchque.LoginRequestOptions{ExternalUsername: email, Type: "LOGIN", ClientIP: ip, DeviceRiskRequestID: field})string? field = Request.Form["touchque_request_id"];
var r = await tq.DeviceRisk.CheckAsync(field, new() { Event = "sign_up", ExternalUsername = email, ClientIp = ip });
if (r.StepUp) AskForEmailCode();
if (r.RequestId is { } requestId) { var ev = await tq.DeviceRisk.GetEventAsync(requestId); }
// For sign-ins, instead of check:
await tq.Login.RequestAsync(new LoginRequestOptions { ExternalUsername = email, ClientIp = ip, DeviceRiskRequestId = field });String field = request.getParameter("touchque_request_id");
var r = tq.deviceRisk().check(DeviceRisk.CheckOptions.ofRequest(field)
.event("sign_up").externalUsername(email).clientIp(ip));
if (r.stepUp()) askForEmailCode();
if (r.requestId() != null) { JsonNode event = tq.deviceRisk().getEvent(r.requestId()); }
// For sign-ins, instead of check:
tq.login().request(new LoginRequestOptions(email).clientIp(ip).deviceRiskRequestId(field));field = params[:touchque_request_id]
r = tq.device_risk.check(field, event: "sign_up", external_username: email, client_ip: ip)
ask_for_email_code if r["stepUp"]
event = tq.device_risk.get_event(r["requestId"]) if r["requestId"]
# For sign-ins, instead of check:
tq.login.request(email, client_ip: ip, device_risk_request_id: field)# The page (public site key, from your own origin, text/plain)
POST /device-risk/identify
{ "siteKey": "tqpk_…", "report": "<deviceRisk.collect()>" }
→ { "visitorId": "tqv_…", "requestId": "dre_…", "confidence": 0.99 }
# Your server (signed request)
GET /device-risk/events/dre_…
POST /device-risk/check { "requestId": "dre_…", "event": "sign_up", "externalUsername": "ayse@example.com", "clientIp": "88.230.12.4" }