@atol-sh/fingerprint
v0.2.0
Published
Browser device-signal collection for Atol Device Intelligence
Maintainers
Readme
@atol-sh/fingerprint
Browser device-signal collection for the Atol Device Intelligence Engine. Collects raw browser signals, submits them to the Atol control plane, and returns a server-assigned device ID plus server-computed smart signals (bot, VPN, tamper, anomaly score). All analysis happens server-side; the browser never makes trust decisions.
- Zero production dependencies. Pure TypeScript + DOM APIs.
- ESM + CJS + type declarations.
- Apache-2.0.
This package is a standalone SDK, but it's also an optional companion to
@atol-sh/js and
@atol-sh/react - install it
alongside either to enable device intelligence (bot/VPN/tamper signals) in
those SDKs.
Install
npm install @atol-sh/fingerprintQuick start
import { AtolFingerprint, isCollectionDisabled } from "@atol-sh/fingerprint";
// Collect signals once at load time.
const fp = await AtolFingerprint.load({
endpoint: "https://api.atol.sh", // default
});
// Submit to the control plane.
const result = await fp.identify({
authorize: async ({ method, url }) => {
const { token, proof } = await acquireTenantAuthorization(method, url);
return proof
? { scheme: "DPoP", accessToken: token, dpopProof: proof }
: { scheme: "Bearer", accessToken: token };
},
});
if (isCollectionDisabled(result)) {
// User opted out (config) or browser sent Global Privacy Control (gpc).
console.log("collection disabled:", result.reason);
} else {
console.log(result.device_id, result.confidence, result.signals?.anomaly_score);
}
// Inspect raw signals without submitting:
const signals = fp.getSignals();identify({ authorize }) calls the credential owner for the exact POST URL
immediately before dispatch. The callback returns either a Bearer token or an
inseparable DPoP token/proof pair. Neither the callback nor its result is
stored by the collector. The control plane derives the organization, user,
and session from that verified authorization.
Server contract
identify() performs POST {endpoint}/api/v1/devices/identify with body:
{
"client_platform": "browser",
"client_signals": { /* ClientSignals, see below */ }
}The control plane handler (atol repo, internal/api/device_handler.go) is
the canonical contract. Its response, typed as IdentifyResult:
interface IdentifyResult {
device_id: string; // server-assigned; never generated client-side
known: boolean;
confidence: number;
new_device: boolean;
platform: string;
browser: string;
os_version: string;
signals: SmartSignals;
}
interface SmartSignals {
bot: boolean;
vpn: boolean;
proxy: boolean;
tor: boolean;
incognito: boolean;
tampered: boolean;
emulator: boolean;
rooted: boolean;
geo_mismatch: boolean;
device_mismatch: boolean;
device_shared: boolean;
shared_user_count: number;
anomaly_score: number; // [0, 1]; 0 = clean, 1 = highly suspicious
}Failures make identify() throw IdentifyRequestError, with a closed code
and an HTTP status when a response was received. The SDK validates the exact
response shape and does not coerce missing fields or return unknown members.
Raw response bodies, status text, request URLs, and caught browser values are
never retained on the error. Identify requests omit ambient credentials and
referrers, bypass browser caching, and reject redirects. There is no silent
fallback.
All types (ClientSignals, SmartSignals, IdentifyResult,
IdentifyOptions, FingerprintConfig, CollectionDisabled) and the
isCollectionDisabled type guard are exported from the package root for downstream SDKs.
IdentifyRequestError and IdentifyRequestErrorCode are exported there too.
What is collected
Everything below is read once at AtolFingerprint.load() and only leaves the
browser when you call identify().
| Signal | Detail |
|--------|--------|
| Canvas rendering | A small test scene (text, gradient, arcs) rendered to an offscreen canvas, exported as a data URL. Hashed server-side. |
| WebGL | Unmasked renderer and vendor strings (WEBGL_debug_renderer_info), supported extension names. |
| Audio | A short numeric hash of an OfflineAudioContext oscillator+compressor render. Raw audio never leaves the device. |
| Fonts | Presence/absence of a fixed list of 30 common fonts (width-measurement technique). Names only. |
| Screen metrics | screen.width, screen.height, devicePixelRatio, colorDepth. |
| Hardware | navigator.hardwareConcurrency, navigator.deviceMemory. |
| Locale | navigator.languages, navigator.platform, IANA timezone. |
| UA Client Hints | High-entropy values: architecture, bitness, fullVersionList, model, platformVersion, uaFullVersion, wow64 (falls back to brands/mobile/platform, then to the plain user-agent string). |
| Touch | navigator.maxTouchPoints. |
| Codecs | Which of 10 fixed codec strings MediaSource.isTypeSupported accepts. |
| Media devices | The COUNT of devices from enumerateDevices(). Never labels or device IDs. |
| CSS preferences | prefers-color-scheme, prefers-reduced-motion, prefers-contrast, pointer, hover, prefers-reduced-transparency, forced-colors. |
| Math fingerprint | Results of 22 Math functions (engine/libm variations). |
| API availability | Booleans for bluetooth, usb, webgpu, speechSynthesis, webxr, serial, hid, credentials, PaymentRequest. |
| Incognito heuristic | Boolean derived from navigator.storage.estimate() quota (< 500 MB suggests private browsing). |
| Automation markers | Booleans: navigator.webdriver, PhantomJS/Nightmare/Selenium/Playwright/Puppeteer/CDP injected globals, domAutomation, and headless indicators (zero plugins, zero outer window size, connection.rtt === 0, notification permission inconsistency, missing languages). |
The mobile-only fields in the wire format (mobile_id, app_attest,
play_integrity, sensor_data, build_props) are always empty strings from
this browser SDK.
What is NOT collected
- No geolocation (no Geolocation API; any geo analysis is server-side from IP).
- No persistent client-side identifiers: the device ID is assigned by the server. Nothing is written to cookies, localStorage, IndexedDB, or any other client storage.
- No media device labels or device IDs - only the count.
- No keystrokes, form contents, page contents, or browsing history.
- No raw audio or microphone/camera access.
Data flow
load()reads the signals above in the browser. No network traffic.identify()POSTs them tohttps://api.atol.sh/api/v1/devices/identify(or your configuredendpoint) with an operation-scoped Bearer token or DPoP token/proof pair bound to that exact POST URL.- The control plane hashes the signals, matches them against known devices
for your tenant, evaluates smart signals, and returns
IdentifyResult. - The returned
device_idandsignalsfeed OPA policies (input.device.*) for step-up auth and fraud decisions.
The loaded agent retains only its configured endpoint and collected signal snapshot in memory. It never retains authorization material and writes no durable browser state. Every durable device record - the profile, its fingerprint history, and the session binding - lives server-side in the Atol control plane, retained per Atol's device-data retention policy.
Privacy & compliance
Device fingerprinting reads information from the user's terminal equipment.
Under GDPR and the ePrivacy Directive this generally requires prior user
disclosure and, depending on your legal basis (e.g. consent vs. legitimate
interest for fraud prevention), user consent. You are responsible for
disclosing fingerprinting in your privacy policy and obtaining any required
consent before calling AtolFingerprint.load().
See the Atol privacy documentation: https://atol.sh/docs/privacy (placeholder).
Consent and opt-out API
// Explicit opt-out: collection never runs, nothing is read or sent.
const fp = await AtolFingerprint.load({ disabled: !userHasConsented });
const result = await fp.identify();
// result = { collection_disabled: true, reason: "config" }When collection is disabled:
load()reads no browser APIs at all.identify()performs no network request and resolves to{ collection_disabled: true, reason: "config" | "gpc" }.getSignals()returns the sameCollectionDisabledobject.- No fake or placeholder data is ever produced.
Use the exported isCollectionDisabled(result) type guard to narrow results,
or check fp.disabled.
Global Privacy Control
If the browser advertises Global Privacy Control
(navigator.globalPrivacyControl === true), the SDK disables collection by
default and behaves exactly as if disabled: true was passed (with
reason: "gpc"). If you have an independent legal basis to ignore GPC (e.g.
fraud prevention required to provide the service), you may override:
const fp = await AtolFingerprint.load({ respectGPC: false });Troubleshooting
identify()throws with a 401/403 - the authorization is expired, mismatched to the target tenant, or missing its DPoP proof. A missing or malformed authorization owner is rejected locally before dispatch. Acquire the token and proof together viaidentify({ authorize }).identify()throwsIdentifyRequestError- inspect its closedcodeand optional numericstatus. The SDK deliberately does not expose raw server or browser diagnostics.getSignals()/identify()return{ collection_disabled: true }unexpectedly - checkreason."gpc"means the browser sent Global Privacy Control; passrespectGPC: falseif you have an independent legal basis to collect anyway."config"meansdisabled: truewas passed toload().- Signals look incomplete in a headless/automated browser - expected. Several collectors (WebGL, fonts, media devices) return sparse data or automation markers under headless Chrome/Playwright/Puppeteer by design.
Development
npm ci
npm run typecheck
npm test # vitest, happy-dom environment
npm run build # tsup -> dist/ (ESM + CJS + d.ts)
npm run verify:pack # publint + are-the-types-wrongLicense
Apache-2.0. Copyright 2026 Atol.
