Skip to content

launch --headless sends "HeadlessChrome" in the User-Agent, and some sites block on that alone #12

Closed as not planned

Description

@JayOfTheKeyboard

What happens

chrome-agent launch --headless (0.6.1, Chrome 153, Linux) sends this User-Agent, both in the
request header and in navigator.userAgent:

Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.0.0 Safari/537.36

At least one large site blocks on that string alone. Headless, https://www.imdb.com/chart/tvmeter/
returns 403 with a page titled 403 Forbidden and none of the 100 list rows. Headed, the same page
loads normally.

The only difference is the string. Launching headless with -- --user-agent= set to the same value,
with HeadlessChrome/ replaced by Chrome/, loads the full page. No JavaScript overrides and no
fingerprint profile are involved. The sec-ch-ua header is "Chromium";v="153", "Not_A Brand";v="8"
in all three cases below, so the client hints do not give headless away; only the UA string does.

launch status rows title User-Agent sent
--headless 403 0 403 Forbidden ...HeadlessChrome/153.0.0.0...
--headless -- --user-agent=...Chrome/153.0.0.0... 202 100 Most popular TV shows ...Chrome/153.0.0.0...
headed 202 100 Most popular TV shows (after the instance marker) ...Chrome/153.0.0.0...

It is easy to misdiagnose. Relaunching headed works, so the natural conclusion is "this site needs a
real window", when the only thing that changed was the UA string. That is where I landed first.

Why raise it here

fingerprint.py makes the case for leaving the JavaScript environment alone and spoofing only what
can be set cleanly at launch. --user-agent is exactly that kind of flag, but launch_browser only
passes it when a fingerprint profile is given, so plain --headless sends the string that is
simplest to sniff.

Options, roughly smallest first

  1. Document it in AGENTS.md next to launch --headless: what the UA looks like, and the
    -- --user-agent=... workaround.
  2. In headless mode, pass --user-agent with HeadlessChrome/ rewritten to Chrome/, unless a
    fingerprint profile or an explicit --user-agent is already given. This needs the browser
    version at launch time, for example from the binary find_chrome_binary() returns.
  3. The same as 2, behind a flag, for anyone who wants the honest headless UA.

This changes default behaviour, which is why I'm opening an issue rather than a PR. Happy to send a
PR for whichever you prefer.

Method

chrome-agent 0.6.1, Chrome 153.0.8010.52, Fedora, Linux 7.2, 2026-09-26. Each case was launched
through the chrome-agent CLI as a fresh instance, twice, with the cache disabled, waiting up to
25 s for 100 rows to render. Status and sent headers come from Network.responseReceived and
Network.requestWillBeSentExtraInfo for the chart document. The headless block also reproduced on
chrome-agent 0.5.7 with Chrome 151 in August. Happy to share the script.

Activity

  1. captivus commented on Sep 28, 2026

    @captivus
    Owner

    Thanks for such a thorough report, @JayOfTheKeyboard. The side-by-side table and the header capture made this easy to confirm. I reproduced it on Chrome 151: plain Chrome launched with --headless=new, without chrome-agent involved at all, sends the same HeadlessChrome/151.0.0.0 UA. So chrome-agent is passing along Chrome's own behaviour, not introducing it.

    I've gone with option 1, documentation, and here's why. chrome-agent's default is to launch Chrome as Chrome launches itself, and anything beyond that is opt-in. Both opt-in routes already exist: a --fingerprint profile sets the UA, and the -- passthrough takes --user-agent directly. Rewriting the UA by default would make headless chrome-agent behave differently from headless Chrome, and it would add moving parts: reading the browser version before launch (which chrome.exe --version doesn't support on Windows), and keeping a generated UA out of session snapshots so a restore after a Chrome update doesn't pin an old version.

    Your point about misdiagnosis shaped the docs most. The new Gotchas entry in AGENTS.md opens with "a site that fails headless but works headed may just be sniffing the User-Agent" and says not to switch to headed. The fix it gives derives the UA from the running browser (Browser.getVersion, then HeadlessChrome/ → Chrome/), so the version and platform always match the installed Chrome instead of going stale in a hardcoded string. There's a matching paragraph in the README's fingerprinting section. Landed in ab3f3be.

    If this turns out to bite people often, I'm open to revisiting option 3 (an opt-in flag) later. Closing for now. Thanks again!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions