Score a browser against itself. A browser controls its own JavaScript. It does not control the network it connects over. liarjs collects the JS fingerprint inside the browser, collects the TLS/HTTP/ASN view from the edge that served the request, and reports every place the two stories disagree.
npx liarjs # your local Chrome
npx liarjs --headless --min-score 60 # CI gate
npx liarjs --cdp http://127.0.0.1:9222 # any browser with a debug portHosted version, no install: liarjs.dev.
Fingerprint test pages tell you what you look like. They don't tell you whether you're coherent. A profile can present a pristine Chrome 150 in JavaScript and still negotiate a handshake no Chrome would send, from an IP whose timezone is twelve hours off. Patch one layer, the other gives you up.
liarjs is that second question, as a command you can run in CI:
- 53 checks, 45 of which need nothing but the browser (
--offline), 8 of which compare the JS story against what the wire actually carried. - 20 probes behind them: canvas, WebGL + WebGL2, WebGPU, audio, 230 fonts over three detection paths plus a fourth that measures widths instead of names, WebRTC, a cross-thread Web Worker comparison,
[native code]verification of 26 APIs, and a census of which interfaces the build was compiled with. - Attaches to anything speaking CDP. Your Chrome, a Chromium fork you patched, a commercial anti-detect browser's debug port, a Playwright/Puppeteer page you already have.
- Zero runtime dependencies. No puppeteer, no playwright, no
ws, just Node ≥ 22 and its built-inWebSocket. - Machine-readable.
--jsonto export,--postto ship results to your own backend,diffso a fingerprint regression shows up in a pull request instead of in your ban rate.
liarjs · browser consistency scan
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/…
20 / 100 Unverified · driver unchecked
fingerprint −20 · network −12 · automation −48
✗ navigator.webdriver −40
webdriver=true, the automation flag is set.
id: webdriver
✗ Worker ↔ main-thread consistency −20
A Web Worker reported different values than the main thread for userAgent, canvasHash.
Detectors re-read identity inside workers precisely because partial overrides only patch
the main thread.
id: worker-consistency
! IP timezone ↔ browser timezone −12
IP resolves to America/Los_Angeles but the browser reports Asia/Shanghai (proxy/VPN or
spoofed timezone).
id: tz
! Driver challenge −8
Not taken yet. Everything else here describes the machine; none of it proves a hand is on
the pointer. The behavioural challenge on liarjs.dev answers that directly - passing
it returns these 8 points and is the only route to the top tier. Taking it and moving too
little costs nothing: the score just stays provisional.
id: driver-unverified
23 checks · 2 critical · 2 warnings · 18 clean
The behavioural layer (is a hand driving this browser?) runs only on liarjs.dev.
That is why this score is provisional: it says the environment is coherent, not that a person is here.
edge: 203.0.113.7 · AS4058 CITIC Telecom International CPC · LAS · HTTP/2 · TLSv1.3
Only the failures print by default; add --all for the full list.
The score starts at 100 and each failing check deducts its weight, split across three axes (fingerprint, network, automation). The rating word needs one more thing: the top label You didn't fool me is only reachable after a behavioural challenge says a hand is on the pointer, and that challenge runs on liarjs.dev, not here. A CLI run therefore always prints Unverified · driver unchecked, however clean the browser is.
The same reason costs points, not just the word: every scan carries one pending item, driver-unverified, a warn worth 8 on the automation axis. It is not evidence of anything - it is the question this package cannot ask. So a spotless browser scores 92 here, never 100, and --min-score 92 is the strictest gate that can pass. Only the challenge on liarjs.dev returns those 8 points.
| id | check | max −pts |
|---|---|---|
webdriver |
navigator.webdriver is set |
40 |
native-integrity |
core APIs are not genuine [native code] (JS-level spoofing) |
35 |
headless-ua |
HeadlessChrome token in the UA |
30 |
gpu-triad |
WebGL GPU ↔ WebGPU adapter.info identity |
22 |
worker-consistency |
a Web Worker reports different identity than the main thread | 20 |
canvas-lie |
identical canvas draws read back differently, or OffscreenCanvas disagrees | 18 |
webgl-lie |
the same WebGL scene rendered twice reads back different pixels | 18 |
webgl-pair |
WebGL and WebGL2 name different GPUs on one machine | 15 |
uach-ver |
UA-CH fullVersionList ≠ the version in the UA string |
15 |
plugins-ver |
plugin/mimeType face doesn't match the claimed Chrome version | 15 |
perm-notif |
Notification.permission ≠ permissions.query() state |
15 |
tz-offset |
Intl timezone implies a different offset than getTimezoneOffset() |
15 |
os-fonts |
installed fonts describe a different OS than the UA claims | 14 |
ua-mobile |
mobile hints contradict the UA or maxTouchPoints |
12 |
domrect-lie |
getBoundingClientRect unstable across reads |
12 |
chrome-object |
the UA claims Chrome but window.chrome is missing |
12 |
window-noise |
outer is inner plus a few pixels of noise: a UI height no real title bar has |
12 |
langs-empty |
navigator.languages is empty |
10 |
gpu-age |
GPU too old to be real for a current Chrome (MAX_TEXTURE_SIZE) |
10 |
webgpu-empty |
WebGPU returned an adapter but adapter.info is blank |
10 |
headless-viewport |
outerHeight === innerHeight in both directions: the window reports no browser UI at all (fullscreen, kiosk, old headless) |
15 |
font-methods |
the measureText and layout font-detection paths disagree |
10 |
audio-params |
DynamicsCompressor factory defaults are off spec |
8 |
voice-locale |
speech-synthesis voice language ≠ locale (leaks host OS language) | 8 |
touch-pointer |
maxTouchPoints contradicts (any-pointer: coarse) |
8 |
codecs |
claims Chrome but cannot play H.264 (a plain Chromium build) | 6 |
cjk-fonts |
CJK fonts installed on a non-CJK locale (leaks host region) | 6 |
colordepth |
screen.colorDepth is neither 24 (8 bpc) nor 30 (10-bit HDR) |
6 |
lang-base |
Chromium only: a region-qualified tag whose bare primary subtag is missing (zh-CN without zh) |
6 |
tz-dst |
the reported January/July offsets don't match the zone's DST rule | 6 |
storage-quota |
StorageManager quota below 1 GB |
4 |
webrtc-mdns |
host ICE candidates expose raw local addresses instead of .local |
4 |
conn-rtt |
navigator.connection.rtt === 0 |
3 |
| id | check | max −pts |
|---|---|---|
ua-http-js |
User-Agent header ≠ navigator.userAgent |
25 |
cf-bot |
the edge already classifies you as a known bot | 25 |
platform |
Sec-CH-UA-Platform ≠ navigator.platform |
15 |
tz |
IP-derived timezone ≠ browser timezone | 12 |
webrtc-ip |
WebRTC-exposed public IP ≠ the IP the connection came from | 10 |
lang |
Accept-Language ≠ navigator.languages[0] |
8 |
http-proto |
modern Chrome that negotiated HTTP/1.1 | 6 |
tls-ver |
modern Chrome that negotiated TLS < 1.3 | 6 |
With --offline these eight are skipped rather than failed, and the report says so explicitly.
--cdp <endpoint> attach to a running browser (http://127.0.0.1:9222 or ws://…)
--page <url> run the probes on this page instead of about:blank
--endpoint <url> network-layer endpoint (default https://liarjs.dev/api/net.json)
--offline JS-layer checks only, no outbound request
--headless launch headless (headless tells will be reported)
--json [file] export the full result as JSON (stdout when no file given)
--post <url> send the full result to your own backend (POST, JSON body)
--token <token> auth token for --post, sent as X-Liarjs-Token
--min-score <n> exit 1 when the score is below n
--all also print the checks that passed
--timeout <ms> per-command timeout, default 30000
--keep-open leave the tab open after scanning
Launching uses the first Chrome/Chromium/Edge it finds; override with LIARJS_CHROME=/path/to/binary.
Extra browser flags come from LIARJS_CHROME_ARGS; containers and CI generally need
LIARJS_CHROME_ARGS="--no-sandbox --disable-dev-shm-usage".
--json writes the result where you point it; --post ships the same object to an endpoint you own. They compose:
npx liarjs --json scan.json --post https://api.example.com/scans --token "$LIARJS_TOKEN"The posted body is the full ScanResult: score, label, every check, the raw fingerprint and the raw edge view.
about:blank is not a secure context, so UA-CH, StorageManager and most Permissions names are unavailable there no matter which browser you point at. The report says so when it happens. For the complete picture, and whenever your endpoint has no CORS headers:
npx liarjs --page https://example.comAnything that exposes a CDP endpoint works, including most anti-detect browsers, which expose a local debug port per profile:
# a profile started by an anti-detect browser's local API
npx liarjs --cdp http://127.0.0.1:54321
# a stealth-patched Chromium you built yourself
./chrome --remote-debugging-port=9222 &
npx liarjs --cdp http://127.0.0.1:9222Works with any object exposing evaluate(expression: string). Playwright and Puppeteer Pages both qualify:
import { checkPage } from 'liarjs';
const result = await checkPage(page);
expect(result.score).toBeGreaterThanOrEqual(85);
const critical = result.checks.filter((c) => c.status === 'bad');
expect(critical, JSON.stringify(critical, null, 2)).toHaveLength(0);Or drive the browser yourself:
import { scan, postResult } from 'liarjs';
const result = await scan({ cdp: 'http://127.0.0.1:9222', endpoint: 'https://my-worker.example/api/net.json' });
console.log(result.score, result.checks.filter((c) => c.status !== 'ok').map((c) => c.id));
await postResult('https://api.example.com/scans', result, { token: process.env.LIARJS_TOKEN });ScanResult is { score, label, checks[], client, server, meta }, where client is the raw fingerprint, server the raw edge view and meta.schema the payload version (currently 4).
- run: npx liarjs --headless --json scan.json --min-score 60
- run: npx liarjs diff baseline.json scan.json # what changed since the last known-good rundiff prints only the checks whose status moved, so a rebase that silently breaks WebGPU↔WebGL alignment is one line in the log instead of a mystery ban three weeks later.
Ignore checks that can't hold in your environment (datacenter IPs will always trip tz) by filtering result.checks in the library API, or by comparing against a baseline instead of an absolute floor.
--offline makes no outbound request at all.
Otherwise the browser under test fetches https://liarjs.dev/api/net.json, a normal request answered with what Cloudflare saw about that request (IP, ASN, colo, HTTP protocol, TLS version and ClientHello shape, headers). liarjs.dev does not store scans. If you'd rather not talk to it, point --endpoint at your own deployment: the endpoint is a ~60-line Cloudflare Worker, and the site that hosts it is open in the repo.
| package | what it is |
|---|---|
liarjs |
this CLI + the driver/library API |
@liarjs/collect |
the 19 browser-side probes, standalone, injectable, or embeddable on your own site |
@liarjs/checks |
the 53 consistency rules as pure functions |
The hosted scan at liarjs.dev runs the exact same packages, so a score there and a score here mean the same thing.
- A score is not a ban prediction. It measures internal coherence. Real sites also weigh proxy reputation, account age and behaviour, none of which this can see.
- Cloudflare does not expose a raw JA3/JA4 on non-Enterprise plans, so the TLS checks use ClientHello length + extension/cipher hashes, not a full fingerprint string.
- Checks drift with Chrome. Plugin faces, UA-CH shapes and GPU expectations change; rules are versioned with the package and reviewed per Chrome major.
- Headless is detected on purpose.
--headlesswill lose points on a stock browser. That's the correct answer, not a bug.
This is a measurement and diagnostics tool: understand what a modern detector can see, and check your own footprint. It doesn't ship evasions, profiles or proxies.
MIT © liarjs.dev. Field notes on how each of these checks works: liarjs.dev/blog