reCAPTCHA v3 rejects your headless Chromium and no fingerprint patch fixes it
Symptom
An end-to-end check drives a form on a site I am allowed to automate. In a normal browser session the
submission succeeds. From headless Chromium, the token is generated without error — grecaptcha.execute()
resolves and returns a 2 361-character token — but the backend rejects it:
{"success":false,"errors":{"general":["validation.recaptchav3"]}}
The important detail: the failure is not client-side. There is no exception, no missing site key, no network error. The token exists and is syntactically fine. reCAPTCHA v3 is score-based, and the score arrives at the server too low to pass its threshold.
First real cause: a stale token
Before blaming fingerprints, check timing. My first implementation filled the form, then requested a token,
then did some more DOM work before the POST. That layout produces the same
validation.recaptchav3 error even in a perfectly normal browser, because v3 tokens are short-lived
(two minutes) and single-use.
Fix: request the token as the last step before the request, in the same tick as the submit:
const token = await new Promise((res, rej) =>
grecaptcha.ready(() =>
grecaptcha.execute(SITE_KEY, { action: 'my_action' }).then(res, rej)));
await fetch(endpoint, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrf, 'X-Requested-With': 'XMLHttpRequest' },
body: new URLSearchParams({ ...fields, 'g-recaptcha-response': token }),
});
In an ordinary desktop browser this single change turned the rejection into a success. In headless it did not — which is how you separate the two failure modes.
What I measured in headless, and what did not help
Fingerprint of headless Chromium versus a browser whose token was accepted (same IP, same site):
| Signal | accepted browser | headless Chromium |
|---|---|---|
navigator.userAgent | Chrome/150, Windows | HeadlessChrome by default |
navigator.webdriver | false | true by default |
window.chrome keys | loadTimes, csi, app, runtime | loadTimes, csi, app — no runtime |
navigator.languages | ["en-US","en"] | ["hu-HU","hu;q=0.9"] — malformed |
screen.width × height | 1920 × 1080 | 800 × 600 |
navigator.plugins.length | 5 | 5 |
navigator.platform | Linux x86_64 | Win32 (spoofed) |
I then applied, one at a time and cumulatively:
--user-agentplusEmulation.setUserAgentOverridewith matchinguserAgentMetadataObject.defineProperty(navigator, 'webdriver', { get: () => false })viaPage.addScriptToEvaluateOnNewDocument- a
window.chrome.runtimestub with the usual enums acceptLanguagewithoutq=values, because a rawhu-HU,hu;q=0.9string lands innavigator.languagesverbatim and looks broken--window-size=1920,1080soscreen.*stops reporting 800 × 600--disable-blink-features=AutomationControlled- a persistent profile directory instead of a fresh one, so the
_GRECAPTCHAcookie and history survive - simulated interaction before the token: 14
Input.dispatchMouseEventmoves with randomised deltas, threemouseWheelscrolls, several seconds of dwell, and a warm-up navigation to the site root so the request carries a referrer
Result after all of it: still validation.recaptchav3. The same request from a normal browser on the
same machine and IP passed on the first try.
What this means in practice
For score-based v3 there is no supported client-side switch you can flip. The score is a judgement about the whole session, and a fresh automated browser does not look like a returning human visitor no matter how many individual properties you rewrite. Chasing them one by one is unbounded work with no confirmation signal — you only ever see a boolean rejection.
Practical options, in the order I would pick them:
- Ask the site owner (or yourself, on your own site) for a test bypass. Google supports test keys and allows lowering the threshold or exempting a path. For CI against your own application this is the correct answer, not fingerprint work.
- Drive a real browser profile over CDP — a normal Chrome with the user’s profile, launched with
--remote-debugging-port. From WSL2 with mirrored networking,127.0.0.1:9222reaches a Chrome started on the Windows host, so this works even across the boundary. - Accept the limitation and move the check below the captcha: test the API with a bypass token, and test the form rendering separately.
Verification detail worth copying
When you probe an endpoint like this, look for a distinguishing response instead of a boolean. In my case the server had two different validation errors, and a submission with an incomplete payload returned
{"error":"fosorNemKitoltheto","message":"…"}
while a captcha rejection returned validation.recaptchav3. That difference is what proved the token —
not the payload — was the problem, and it is the first thing to establish before optimising anything.