Est.
ChromiumLong read

Chromium Custom User Agent Strings in Automation Contexts

Fixing the User-Agent string alone won't stop bot detection.

Editor at Large · · 11 min read
Cover illustration for “Chromium Custom User Agent Strings in Automation Contexts”
Chromium · September 2, 2026 · 11 min read · 2,511 words

A Chromium browser announces its identity before a single line of page content loads, through the User-Agent string, and getting that string wrong is one of the fastest ways an automated session gets flagged. The header rides along on every outbound HTTP request; it's the one piece of client metadata a server can count on seeing every time, no JavaScript required. This holds whether the automation runs on Playwright, Puppeteer, Selenium, or a Chromium Embedded Framework build. Here's the position worth stating up front: UA spoofing is one of the most overrated fixes in the whole bot-detection conversation. Fixing the string alone solves almost nothing, and most of this piece is about why that's true and what actually needs to happen instead.

Take a stable Chrome build on Windows. The string reads something like Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36. Each piece means something specific: a compatibility prefix left over from the 1990s browser wars, a platform token, the rendering engine, the browser name and version, and a trailing Safari token that Chrome keeps around purely so old UA-sniffing code doesn't break. Chromium builds this string through a central function, embedder_support::GetUserAgent(), which branches on build flags for Android, Windows, macOS, and the rest, and calls ui::GetDeviceFormFactor() to sort out whether the device is a phone or a tablet. This is compiled logic, driven by build flags and platform checks, and any script that wants to override it convincingly has to work inside that structure.

How UA Reduction changed what Chrome sends by default, and what it froze

Chrome's UA Reduction effort changed the math for anyone setting a custom string. Starting around Chrome 110, the browser stopped reporting its full version and froze everything but the major version number to zeros, so Chrome/134.0.6998.31 became Chrome/134.0.0.0. The OS token got the same treatment: instead of the real build number, Chrome sends one of a small, fixed set of platform strings. Windows gets Windows NT 10.0; Win64; x64 whether the machine is fully patched or three years stale, and macOS gets Macintosh; Intel Mac OS X 10_15_7, a version number frozen well past its actual release cycle. Android gets Android 10; K, ChromeOS reports X11; CrOS x86_64 14541.0.0, and Linux gets the plain X11; Linux x86_64.

The rollout finished by Chrome 110, and the legacy opt-out, a deprecation trial that let sites request the old unreduced format, closed for good at Chrome 113 in May 2023. One exception: Chrome on Apple hardware hasn't had UA Reduction turned on the same way, so that platform still behaves a bit differently.

Here's the part that matters most for anyone writing an override script: on desktop, only two fields in that string still move, the platform family token and the major version number, and everything else is fixed, permanently, by design. A script that hardcodes a full four-part version number like Chrome/134.0.6998.31 is sending a format no real, current Chrome installation would ever produce. That mismatch gets caught before any deeper fingerprinting even starts, and it's the kind of mistake that makes every other layer of stealth work pointless.

Why headless Chrome announces itself by default and what changed in recent versions

Headless Chrome has historically been bad at hiding what it is, almost comically so. By default, it swaps "Chrome" in the UA string for "HeadlessChrome," about as subtle as a name tag that says "HELLO, I AM A BOT." A typical headless string looks like Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/131.0.0.0 Safari/537.36, and any server-side script checking for the literal substring "Headless" catches it in one line of code. For years this has been the easiest bot check on the internet, and fixing it takes almost no effort.

The newer headless mode, --headless=new, was introduced to close the gap between headless and headed behavior. Yet as of Chromium 133, bug reports confirmed the HeadlessChrome token still showed up in the UA field reported by the CDP /json/version endpoint, so the fix didn't land everywhere at once. Separately, reports from around March 2024 indicated Google had removed the token in at least one headless configuration, which means UA-string detection alone is no longer something a detection system can rely on universally. Behavior depends on the specific Chrome version and headless mode in play.

There's a second leak carrying the same information: the sec-ch-ua Client Hints header exposes the HeadlessChrome substring independently of the main UA header. Patch one and ignore the other, and the fix accomplishes roughly nothing. That raises the next question directly: what else has to match, and why?

User-Agent Client Hints and navigator.userAgentData: the second signal that must stay consistent

Client Hints exist because UA Reduction took detail out of the UA string, and something had to replace it. Sites now ask for specific details only when they need them, rather than getting a browser's full identity passively on every request. This creates a second surface any UA override has to keep in sync with the first, or the mismatch becomes its own signal, arguably a louder one than the original UA string ever was.

Two places carry this data: HTTP headers (sec-ch-ua, sec-ch-ua-platform, sec-ch-ua-mobile, plus a set of high-entropy headers a site can ask for by name), and the navigator.userAgentData JavaScript API, which exists only in Chromium-based browsers. Safari and Firefox don't implement it, so any detection logic checking for it needs a fallback for those engines.

Low-entropy data, brand names, whether the device is mobile, and the platform, comes back immediately from navigator.userAgentData with no extra step. High-entropy fields, architecture, bitness, device model, platform version, full version list, need a call to getHighEntropyValues(), which returns a Promise and can get blocked by a site's Permissions-Policy header.

One more wrinkle, and it's deliberate: Chromium injects random extra brand entries into the Client Hints brand list on purpose, a mechanism called GREASE, specifically to stop sites from building a clean whitelist of exact brand strings. A script that forges a tidy, noise-free brand list is skipping a step every real Chrome installation includes automatically, and that absence is itself a tell. Set a fake UA string but leave navigator.userAgentData untouched, and the HTTP header claims one browser while the JavaScript layer reports another. This contradiction is exactly what detection systems are built to catch, and it's a bigger red flag than an outdated version number ever was.

How to set a custom UA string across the main automation frameworks

Command-line flags are the blunt instrument. Pass --user-agent="..." at launch, and it applies across the whole browser process, with no per-context flexibility. Fine for a single script running one session; less useful for anything juggling multiple identities at once.

Playwright, Puppeteer, and Selenium each do a bit better with launch-time options, though none of them, on their own, fixes the problem the way people assume. Playwright's browser.newContext({ userAgent: ua }) sets the UA per context, so one browser instance can run several sessions, each with a different UA, without relaunching anything. Selenium and Puppeteer offer equivalent settings through their own options objects. Set the UA string this way and stop, though, and navigator.userAgentData keeps reporting the browser's real identity while the header says something else. That's the mistake most scripts make: they treat the launch option as the whole job, when it's really step one of at least three.

The Chrome DevTools Protocol gives finer control through Emulation.setUserAgentOverride({ userAgent, acceptLanguage, platform, userAgentMetadata }). That userAgentMetadata parameter is the piece most implementations skip, and it's the piece that actually covers Client Hints: brands, full version, full version list, platform, platform version, mobile flag, architecture, model, bitness, WOW64 status. Skip it while changing the UA string, and the mismatch described above shows up automatically, every time. Worth flagging too: UA overrides in Chromium can start from two separate places, the browser process itself through WebContentsImpl::SetUserAgentOverride, and CDP. Catching every request type consistently across both isn't trivial, and most scripts only handle one.

WebDriver BiDi is the newer standard, a W3C-backed protocol running over a two-way WebSocket connection, and it's getting built natively into Chrome, Edge, and Firefox. It exposes emulation.setUserAgentOverride, supports targeting specific contexts, and Selenium's own documentation describes its CDP support as a temporary bridge while BiDi matures. BiDi already covers most day-to-day needs: network interception, console access, authentication. The more exotic CDP features, heap profiling and performance tracing among them, still need dropping down to CDP directly.

CEF sits apart from all of this. UA overrides there happen at the application embedding layer rather than through any automation protocol, which matters for anyone automating a desktop app built on Chromium rather than a browser Playwright or Puppeteer can attach to directly.

The rule worth keeping: match the reduced UA format, major version only, frozen OS token, and pair every UA change with a matching userAgentMetadata update. Then check that sec-ch-ua actually reflects the override instead of assuming the header change propagated on its own. It often doesn't, and that gap is exactly where a lot of "why did this account get flagged" debugging sessions end up.

Why a correct UA string is necessary but not sufficient for passing as a real browser

Here's the part that trips people up: rotating the UA string changes exactly one header. Everything else a detection system checks stays untouched, and most of those signals get set automatically the moment the automation framework launches, with zero input from whoever wrote the script.

navigator.webdriver is the plainest example. Selenium sets this flag to true by default, and it's one of the first things anti-bot systems check, because checking a single boolean costs them nothing. --disable-blink-features=AutomationControlled suppresses it, but that's a separate fix from anything UA-related, and skipping it leaves the flag waving no matter how convincing the UA string looks.

CDP transport artifacts run deeper. The debugger connection that Playwright and Puppeteer rely on leaves characteristic traces in the performance timeline and in layout and paint event timing, and those traces survive surface-level JavaScript patches because they come from the transport layer, out of reach of anything a script can edit. Selenium leaves its own fingerprints too: injected global variables like window.cdc_adoQpoasnfa76pfcZLmcfl_Array sit in the page's JavaScript environment, untouched by any UA fix. Playwright has its own set, window.__playwright__binding__ and window.__pwInitScripts among them, sitting quietly next to whatever UA and webdriver patches got applied.

Then there's TLS fingerprinting, operating below all of this, at the network handshake. A client can claim to be Chrome all day through its UA string, but if the actual TLS handshake matches a Node.js or Python HTTP library instead of a real Chrome binary, that mismatch gets caught before the request finishes connecting. Stealth plugins working at the JavaScript layer have zero reach down there; it's a different layer of the stack entirely. UA spoofing is the entry-level move: skipping it gets you caught fast, but doing it well only clears the first of several hurdles still standing.

Tools and approaches the automation ecosystem has developed to address the full fingerprint

The ecosystem answers this at several layers, and it's worth being clear about which layer each tool actually touches, because that's where most buying decisions go wrong. At the JavaScript layer, stealth libraries patch over the obvious tells: playwright-stealth for Python, playwright-extra paired with puppeteer-extra-plugin-stealth for Node.js. For Selenium, undetected-chromedriver strips automation flags and hides the WebDriver properties detection scripts look for, while SeleniumBase packages a fuller framework with evasion behavior and CAPTCHA handling built in. All of these share the same ceiling: they work above the TLS layer, so none of them touch a transport-level fingerprint mismatch. This is the limit worth stating plainly, since it's the one stealth libraries can't advertise their way around.

A different approach skips CDP-heavy automation protocols almost entirely. Projects like nodriver and selenium-driverless cut down the number of transport-layer artifacts by not generating them in the first place, rather than trying to mask artifacts after the fact. This approach holds up better over time: masking a footprint after the fact is a permanent game of catch-up, while not leaving the footprint in the first place removes the thing detection systems are looking for. At the browser level, projects like Patchright take a modified Playwright build and patch the browser binary directly, fixing the problem below where a script can normally reach.

Betting everything on the JavaScript-layer stealth libraries and calling the job done carries real risk. They're the easiest to bolt on, and that ease is exactly why they're the least durable: detection teams test against the popular ones first, since everyone else is using them too. Cutting down how much of the automation footprint gets generated in the first place tends to hold up better than getting cleverer about hiding a footprint after the fact, since fewer artifacts to hide beats better ways of hiding them. Detection systems moved on from UA-only checks a while ago too; current server-side systems weigh behavioral patterns, TLS fingerprints, and timing signals together, because a UA check by itself hasn't reliably separated bots from humans in years.

A checklist for setting a UA override that holds up across all the signals that matter

Start with the string itself. Pick a UA in the reduced format Chrome actually ships today: major version number only, frozen OS token matching the platform family, no four-part version numbers a current browser would never send.

Set the override at the layer that fits the framework. Playwright wants newContext({ userAgent }) for per-context control. CDP users need Emulation.setUserAgentOverride with userAgentMetadata actually filled in, not left blank, and where BiDi support exists, emulation.setUserAgentOverride is the forward-looking choice.

Keep Client Hints matched to the header. Brand list, platform, mobile flag, and version inside userAgentMetadata all need to agree with what the UA string itself claims, or the mismatch becomes the tell that undoes everything else.

Check both channels, not just one. Confirm the override shows up correctly in the HTTP header and in navigator.userAgentData; checking only one and assuming the other followed along is how most mismatches happen in practice.

Handle what UA changes never touch: the navigator.webdriver flag, the Selenium and Playwright global variables sitting in the page's JavaScript scope, and the timing artifacts CDP leaves behind. None of these move when the UA string changes, so none of them get fixed by fixing the UA string; that part has to happen separately, every time.

For anything where accuracy really matters, weigh whether TLS fingerprinting is even a factor. If it is, no amount of JavaScript-layer stealth work reaches it, and that's a different problem, needing a different tool entirely, further down the stack than any UA fix can go.

One last, unglamorous point: Chrome's major version number climbs every few weeks, and a UA string hardcoded into a script today reads as stale within a month or two. Pulling the current UA string from a maintained source, or at least parameterizing the version number instead of typing it by hand, keeps a script from quietly aging out of believability while nobody's watching.

Sources

  1. blog.chromium.org
  2. github.com
  3. en.wikipedia.org
  4. developer.mozilla.org
  5. developer.mozilla.org
Filed underChromium

More in Chromium