errno

reCAPTCHA v3 rejects your headless Chromium and no fingerprint patch fixes it

· tested on Chrome for Testing 149 headless=new, reCAPTCHA v3 (invisible, score-based), Node 22 CDP client

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):

Signalaccepted browserheadless Chromium
navigator.userAgentChrome/150, WindowsHeadlessChrome by default
navigator.webdriverfalsetrue by default
window.chrome keysloadTimes, csi, app, runtimeloadTimes, csi, appno runtime
navigator.languages["en-US","en"]["hu-HU","hu;q=0.9"] — malformed
screen.width × height1920 × 1080800 × 600
navigator.plugins.length55
navigator.platformLinux x86_64Win32 (spoofed)

I then applied, one at a time and cumulatively:

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:

  1. 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.
  2. 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:9222 reaches a Chrome started on the Windows host, so this works even across the boundary.
  3. 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.

recaptcha chromium headless automation