Skip to content

The chrome.runtime and chrome.app objects: presence checks in headless detection

· 21 min read
Copyright: MIT
Wordmark reading window.chrome with chrome.runtime and chrome.app branches and an orange accent

For about five years, one of the cheapest ways to tell whether a visitor was a real desktop Chrome or a server pretending to be one was a single line of script: does window.chrome exist? Real Chrome had it. Old headless Chrome did not. That asymmetry was so reliable, and so trivial to check, that it ended up baked into half the public headless-detection write-ups of the era and into every stealth plugin built to defeat them. The plugin authors added the object back. The detectors stopped asking whether it was present and started asking whether it had the right shape. That second question is much harder to answer with a fake.

This post is about that single signal and the long argument it produced. Not the whole headless fingerprint, just the window.chrome namespace: chrome.runtime, chrome.app, the two relics chrome.csi and chrome.loadTimes, and the prototype and descriptor machinery underneath them. The interesting thing is how a binary presence check decays into a structural one, and how each property carries enough internal detail that a hand-built mock leaks on a dozen small edges at once.

The sections below trace it in order. First what the object actually is and where it came from, because most people who check for it could not tell you why it exists. Then what old headless lacked and why, which is the origin of the whole signal. Then the stealth response, property by property, and the specific contradictions a detector reads back out of those fakes. Then chrome.runtime’s secure-origin quirk, which is its own small trap. And finally the 2023 split, when Google’s new headless mode shipped the real object and quietly retired the presence check for anyone who upgraded.

What window.chrome actually is

The window.chrome object is not a single API. It is a junk drawer of loosely related things that the Chrome team exposed to web content over the years, most of them non-standard, some of them legacy, one of them a bridge to the extension system. Nobody designed it as a coherent surface. It accreted.

The most durable members are easy to enumerate on a normal desktop Chrome. There is chrome.runtime, the entry point a web page sees into the extension messaging system. There is chrome.app, a remnant of the Chrome Apps platform that Google deprecated for the open web years ago but never fully removed from the page-facing object. There is chrome.csi(), a function returning client-side instrumentation timing numbers, and chrome.loadTimes(), an older function returning page load timing and a couple of network protocol fields. Of these, two are functions you call, and two are namespaces you reach into. They share a parent object and almost nothing else.

chrome.loadTimes() is the oldest and the most clearly obsolete. It was implemented in 2009 and exposed page timing through a non-standard call. Chrome 64 deprecated it because everything useful it returned had moved into standardized replacements: Navigation Timing 2, the Paint Timing API, and the nextHopProtocol field added to Resource Timing 2. The official deprecation note from Google’s Chrome team is explicit that the standards-based equivalents cover all of its features. The function was slated for removal in late 2018 and a console deprecation warning shipped to nudge the remaining callers off it. chrome.csi() followed a parallel path as another non-standard timing helper. The point for detection is that both functions were present on real Chrome for years after they were deprecated, because removing a long-lived non-standard API breaks sites that quietly depend on it. Deprecated and present is exactly the state a fingerprint loves: a real browser carries the relic, a naive fake forgets it.

window.chrome .runtime extension messaging bridge, secure-origin only .app Chrome Apps relic: InstallState, RunningState .csi() non-standard timing function .loadTimes() deprecated Chrome 64, present long after *The four durable members of window.chrome. Only runtime is a live API; the rest are relics that real Chrome still carries, which is exactly what makes their absence a tell.*

chrome.runtime is the one member that still matters as a live API. On a page it is the visible edge of the extension messaging system. A web page that wants to talk to an installed, externally-connectable extension calls chrome.runtime.sendMessage or chrome.runtime.connect with the target extension’s ID. The Chrome extension docs describe this externally-connectable messaging path in the message passing guide. Most pages never use it. But the object sits there on every normal Chrome, with its sendMessage and connect functions and an id property that reads undefined when the page itself is not an extension. That idle presence is the signal.

Why old headless did not have it

The original headless Chrome, shipped in 2017, was not the same browser as desktop Chrome with the window hidden. It was a separate, alternate implementation. Google’s own documentation on the unified headless mode describes the old one plainly as “a separate, alternate browser implementation” that did not share the main Chrome browser code. It reused the rendering engine and a lot of plumbing, but it skipped large parts of the browser layer that a server, in principle, did not need: no real window system, a different startup path, and crucially for this signal, no extension system and no Chrome Apps machinery wired into the page-facing object.

That is the root of the window.chrome tell. The members of that object are not pure web-platform features living in the renderer. They are bridges into browser-layer subsystems. chrome.runtime is the page’s handle on extension messaging, and old headless had no extension system to bridge to, so the renderer never installed the object. The same logic explains the absence of chrome.app. The result was a binary you could read in one expression: on real Chrome, window.chrome was an object full of members; on old headless, it was missing or hollow.

This got noticed quickly and publicly. A January 2018 issue on the ChromeDevTools devtools-protocol repository, opened by a user running Nightwatch tests, reported tests passing in headful Chrome and failing in headless because window.chrome was unavailable in headless mode. The reporter pointed at window.chrome.webstore as something that should exist in any Chrome version, headless or not, and filed a parallel Chromium bug. The bug report was about broken tests, but the observation was the same one anti-bot vendors were already turning into a detection rule: a thing that is supposed to be universally present on Chrome was missing on the automated build.

The check that came out of this was about as simple as a fingerprint gets. Cast window.chrome to a boolean, or read typeof window.chrome, and branch. If you claimed to be Chrome in your user agent but had no window.chrome object, you were either a non-Chrome browser lying about its identity or a headless Chrome that had not bothered to fake it. Either way, suspicious. The signal generalized into the broader rule that anti-bot systems still apply: a user agent that says Chrome, paired with a missing Chrome-specific surface, is a contradiction worth scoring against. For more on how that contradiction lands inside a larger model, the headless Chrome detection catalog walks the rest of the signals it travels with.

The window.chrome test rarely traveled alone. It sat in a cluster of cheap, script-readable headless tells that the public detection write-ups of 2017 and 2018 catalogued together, and understanding the cluster matters because no serious detector ever scored on a single one of them. The classic companions were the HeadlessChrome token in the user agent string, an empty navigator.plugins array where a normal Chrome listed a PDF viewer and a Native Client entry, an empty navigator.languages, and the permissions contradiction where Notification.permission reported denied while navigator.permissions.query for notifications reported prompt for the same browser at the same moment. That last one was its own small classic: old headless could not maintain a coherent permission state, so the two APIs that should always agree reported contradictory values, and the navigator.permissions inconsistency became a tell in its own right. The window.chrome check belonged to the same generation and shared the same fate. Each was a single bit, each was cheap, and each was patched the moment a stealth author got to it.

What made window.chrome notable inside that cluster was how clean the asymmetry was. The permissions contradiction required two API calls and a comparison. The plugins check required knowing what a real Chrome’s plugin list looked like, which itself drifted across versions. The window.chrome check required one expression and had essentially no version dependence: real desktop Chrome always had the object, old headless never did. That made it a favorite in tutorial code and a favorite first target for stealth. It is the purest example in the whole headless-fingerprint catalog of a binary signal, which is also why it is the cleanest case study in how binary signals fail.

The stealth response, property by property

The stealth answer was obvious and was implemented early: if the detector checks for window.chrome, put window.chrome back. The classic minimal version, quoted in essentially every write-up on the subject, is assigning window.chrome = { runtime: {} } before the page’s own scripts run. That passes the dumbest possible check, the one that only asks whether the object and a runtime member exist. It also fails almost everything more careful, because a real chrome.runtime is not an empty object and a real chrome.app is not absent.

The widely used puppeteer-extra-plugin-stealth took the obvious approach and split the work across separate evasion modules, one per member of the object. There is a chrome.app module, a chrome.csi module, a chrome.loadTimes module, and a chrome.runtime module. Each one tries to reconstruct the corresponding piece of a real desktop Chrome from the outside. Reading what those modules have to reproduce is the clearest way to see how much internal detail a single junk-drawer object actually carries.

The chrome.app module mocks the object when it is missing, which is to say when running headless. Its own comment says exactly that: mock the chrome.app object if not available, for example when running headless. To do it convincingly the module has to recreate the shape of a thing almost nobody uses on purpose. There is an isInstalled boolean that reads false. There is an InstallState enum object with the three states a real Chrome exposes, DISABLED, INSTALLED, and NOT_INSTALLED, and a RunningState enum with CANNOT_RUN, READY_TO_RUN, and RUNNING. There are the methods getDetails, getIsInstalled, and runningState, and here the detail gets sharp: a real Chrome’s getDetails() returns null when called with no arguments but throws a TypeError when called with arguments, and the methods produce an error whose message follows Chrome’s exact invocation-error format, the “Error in invocation of app.getDetails()” style string. The module reproduces that throwing behavior, because a detector can call the method wrong on purpose and read the error back.

naive fake what a careful detector probes window.chrome = { runtime: {} } present: yes shape: wrong chrome.app.getDetails(1) should throw TypeError chrome.runtime.connect no .prototype, not new-able fn.toString() [native code] Object.keys(window) *Presence is the easy half. A real chrome.app.getDetails throws on bad arguments, a real chrome.runtime.connect has no prototype and is not constructable, and a real native function stringifies to [native code]. Each probe is a place a hand-built mock can disagree with the genuine article.*

The chrome.runtime module has the hardest job because it is impersonating a live API rather than a relic. It builds the object with id reading undefined, as real Chrome does on a non-extension page, and with connect and sendMessage functions. Those functions are not decorative. The module validates extension IDs the way Chrome does, enforcing that a valid ID is 32 characters drawn from the letters a through p, and throwing TypeErrors whose text matches Chrome’s real error messages when the arguments are wrong. It also pulls in static enum data, the kind of OnInstalledReason and OnRestartRequiredReason constants a real chrome.runtime carries. All of this exists because the detector is not going to be satisfied that connect is a function. It is going to call connect with garbage and check that the failure looks native.

That is the structural point. Once a property is known to be faked, the detector stops checking presence and starts checking behavior, and behavior has many more degrees of freedom than presence. CreepJS, the open fingerprinting test bed, encodes several of these probes directly. One of its stealth checks, hasBadChromeRuntime, flags the runtime object if chrome.runtime.sendMessage or chrome.runtime.connect carry a prototype property, which native functions of this kind do not, or if calling them with new succeeds when a real one throws a TypeError. A naive mock built from an ordinary JavaScript function has a prototype and is happily constructable. The genuine bound native is neither. The fake announces itself not by being absent but by being the wrong kind of function.

CreepJS adds a positional check on top of the behavioral ones. Its hasHighChromeIndex test looks at where 'chrome' appears in the key ordering of the window object, specifically whether it shows up in the last fifty keys of both Object.keys(window) and Object.getOwnPropertyNames(window). A real Chrome installs window.chrome early, so the key sits in a stable, expected position. A stealth patch that assigns window.chrome late, after the page environment is mostly built, drops the key at the tail of the enumeration order where it does not belong. The object can be perfect in every internal detail and still be caught by the fact that it arrived too late to the window namespace. This is the same family of insertion-order and descriptor tells that trips up runtime patching in general, and it is one of the recurring arguments for patching the browser at the source instead, which the source-patch versus runtime-injection comparison gets into.

It is worth being concrete about the layers of probing here, because “check the shape” is doing a lot of work as a phrase. The shallowest layer is type and presence: is window.chrome an object, does it have runtime, does runtime have connect. A fake passes this trivially. The next layer is property descriptors. Every property on a real window.chrome has a particular descriptor: writable or not, enumerable or not, configurable or not, and whether it is a data property or an accessor with a getter. A detector can read these back with Object.getOwnPropertyDescriptor and compare against what a genuine Chrome reports. A mock assembled from object literals tends to produce plain writable-enumerable-configurable data properties everywhere, which is not how the native object is laid out. The third layer is the function identity layer, and it is the one that catches the most fakes. A native function, asked to stringify itself, returns the canonical function connect() { [native code] } form rather than its actual source. Stealth code knows this and overrides Function.prototype.toString to lie about the patched functions, but the override is itself a function, and asking it to stringify itself, or wrapping it in a Proxy and watching for the recursion behavior a real native handles differently, can expose the lie one level up. CreepJS escalates exactly this way: when its Function.toString or Permissions.query probes already smell wrong, it builds nested Proxy objects around the function and tests for the recursion and prototype-cycle behavior that distinguishes a real native from a proxied stand-in.

The fourth layer is the one that does not look at the object’s contents at all, and it is the most durable. Instead of reading properties off window.chrome, the detector reaches into the engine’s own machinery and asks it to enumerate or preview the object, then watches for side effects that a real object cannot produce but a Proxy-backed or accessor-backed fake can. This is the territory the CDP fingerprinting work documents in detail: getters on stack that fire when the DevTools inspector serializes an error, ownKeys traps on a prototype that fire when the engine walks the chain to build a console preview. None of those are checks on window.chrome specifically, but a window.chrome reconstructed out of getters and proxies is exactly the kind of object that trips them. The deeper the detector goes, the less it cares what your object claims to be and the more it cares how your object behaves when the engine, rather than your script, is the one touching it.

The secure-origin quirk in chrome.runtime

chrome.runtime has one extra property that makes it a sharper trap than the rest of the object: it is not always supposed to be there. The stealth module that fakes it carries a comment to exactly this effect, that chrome.runtime is only exposed on secure origins. The plugin therefore gates its own injection, only mocking the object on HTTPS pages unless an explicit runOnInsecureOrigins flag overrides the default. The reason is that injecting chrome.runtime onto an insecure http:// page produces a state real Chrome never produces, and that mismatch is itself detectable.

Think about what this gives a detector. The presence of chrome.runtime is conditional on the page’s origin, and the condition is one the detector controls. Serve the fingerprinting probe from an http:// origin and a correct browser will not expose the live runtime surface there. A blunt stealth setup that injects window.chrome.runtime unconditionally will show it anyway. Now the runtime object is not just present when it should be absent, which is a contradiction in the opposite direction from the original headless tell. The original signal was a Chrome with no runtime; this one is a “Chrome” with a runtime where genuine Chrome would have none. Both are scoreable, and the second is harder for a naive fake to even know about, because it requires understanding why the property exists rather than just that it exists.

probe served from http:// origin chrome.runtime present? yes: contradiction no: consistent with real Chrome On a secure origin the genuine object appears; on an insecure one it should not. An unconditional fake shows runtime in both, which a real browser never does. *The origin gate turns chrome.runtime into a two-sided test. A missing object on HTTPS is one contradiction; a present object on plain HTTP is the opposite one, and a fake that does not know the rule trips the second.*

This is the general failure mode of presence-based spoofing stated in miniature. A presence check has one bit of state and one way to be wrong. A property with a rule attached, an origin condition, a native function signature, an argument-validation path, a key-insertion position, has many bits of state and many independent ways to be wrong, and the spoofer has to get all of them right simultaneously while only being scored against on the first one they miss. The detector does not need to enumerate the fake’s mistakes. It needs one. That asymmetry is why the stealth-plugin approach loses ground over time even as the individual mocks get more faithful: faithfulness has to be total, and detection only has to be partial.

The 2023 split: new headless ships the real object

Then Google changed the terms of the argument. The old headless implementation was the source of the missing window.chrome surface, and in 2023 Google replaced it. Starting with Chrome 112, the documentation describes a unified headless mode where Chrome creates platform windows but does not display them, sharing the same browser code as headful Chrome rather than running a separate, alternate implementation. This is the mode you reach with --headless=new. From Chrome 132 onward, the original implementation is no longer the default headless at all; it survives only as a standalone binary named chrome-headless-shell for the narrow use cases that still want the old stripped behavior.

The consequence for this signal is direct. New headless is the real browser with the window hidden, so the browser-layer subsystems that back window.chrome are present, and the object comes along with them. The members that old headless skipped because it had no extension system or apps machinery are there in the unified build, because the unified build is not skipping those subsystems. The presence check that anchored five years of write-ups simply stops separating bot from human for any automation that runs the current headless mode. Research from early 2023 on the new headless Chrome described its fingerprint as near-identical to a normal desktop Chrome’s, and the disappearance of the window.chrome gap is part of why.

What that does not do is end the argument. It moves it back onto the ground the careful detectors were already standing on. Presence of window.chrome is no longer a discriminator, because the legitimate automated browser and the legitimate human browser now both have a genuine, byte-correct object. The remaining signal is not in the object at all; it is in everything around it, the CDP attach surface, the timing of how the page is driven, the input that does or does not arrive. The window.chrome namespace went from a high-value cheap check to a near-dead one over a single Chrome release, and the detectors that had over-invested in it had to find their discrimination somewhere else. The ones that had treated it as one weak signal among many barely noticed.

What the object teaches about presence checks

The arc of window.chrome is the whole life cycle of a presence-based fingerprint compressed into one object. It started as a free discriminator, a thing real Chrome had and the automated build did not, readable in a single boolean cast. It survived exactly as long as it took stealth authors to assign the property back. Then it became a structural check, because the only remaining question once everyone fakes presence is whether the fake has the right internal shape, and that question reaches into native function semantics, argument-validation throws, enum constants, key-insertion order, and a secure-origin rule that most people faking the object never knew existed. And then Chrome shipped the real object in headless and quietly retired the discriminator for anyone who upgraded.

The durable lesson is not about Chrome specifically. It is that any detection signal whose answer is a single bit gets defeated the moment the bit is worth flipping, and the only signals that age well are the ones with enough internal structure that the spoofer has to be right about many things at once. chrome.runtime aged better than window.chrome as a whole, not because it was rarer but because it carried more constraints: a function that must not be constructable, a method that must throw on bad input, an object that must not appear on an insecure origin. Each constraint is a place to be wrong. A detector wins by finding one. That is the shape every signal in this space trends toward, and it is why the most current battleground is no longer the contents of the chrome object but the protocol attached to the browser that exposes it.

The most concrete thing to take away is small and specific. If you ever see a window.chrome presence check still being treated as a headless tell in 2026, it is reading a property that the default headless Chrome has had a genuine copy of since Chrome 112. It is no longer measuring what its author thinks it measures.


Sources & further reading

Further reading