@trustsig/client
v2.12.0
Published
Browser client for TrustSig bot protection — dynamic script injection and token retrieval
Maintainers
Readme
@trustsig/client
Browser loader for TrustSig. It injects the edge script, runs the device analysis, and hands your app a token to send to your backend.
| Class | Edition | Key | Import |
| --- | --- | --- | --- |
| TrustSigClient | Vanilla bot protection | site key | @trustsig/client |
| TrustSigProClient | Pro fraud intelligence | project publishable key (UUID) | @trustsig/client or @trustsig/client/pro |
For React, use @trustsig/react.
Install
npm install @trustsig/clientESM and CommonJS builds ship with TypeScript types. Building requires Node.js 18 or newer.
Get started
import { TrustSigClient } from '@trustsig/client';
const client = new TrustSigClient({ siteKey: 'YOUR_SITE_KEY' });
const response = await client.getResponse();
await fetch('/api/action', {
method: 'POST',
headers: { 'X-TrustSig-Response': response?.token ?? '' },
body: JSON.stringify({ data: '...' }),
});getResponse() resolves to { request_id, token } or null. Verify the token on your server with @trustsig/server.
X-TrustSig-Response is a convention, not a protocol. Any transport works as long as both ends agree.
How it works
load()appends<script src="https://edge.trustsig.eu/sdk/trustsig.js">with your configuration ondata-*attributes.getResponse()andscan()call it for you.- The script analyses the device. With
autoScanon, the default, it announces the first result on atrustsig:readywindow event, which the client caches. getResponse()returns that cached result, or runs ascan()when none has arrived.
getResponse() and scan() never throw. They resolve to null under server-side rendering, on a script load failure or timeout, and on a scan that produced no usable token. Set debug: true to log the reason.
Manual scanning
With autoScan: false, nothing runs until you call scan(). Set keepFresh too, or the token ages out and server-side verification starts failing closed.
const client = new TrustSigClient({
siteKey: 'YOUR_SITE_KEY',
autoScan: false,
keepFresh: true,
});
const response = await client.scan();Consent
With requireConsent: true, the script holds DOM and keystroke capture until you release it. Device telemetry is collected either way.
const client = new TrustSigClient({ siteKey: 'YOUR_SITE_KEY', requireConsent: true });
onCookieBannerAccept(() => client.setConsent(true));
onCookieBannerReject(() => client.setConsent(false));Keystroke capture records timing only: when keys were pressed and released. The key itself is never read, stored, or transmitted, so no typed content leaves the page.
Pointer behaviour
verifyRemote judges the token minted on page load. Movement after that is not in it, so a verify on form submit races the next flush.
flushMouse({ behavior: true }) flushes the pointer stream now and resolves with an opaque handle. Your backend exchanges it for the session's pointer result.
const handle = await client.flushMouse({ behavior: true, deviceId: true });
await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...order, behavior_token: handle }),
});deviceId: truewaits for a scan to resolve the device id, so the exchange can answer with it. Without it the exchange reportsdevice.status: 'not_requested'.- Handles are valid for 30 minutes. A later handle supersedes an earlier one for the same session, so send the newest you hold.
- The handle is sealed to your project. A page cannot inspect it or mint one.
flushMouse()with no arguments resolves with'', as does any call the script could not mint a handle for.
Receive handles passively
Register onBehaviorReady once and every scheduled flush hands you a handle, so one is ready before the visitor submits.
let behaviorToken = '';
const stop = client.onBehaviorReady(({ token }) => { behaviorToken = token; });Exchange the handle
getBehavior posts the handle to POST /api/v1/behavior. It is billed as a verification. Pro is the same call on TrustSigPro.
import { TrustSig } from '@trustsig/server';
const ts = new TrustSig({ secretKey: process.env.TRUSTSIG_SECRET_KEY });
const behavior = await ts.getBehavior(handle);
behavior.behavior.state; // 'none' | 'idle' | 'partial' | 'rich'
behavior.behavior.human_score; // 0 automated .. 100 human, null when unjudged
behavior.behavior.scored_at; // when the newest scored batch arrived
behavior.device.id; // the device id, when deviceId: true was asked for
behavior.error; // set when the session could not be read at allhuman_score is null unless the session cleared the evidence bar. Read state and samples first, and check error before treating absent evidence as a finding. Full response shape in the @trustsig/server README.
This is a different shape from behavior.mouse on a verify response. getBehavior answers with state, factors, scored_batches, session_ended, scored_at and model; behavior.mouse answers with verdict, coverage, analyzed and sufficient_data. Both carry human_score, automated, samples and batches.
Automatic flushes
Nothing here needs configuring. Flushes go out:
- Once the cursor has produced enough movement to judge, and again right after the first scan returns.
- On a click,
Enter, a form submit, a route change, or enough accumulated movement. The first two are rate limited to one per 3 seconds, the rest to one per 5 seconds. - On an interval backstop at 10s, then 20s, then 45s, relaxing to 2 minutes past five minutes of session age. An interval with no new samples sends nothing.
The front-loading is what lets a verification during a signup or checkout see a session the model can judge.
Device id
getDeviceId() resolves the project-scoped device id: 16 lowercase hex characters, the same value /verify publishes as identity.device_id.
const deviceId = await client.getDeviceId(); // "3f2a9c14b7e05d68"
const quick = await client.getDeviceId({ timeout: 2000 }); // null if not ready in 2s- It is minted on the edge and exists only once a scan returns, so a call before that waits for the next scan and starts one when none is in flight.
options.timeoutbounds that wait in milliseconds. Default30000;0waits indefinitely; a timeout resolvesnull, as does an unloaded script.- It is never stored in the browser. Stable across reloads for the same device, and different for that device on another project.
- The same id rides
trustsig:readyasdetail.device_idwith no wait. - Do not use it as an authentication factor. It identifies a device, not a person, and a determined visitor can produce a new one.
Read the token without waiting
getCachedToken() returns whatever token is in memory right now, synchronously, and never starts a scan.
const token = client.getCachedToken();
if (token) headers['X-TrustSig-Response'] = token;It answers null before the first scan resolves, on an unloaded script, and after a failed scan, where the script holds an ERROR: marker rather than a token.
Content Security Policy
The script boots a sandbox iframe on the asset origin and talks to the edge from inside it, so script-src alone is not enough. A default PROD setup needs:
script-src https://edge.trustsig.eu;
frame-src https://edge.trustsig.eu;
worker-src https://edge.trustsig.eu blob:;
connect-src https://edge.trustsig.eu;For Pro, substitute https://epro.trustsig.eu. Pass nonce to put your Content Security Policy (CSP) nonce on the injected <script>.
A blocked iframe fails quietly: the <script> still loads, no error surfaces, and getResponse() resolves to null after the script's watchdog.
Hidden form field
With autoScan on, the script injects a hidden input[name="trustsig-response"] into every form and patches HTMLFormElement.prototype.submit. A backend reading that field instead of a header can receive the literal ERROR:pending before the scan finishes. Treat any ERROR:-prefixed value as "no token".
API reference
new TrustSigClient(options)
Throws SITE_KEY_REQUIRED when siteKey is missing.
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| siteKey | string | required | Your public site key. |
| autoScan | boolean | true | Analyses on load and announces the result on trustsig:ready. Sets data-auto-scan. |
| interceptRequests | boolean | false | Has the script attach the token header to every fetch and XMLHttpRequest the page makes, cross-origin included. Sets data-intercept-requests. |
| requireConsent | boolean | false | Holds DOM and keystroke capture until setConsent(true). Sets data-require-consent. |
| keepFresh | boolean | false | Refreshes the token in the background when autoScan is off. Ignored when autoScan is on. Sets data-keep-fresh. |
| debug | boolean | false | Sends swallowed errors and timeouts to console.warn. |
| nonce | string | none | Content Security Policy nonce for the injected <script>. |
| env | TrustSigEnv | PROD | PROD, STAGING, or DEV. Selects the script origin (edge, staging-edge, dev-edge). |
| scriptUrl | string | env-derived | Overrides the script URL. Read the warning below first. |
| scriptTimeoutMs | number | 10000 | Rejects script injection if it has not loaded in time. |
scriptUrl is not a plain content delivery network override. The script resolves its API origin from its own src and trusts only *.trustsig.eu and loopback hosts. Serving it from your own domain sends all telemetry to the Vanilla production edge regardless of env.
Methods
| Method | Returns | Notes |
| --- | --- | --- |
| load() | Promise<void> | Injects the script. Idempotent per instance. Rejects with SCRIPT_LOAD_FAIL or SCRIPT_LOAD_TIMEOUT. Resolves immediately with no window. |
| getResponse() | Promise<TrustSigResponse \| null> | Cached auto-scan result, the token the script already holds, or a fresh scan(). |
| getCachedToken() | string \| null | The token in memory right now. Never scans. |
| scan() | Promise<TrustSigResponse \| null> | Fresh analysis, ignoring the cache. |
| setConsent(granted) | void | Grants or withdraws consent for DOM and keystroke capture. No-op before the script loads. |
| flushMouse(options?) | Promise<string> | Flushes buffered pointer samples. With { behavior: true } resolves with a behaviour handle, otherwise ''. |
| onBehaviorReady(cb) | () => void | Calls cb({ token, at }) for every handle the script mints. Returns an unsubscribe function. |
| getDeviceId(options?) | Promise<string \| null> | The project-scoped device id. options.timeout in milliseconds, default 30000, 0 waits indefinitely. |
TrustSigResponse is { request_id: string; token: string }.
normalizeScanResult(result)
Turns a raw window.TrustSig.scan() result into TrustSigResponse | null, returning null for a missing or ERROR:-prefixed token. Use it when you listen for trustsig:ready yourself.
import { normalizeScanResult } from '@trustsig/client';
window.addEventListener('trustsig:ready', (e) => {
const response = normalizeScanResult(e.detail);
if (response) console.log(response.token);
});TrustSig Pro
TrustSigProClient loads the Pro edge script from the epro origin with your project's publishable key, a UUID shown in the console next to the project. It adds session handles and action proofs on top of the Vanilla surface.
Do not mount both editions on the same page. They share one script build, which refuses to initialise twice, so the second loader is inert and its key is discarded. The Pro caller then holds a Vanilla token, which Pro verification rejects.
Get started with Pro
import { TrustSigProClient } from '@trustsig/client';
const client = new TrustSigProClient({ publishableKey: 'YOUR_PUBLISHABLE_KEY' });
const response = await client.getResponse();
await fetch('/api/login', {
method: 'POST',
headers: { 'X-TrustSig-Response': response?.token ?? '' },
body: JSON.stringify({ email }),
});getResponse() resolves to { request_id, token, session_handle? } or null. Everything above applies unchanged, against the epro origin.
Session handles
Your backend gets a session_handle (pss_...) back from verify. Store it, and later events and interactions can name this device without another scan.
const client = new TrustSigProClient({
publishableKey: 'YOUR_PUBLISHABLE_KEY',
persistSession: 'local',
});
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'X-TrustSig-Response': (await client.getResponse())?.token ?? '' },
});
client.absorbSessionHandle(await res.json());
await fetch('/api/settings', { headers: { ...client.sessionHeaders() } });
client.clearSession();absorbSessionHandle accepts the bare handle, a verify response, an event verdict, or anything else carrying session_handle, and ignores anything that is not one. A response that minted no handle never clears the one you already had. Call clearSession() on sign-out and on a privacy reset.
Four properties to know before you rely on a handle:
persistSessionis off by default. Nothing is written to the user's browser until you set it.- Storage is on your own origin under
trustsig.pro.session. TrustSig never reads it. A browser that blocks storage falls back to memory instead of throwing. - A handle names one scan, not the device. Two scans on the same machine give unrelatable handles.
- A handle attributes, it does not decide. It expires after 30 days and is a bearer string, so treat it like a session cookie. Call
verifyat the moment you decide.
Action proofs
An action proof shows a live browser session answered a challenge for one specific request. Your backend mints the challenge with createChallenge and verifies the answer offline with verifyProof, both from @trustsig/server.
fetchWithProof handles the round trip. Call your endpoint as usual. On a 401 with a { trustsig_challenge: {...} } body, the client proves the challenge and repeats the request once with X-TrustSig-Proof and X-TrustSig-Challenge set. Any other response is returned untouched.
const res = await client.fetchWithProof('/api/transfer', {
method: 'POST',
body: JSON.stringify({ amount: 500 }),
});Use prove() when you drive the exchange yourself. It takes { nonce, bind?, difficulty? }.
const proof = await client.prove(challenge);
await fetch('/api/transfer', {
method: 'POST',
headers: { 'X-TrustSig-Proof': proof, 'X-TrustSig-Challenge': challenge.nonce },
});prove() never rejects. A browser that cannot answer resolves to a refusal string ({"v":0,"unsupported":true,"reason":"..."}) that your backend reads as reason: 'unsupported', so you decide per route whether that fails open or closed.
new TrustSigProClient(options)
Throws PUBLISHABLE_KEY_REQUIRED when publishableKey is missing. Same options as TrustSigClient, with these differences:
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| publishableKey | string | required | Project publishable key (a UUID). Safe to expose client-side. |
| persistSession | 'local' \| 'session' \| 'memory' | 'memory' | Where to keep the session handle. local survives a tab close, session lasts the tab, memory lasts the page. |
| env | TrustSigEnv | PROD | Selects the Pro script origin: epro, staging-epro, dev-epro. |
Pro methods
Everything on TrustSigClient, plus:
| Method | Returns | Description |
| --- | --- | --- |
| prove(challenge) | Promise<string> | Answers a challenge. Never rejects. |
| fetchWithProof(input, init?) | Promise<Response> | fetch that answers a 401 { trustsig_challenge } and retries once. |
| absorbSessionHandle(input) | string \| null | Stores the handle found in whatever you pass. Returns what was stored. |
| getSessionHandle() | string \| null | The stored handle, or null if absent or older than 30 days. |
| sessionHeaders() | Record<string, string> | { 'X-TrustSig-Session': handle }, or {}. |
| clearSession() | void | Forgets the handle. |
getResponse() and scan() return ProClientResponse ({ request_id, token, session_handle? }).
Session helpers
For a backend-for-frontend or a custom store:
| Export | Description |
| --- | --- |
| ProSessionStore | The store itself: absorb, get, headers, clear. |
| readHandle(input) | Pulls a pss_... handle out of a string or any object carrying session_handle. |
| SESSION_HEADER | 'X-TrustSig-Session'. |
| SESSION_STORAGE_KEY | 'trustsig.pro.session'. |
Related packages
- @trustsig/server: token verification and the Pro backend API
- @trustsig/react: provider and hooks
- @trustsig/types: shared TypeScript contracts
- GitHub repository and issue tracker
MIT licensed.
