Skip to content
Detection teardowns

Identifying the engine behind the User-Agent

A User-Agent is a claim. The JavaScript engine underneath answers independently — and it cannot easily be made to lie.

Pim
Pim· Clearcote Research
8 min read

Key takeaways

  • The User-Agent is self-reported text; the JavaScript engine is a compiled artifact that answers questions about itself on surfaces no UA patch, CDP override or userAgentData shim reaches.
  • A two-value oracle — the length of the RangeError message from (-1).toFixed(-1) plus the length of the Array constructor's source with its name removed — sums to 80 on V8, 58 on SpiderMonkey and 77 on JavaScriptCore.
  • An honest engine check returns indeterminate on an unrecognised value rather than failing: engines reword error messages between versions, so failing on the unknown would fire on every real browser after an engine update.

A User-Agent is a claim, not a measurement

The User-Agent request header and its JavaScript twin navigator.userAgent are self-reported strings. Nothing in the platform verifies them. They are rewritten trivially — at launch, through a CDP override, through an injected getter, through a proxy that edits headers on the way out — and essentially every automation stack rewrites them. Their evidential value is therefore not what browser is this? but what browser does this session say it is?

Underneath that string sits an engine: V8 in Chromium and its derivatives, SpiderMonkey in Gecko, JavaScriptCore in WebKit. The engine is a compiled artifact. It has its own error wording, its own source formatting, its own CSS property registry, its own Web API surface, its own native WebRTC stack. None of that is a string a page-level patch was designed to reach, and much of it is not a string a page-level patch can reach.

That asymmetry is the whole of the technique. A detector does not need to prove the UA is false in the abstract. It only needs one independent surface that names the engine, and then a comparison. If the measured engine and the claimed engine disagree, the identity was written by a layer sitting above the engine — and that is an inference the engine itself supplied.

The engine oracle: two lengths that name the engine

The cheapest reliable engine oracle available to a page reads two numbers straight out of the runtime. First, the character length of the RangeError message thrown by (-1).toFixed(-1) — an expression that always throws, and whose message text each engine words in its own way. Second, the length of the Array constructor's own source text with every occurrence of its name removed: ([].constructor + "").split([].constructor.name).join("").length. Their sum is the identifier.

Three sums are adjudicable: 80 identifies V8, 58 SpiderMonkey, 77 JavaScriptCore. Note what is deliberately not read: the message's text, or the constructor's source. Only the lengths. Stripping the constructor's name before measuring removes the one part of that source a runtime could plausibly differ on for uninteresting reasons, leaving formatting decisions baked into the binary.

What makes this the anchor for every other engine claim is where the values live. A UA patch edits a string on navigator. A CDP override sets the UA the browser sends and reports. A navigator.userAgentData shim fabricates a Client Hints object. None of the three touches the wording of an error message compiled into the engine's own binary, or the way that engine's parser re-serialises a built-in constructor. To move the identifier you would have to modify and rebuild the engine — at which point you are no longer spoofing the UA, you are shipping a different browser.

The measured family is then paired against the family the UA names. That side of the comparison is a coarse, ordered token match, and the order matters: firefox/fxios is tested first and claims SpiderMonkey; then edg/chrome/chromium/crios, claiming V8; then an iOS device token; then safari without a Chromium token — the last two claiming JavaScriptCore. Because the browser-brand tokens win, an iOS wrapper such as CriOS is read as V8 even though iOS wrappers are WebKit underneath. The map records what the UA advertises, not what an iOS shell truly runs, and it is worth knowing that this is where the check's blind spot sits. A UA naming no listed token is not adjudicated at all. When two recognised families disagree, the check fails at critical severity — the strongest engine assertion a page can make about itself.

"I don't know" is a different answer from "this contradicts"

There is a trap in the oracle above, and how a check handles it is the difference between a signal and noise. The identifier is a closed table of three known values. What should the check do when it measures something else — say, 82?

The tempting answer is "fail: an unrecognised engine is a fake engine." That answer is wrong, and it is wrong in a way that degrades over time. Engines reword their error messages between versions. The day a shipping engine adjusts the wording of that RangeError by two characters, a check that failed on unrecognised identifiers begins firing on every real browser running that release. The check would not be detecting spoofing; it would be detecting the passage of time.

So the implementation returns pass: null — indeterminate — whenever the measured identifier is not one of the three known values, or the UA names no family worth adjudicating. Indeterminate rows are excluded from the score entirely, which is computed as the share of applicable checks that passed. The check fails only on a positive contradiction: a recognised engine that disagrees with a recognised claim.

This principle generalises well beyond one check. An honest detector distinguishes "this contradicts" from "I do not know", and never charges the second as the first. The same restraint shows up throughout a well-built audit: excluding language and languages from worker comparisons because Chrome legitimately diverges there; treating a WebRTC stack that is blocked as an environment fact rather than a tell; refusing to score an mDNS host candidate as a leak when it is the obfuscation working. A check that fires on stock browsers is not strict — it is broken, and its operator learns to ignore it.

The API surface is compiled in

The oracle is not the only place the engine names itself. Which APIs exist at all is a shipping decision made by an engine vendor and compiled into the binary, and existence is the cheapest possible test — an object is there or it is not, with no measurement, no tolerance, and no baseline corpus required.

  • Blink-only Web APIs. Against a UA matching Chrome/\d+ on a secure page, real Chromium exposes navigator.connection, navigator.userAgentData, performance.memory, and — on desktop — navigator.keyboard. Gecko and WebKit ship none of them. The secure-context gate matters, since userAgentData is HTTPS-only; the desktop gate matters because no Android build ships navigator.keyboard and iOS wrappers (CriOS, FxiOS, EdgiOS) are WebKit underneath and legitimately lack it — the same wrappers the UA token map above reads only at face value, which is exactly why this check gates on them explicitly. Each of these bits is really the same question — is this Blink? — so they fold into one check rather than charging a single lie four times.
  • The CSS property registry. For a Chrome UA, CSS.supports("-webkit-app-region", "drag") must be true and the Gecko markers absent — no MozAppearance on the inline style object, no CSS.supports("-moz-user-focus", "normal"). Which vendor-prefixed properties parse is decided at build time. A Gecko engine wearing a Chrome UA still answers Blink's questions from Gecko's property table.
  • The masked WebGL vendor. gl.getParameter(gl.VENDOR) is not the GPU string from the debug extension — it labels the engine that built the context. Chromium answers "WebKit"; Gecko answers "Mozilla".
  • NetworkInformation in the other direction. The reverse lie is caught the same way: if navigator.connection exists but the UA claims Firefox or Safari, that is a Chromium-only API present on a non-Chromium identity. The contradiction is the object's existence, not its contents.
  • Enumerated device kinds. navigator.mediaDevices.enumerateDevices() exposes an audiooutput kind on Chromium; Gecko does not implement that kind. Kinds only — never labels or device IDs, which is what keeps the reading permission-independent and safe to display. A Firefox-shaped device list behind a Chromium UA is two independent surfaces disagreeing.

Each of these is individually shimmable, and that is rather the point. Shimming navigator.connection onto a Gecko build leaves the CSS registry, the masked WebGL vendor and the engine identifier still contradicting. The surfaces are numerous, cheap to read, and independent; patching them one at a time is a race against an inventory the engine vendor controls.

The native stack signs its own work

WebRTC gives the same answer from a different direction. When a page constructs an RTCPeerConnection and calls createOffer, the SDP that comes back is authored deep in C++ by the native media stack — not assembled by any JavaScript a shim could intercept. libwebrtc writes markers into its own offers that no other implementation emits: goog-remb, and the x-google-* attribute family. The stack signs its work.

The check is deliberately coarse. It compares the presence of a Chromium marker against whether the UA claims V8 — nothing finer. An exact codec-set match would look more rigorous and be worse: it would misread Chromium builds compiled without proprietary codecs, enterprise codec policy, and distribution builds as contradictions. Family-level is the level at which the inference actually holds. It goes indeterminate when the UA names no adjudicable family, or when the offer carries no audio and no video sections at all — and it is not raised at all when the WebRTC stack is unavailable or disabled, since an absent stack is an environment fact.

Worth stating plainly, because it is unusual: this probe is fully offline. The peer connection is built with no iceServers key, which restricts gathering to host candidates — no STUN server is contacted and not one packet leaves the machine for the engine-identity reading. The full local SDP is produced regardless, because the offer is generated before anything is negotiated. This diverges from CreepJS, which supplies a public STUN server list to its peer connection and therefore contacts a third party as a side effect of the measurement; see what is CreepJS. The claim is scoped, and should be: a probe that compares the media path's exit address against the HTTP path's must contact a STUN server, because no self-referential reading can answer that question. Engine identity is not that question — the engine is already in the room.

The through-line

Every check here has the same shape: find a surface the engine authors for itself, read it, and compare it to what the session claims. The engine identifier, the CSS registry, the Blink API inventory, the masked WebGL vendor, NetworkInformation read in reverse, the enumerated device kinds, the SDP — seven independent witnesses to one fact, none of which was designed as a fingerprinting surface and none of which a UA rewrite reaches. This is why the engine layer is where a UA lie tends to surface first, and why the resolution is never another shim: either run the engine the UA claims, or claim the engine you run.

It also explains where the cost lands. Reaching these surfaces means modifying and rebuilding the engine — genuinely exposing the API surface, genuinely emitting the right SDP — rather than decorating it from JavaScript. Clearcote is one open-source implementation that takes that route, and the same logic drives its detection breakdown; the technique described here is the engine vendor's doing, not any tool's, and it works identically against every tool.

Two neighbouring ideas complete the picture. Engine identity must also hold across realms — a worker is a separate global with its own navigator, and the engine identifier is readable from inside it too; see cross-realm coherence. And engine surfaces are only one family of self-evident contradiction; an API's own specification supplies others, as in spec invariants as lie detectors. All three are the same principle from coherence over camouflage: nothing here asks whether a value is unusual. It asks whether two answers to the same question agree. You can run these checks against your own browser in the audit.

#bot-detection#coherence#fingerprinting#webrtc#reference

Clearcote puts this into practice

Open-source, engine-level, coherent down to the TLS handshake — a drop-in for Playwright & Puppeteer.