@liarjs/collect
v0.4.0
Published
Browser fingerprint collector: 20 probes across canvas, WebGL/WebGL2, WebGPU, audio, 230 fonts by name and by metric, WebRTC, Web Worker cross-thread consistency and native-function integrity. Auto-send to your backend or export JSON. The collector behind
Maintainers
Readme
@liarjs/collect
Browser fingerprint collector for JavaScript. 20 probes, one call, one plain-JSON object. Zero dependencies, ~50 kB minified, and it never throws into your page.
npm i @liarjs/collectimport { collect } from '@liarjs/collect';
const fingerprint = await collect();
fingerprint.summary;
// { os: 'windows', browser: 'Chrome 150', webview: null,
// gpu: 'ANGLE (AMD, AMD Radeon 780M …)', screen: '[email protected]',
// timezone: 'Asia/Shanghai', fontCount: 101,
// fpHash: 'c4f9e1be25ee836b', machineKey: '9d1a0b7c33e5f204', … }This is the collector behind liarjs.dev. It measures; it does not judge. To turn a sample into a 0-100 score use @liarjs/checks, or run the whole pipeline with the liarjs CLI.
Two modes
1. Send mode: collect on your site, POST to your backend
One tag, no code. The collector waits for load, runs after a delay, POSTs the sample and remembers not to do it again for 30 days.
<script src="https://cdn.example.com/liarjs-collect.min.js"
data-endpoint="https://api.example.com/fp"
data-token="your-auth-token"></script>Or drive it yourself, e.g. after the visitor accepts your consent banner:
<script src="/liarjs-collect.min.js" data-auto="0"></script>
<script>
document.querySelector('#accept').onclick = async () => {
const { payload, result } = await liarjsCollect.run({ endpoint: '/fp' });
console.log(payload.summary, result.ok);
};
</script>Delivery is fetch first, navigator.sendBeacon as a fallback, and a localStorage queue that retries on the next page load. See the endpoint contract.
2. Export mode: collect and hand back a JSON file
No endpoint, no request, nothing leaves the device except through the visitor's own download.
<script src="/liarjs-collect.min.js" data-mode="export"></script>import { collect, exportJson, download } from '@liarjs/collect';
const fp = await collect();
download(fp); // saves liarjs-fingerprint-<hash>.json
const text = exportJson(fp); // or take the string and do what you likeExport mode is the right default for a diagnostics page, a support tool, or anywhere you want the user to hold their own data.
What it probes
| probe | what it reads | why it matters |
|---|---|---|
| navigator | UA, platform, languages, hardware, webdriver, plugins + mimeTypes in full, UA-CH high entropy (platformVersion, architecture, bitness, model, fullVersionList, wow64), window.chrome shape | UA-CH is the easiest place for a browser to contradict its own User-Agent |
| screen | size, avail size, multi-monitor offsets, colorDepth, DPR, orientation, isExtended | colorDepth !== 24 is a classic spoof leftover |
| viewport | inner/outer size, screenX/Y, browser UI height | outerHeight === innerHeight means no browser UI: headless |
| intl | IANA timezone, locale, calendar, January + July offsets (DST rule), formatting samples, ICU zone count | a timezone override that patches only the current offset gets the DST rule wrong |
| webgl / webgl2 | unmasked vendor + renderer, VERSION, 18 scalar caps, range caps, all 12 getShaderPrecisionFormat pairs, extension list, context attributes, readPixels render hash read twice | a GPU is ~40 numbers, not one string; overriding only the renderer name leaves the caps describing another card |
| webgpu | adapter.info vendor/architecture/device, full limits table, features, WGSL features | a second, independent statement about which GPU this is |
| canvas | toDataURL + getImageData hashes, stability across reads, across canvases and against OffscreenCanvas, measureText geometry for 4 fonts, webp support | randomised canvas output fails at least one of the three stability tests |
| audio | OfflineAudioContext render sum, sample rate, channel counts, DynamicsCompressor factory defaults, live-context latencies | the compressor defaults are spec constants, so an altered value is arithmetic-checkable |
| fonts | 230 families over 3 independent paths (measureText, FontFaceSet.check, layout), OS classification, Office detection, CJK flag, plus metrics: raw widths for the generic families and each OS's signature families against a control family nobody has installed | fonts come from the real host OS; a mask usually patches one path and forgets the others, and no allow-list can give a name metrics the host has no font file for |
| media | device counts, canPlayType and MSE for 16 codecs, Widevine/ClearKey, hardware decode info | H.264/HEVC/AAC ship in Chrome and not in plain Chromium |
| speech | getVoices() in full, languages, default voice | the installed voice set leaks the host OS language |
| permissions | 19 Permissions.query states, cross-checked against Notification.permission | two APIs reporting the same setting must agree |
| storage | localStorage / IDB / caches / OPFS availability, quota rounded to GB, persisted | quota tracks free disk, which is a device tier |
| domrect | sub-pixel rects, getClientRects, Range rects, stability across reads | the most-called API in commercial bot detection |
| webrtc | ICE candidate shape (host/srflx/relay, mDNS), RTP codec capabilities, addresses only on request | modern Chrome hides host candidates behind .local names |
| misc | 27 media queries, connection, battery tier, keyboard layout, engine tells (Math, Error.stack, native toString), sensor surface, heap tier, clock resolution, secure-context | this is where a persona has to keep everything aligned at once |
| apisurface | presence census over 53 Web API paths whose availability is a per-platform build decision, plus whether new Notification() constructs | a UA can claim any platform; which interfaces the binary was compiled with is not part of the identity layer. Secure-context gated, so meaningful on https only |
| integrity | [native code] verification of 26 APIs, own-instance navigator properties, rewritten prototype descriptors | separates an engine-level patch from a JavaScript mask |
| worker | navigator + Intl + OffscreenCanvas + WebGL re-read inside a Web Worker, compared field by field with the main thread | detectors re-read identity in workers precisely because partial masks only patch the main thread |
A full sample is ~30 kB of JSON (~6 kB gzipped) and takes roughly 1 second, of which font detection is the bulk. heavy: false drops to ~150 ms at the cost of the worker, WebGPU, media, speech and layout-font probes.
What it deliberately does not collect
Everything here is sensitive to the user and useless for consistency checking:
- WebRTC addresses. Off by default: only the candidate shape is recorded, and with no ICE servers configured the probe makes no outbound connection at all. Opt in with
collectWebRtcIps: true. deviceId/labelof media devices. The first is random per origin, the second is the real hardware model. Only counts are kept.- Exact battery level and storage quota. Rounded to 5% and to GB: the tier survives, the drift does not.
- Page URL and referrer are collected by default;
collectPageUrl: falseturns that off.
Configuration
Precedence, lowest first: built-in defaults < <script data-*> < window.LIARJS_COLLECT_CONFIG < call arguments. Write camelCase keys as hyphenated data attributes, e.g. data-timeout-ms="8000" for timeoutMs.
| key | default | meaning |
|---|---|---|
| mode | 'send' | 'send' POSTs to endpoint; 'export' downloads a JSON file and makes no request |
| endpoint | '' | where to POST. May be a function (payload, body) => Promise to take delivery over entirely. Empty = collect only |
| token | '' | sent as X-Liarjs-Token and echoed into meta.token |
| headers | null | extra request headers. fetch channel only, so never make auth depend on these alone |
| credentials | 'omit' | use 'include' for a same-site endpoint that needs cookies |
| auto | true | run after page load. false exposes the API and runs nothing |
| delay | 800 | startup delay in auto mode, ms, to stay out of the first paint |
| timeoutMs | 12000 | total budget. On expiry the run reports what it has; it never hangs |
| once | '30d' | dedup window: '30d' / '12h' / 'session' / false |
| resendOnChange | true | re-collect when the fingerprint changed, even inside the once window |
| heavy | true | false cuts a run to ~150 ms, dropping worker/WebGPU/media/speech/layout-font/render-hash |
| only / exclude | null | filter probes by name |
| collectWebRtcIps | false | read raw addresses out of ICE candidates |
| collectPageUrl | true | include page URL and referrer |
| clientId | true | mint and persist an anonymous per-browser id |
| beforeSend | null | (payload) => payload \| false. Return false to cancel: the place for a consent check or redaction |
| onDone / onError | null | callbacks |
| retry | true | queue failed deliveries in localStorage, retry next page load |
| filename | 'liarjs-fingerprint-{hash}.json' | export mode filename |
| debug | false | console logging. Off means completely silent |
API
collect(options?) // Promise<ClientData> measure only
send(payload, options?) // Promise<SendResult> deliver only
run(options?) // Promise<RunResult> measure + deliver or export
exportJson(payload, pretty?) // string
download(payload, filename?) // boolean
version, schemaVersion, defaults, probeNames, PROBES, selectProbesClientData is fully typed. Every probe section is independently nullable: a probe that fails, times out or is disabled leaves its section null rather than aborting the run, and @liarjs/checks reads a missing section as "cannot evaluate", never as "failed".
Injecting it into a page you drive
The bundle also ships as a string, for CDP / Playwright / Puppeteer injection:
import { COLLECTOR_SOURCE } from '@liarjs/collect/source';
await page.evaluate(COLLECTOR_SOURCE); // defines globalThis.__liarjs
const fp = await page.evaluate('__liarjs.collect({ collectWebRtcIps: true })');dist/collector.iife.js is the same bundle as a file, if you would rather serve it.
Backend endpoint contract
Your server needs one POST route:
POST <endpoint>
Content-Type: application/json
X-Liarjs-Token: <token>
<sample JSON> ~30 kB, ~6 kB gzipped
200 OK any 2xx counts as success; non-2xx enters the retry queueTwo things to know about the beacon fallback:
- It cannot carry custom headers, so read the token from
meta.tokenas well as fromX-Liarjs-Token. - Its
trueonly means "queued by the user agent". An unreachable host returnstruetoo, so that path is reported asconfirmed: falseand never enters the retry queue. Delivery reliability rests on the fetch channel; reconcile withmeta.clientId+meta.fpHashserver-side if you care about the exact rate.
What only your server can add — the browser cannot see any of it about itself, which is exactly why it is worth cross-checking:
- Exit IP and geolocation. The only way to check whether the reported
timezonematches the network position. - Receive timestamp.
meta.collectedAtis the client clock and is not trustworthy. - TLS / JA3-JA4 / HTTP2 fingerprints. Invisible to JavaScript by construction.
Feed both halves to @liarjs/checks to score them together:
import { computeVerdict, emptyServerData } from '@liarjs/checks';
const verdict = computeVerdict(sample, { ...emptyServerData(), ip, ipTimezone, tlsVersion, httpProtocol });
if (verdict.score < 60) flagForReview(verdict.checks.filter((c) => c.status === 'bad'));Deduplication
Three 64-bit keys, for three different questions.
meta.fpHash answers "is this the same visit?" — a stable key over the non-heavy dimensions only, on purpose: folding in heavy-only probes would give the same machine two different hashes depending on configuration, breaking both the once window and server-side merging. meta.fpHashFull carries the extended hash when heavy probes ran.
meta.machineKey answers "is this the same machine?" and is what you want as a storage key. It deliberately leaves out everything a machine can change without becoming a different machine: devicePixelRatio (browser zoom, or which monitor the window sits on), languages (a settings change), timezone (a laptop that travels) and the browser's own version (auto-update, every few weeks). Each of those splits one machine across several fpHash values in practice — on a live sample of 652 hits, fpHash collapsed 20.7% of them and machineKey collapsed 43.6%.
Neither is a security boundary. Both are FNV-1a over values the client chose to report; a client that wants two identities can have them.
Suggested layout for a bucket that should stay idempotent under the SDK's retry paths:
raw/<date>/<machineKey>-<sha256 of body>.json # content-addressed, PUT is a no-op on a repeat
profiles/<machineKey>/latest.json # newest sample for this machineFAQ
Does this run in Firefox and Safari?
Yes. Probes that need a Chromium-only API (userAgentData, navigator.keyboard, WebGPU on older builds) return null for that section instead of failing.
Will it break my page or spam the console?
No. Every external read is wrapped, every async probe has a timeout, and probes that would trigger Chrome's built-in console warnings are specifically routed around them: extension-gated WebGL caps, DRM robustness levels, and repeat getImageData calls. With debug: false the collector produces no output at all.
Will it block the main thread? Probes run in three phases (serial, concurrent, final) and yield a frame between anything that took over 30 ms. Font layout detection builds all its spans first and reads once, instead of forcing 660 synchronous reflows.
Does it work under a strict CSP?
Mostly. The worker probe needs blob: in worker-src/script-src; without it the section reports present: false, reason: 'blocked' and everything else continues.
Is the canvas or WebGL pixel hash stable across machines? No, and that is the point. Those pixels come from the real GPU and font stack. The collector reads them repeatedly to detect randomisation, not to use them as a cross-device identifier.
Do I need a secure context?
For the full sample, yes. On about:blank, data: URLs and plain http, UA-CH, StorageManager and most Permissions names are unavailable no matter what the browser is. misc.secureContext records which case you are in.
Is this GDPR-compliant?
That depends on your disclosure and legal basis, not on the library. What the library gives you: beforeSend for a consent gate, collectPageUrl: false, collectWebRtcIps off by default, clientId: false to skip the persistent id, and mode: 'export' for flows where nothing should leave the device.
MIT © liarjs.dev · liarjs.dev · field notes on each probe
