DEV Community

Cover image for reCAPTCHA v3 score stuck at 0.1 or 0.3? What drives it and how to raise it
Bassem Shahin
Bassem Shahin

Posted on Edited on Originally published at dev.to

reCAPTCHA v3 score stuck at 0.1 or 0.3? What drives it and how to raise it

If your Selenium, Playwright or Puppeteer run keeps getting a reCAPTCHA v3 score of 0.1 or 0.3, reCAPTCHA decided the session looked automated before you submitted anything. v3 has no puzzle. It gives the site a number between 0.0 and 1.0, and the site's backend compares that number to its own threshold. The score comes from what reCAPTCHA sees when the page calls grecaptcha.execute(): mostly the IP address, the browser session and the signals the browser exposes. To raise it, change those inputs one at a time and measure after each change. The action name doesn't move it, and neither does the HTTP request that carries the token.

Below: how the score travels from Google to the site, why you only ever see four values, a Python and Node script that prints the score your setup gets on Google's own demo page, and the fixes in the order worth trying.

How a v3 score gets from Google to the site

  1. The page loads https://www.google.com/recaptcha/api.js?render=SITEKEY.
  2. On some event (page load, a click, a submit) it calls grecaptcha.execute(SITEKEY, {action: 'login'}). In the Network tab, the token comes back from a request to /recaptcha/api2/reload.
  3. The page's JavaScript sends the token to its own backend: a form field, a JSON body, a header. Every site does this differently.
  4. The backend POSTs its secret key and the token to https://www.google.com/recaptcha/api/siteverify.
  5. It compares the score in Google's answer to its threshold and, if it's written carefully, checks that action is the one it expected. Google's docs suggest 0.5 as a default threshold. Each site picks its own.

The siteverify answer for a v3 token looks like this:

{
  "success": true,
  "score": 0.3,
  "action": "login",
  "challenge_ts": "2026-09-29T08:12:03Z",
  "hostname": "example.com",
  "error-codes": []
}
Enter fullscreen mode Exit fullscreen mode

Three things follow from that flow:

  • You never see the score on someone else's site. You see the outcome: a 403, a "verification failed" message, an extra email or SMS step. So measure it somewhere that shows you the number.
  • Nobody can ask Google for a score. Not you, and not a solving service. The site owns the threshold.
  • The score belongs to the token. It's fixed by what the browser looked like when execute() ran. Your HTTP client never talks to reCAPTCHA in a v3 flow, so changing the headers or the TLS fingerprint of the request that carries the token doesn't change the number. That request can still trip the site's other checks, but that's a separate problem.

Why it's always 0.1, 0.3, 0.7 or 0.9

Google's reCAPTCHA docs say scores come in 11 levels from 0.0 to 1.0. Only four of them, 0.1, 0.3, 0.7 and 0.9, are returned until the site owner adds a billing account to the Google Cloud project behind their key. Most sites never do, so almost every score you'll see is one of those four.

That changes how you read a result. "Stuck at 0.1" means the lowest bucket, and 0.3 is one bucket up. Against the suggested 0.5 threshold, 0.1 and 0.3 both fail and 0.7 and 0.9 both pass. There's no slow climb to watch: a change either moves you across the line or it doesn't.

Measure your score before you change anything

Google runs a public v3 demo at recaptcha-demo.appspot.com. The page calls execute() on load, sends the token to its own backend, and shows Google's verification answer, score included. That makes it a free, safe score meter for any browser setup. This script opens it with Playwright, catches the verification response and prints the score next to navigator.webdriver, which is true in a browser driven by Playwright, Selenium or Puppeteer with default settings.

# v3_score.py: print the reCAPTCHA v3 score a browser setup gets on Google's demo page.
# pip install playwright && playwright install chromium
from playwright.sync_api import sync_playwright

DEMO = "https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php"


def measure(headless=True, channel=None, profile_dir=None, proxy=None):
    """proxy looks like {"server": "http://host:port", "username": "...", "password": "..."}"""
    opts = {"headless": headless, "channel": channel, "proxy": proxy}
    with sync_playwright() as p:
        if profile_dir:  # a saved profile keeps cookies and history between runs
            context = p.chromium.launch_persistent_context(profile_dir, **opts)
        else:
            context = p.chromium.launch(**opts).new_context()
        try:
            page = context.new_page()
            # The demo also loads enterprise.js. When that one initialises first, the
            # page's v3 execute() never resolves, so block it: v3 only needs api.js.
            page.route("**/recaptcha/enterprise.js*", lambda route: route.abort())
            # the page calls grecaptcha.execute() on load, then sends the token here
            with page.expect_response(lambda r: "recaptcha-v3-verify.php" in r.url,
                                      timeout=30_000) as verify:
                page.goto(DEMO)
            result = verify.value.json()
            return {"score": result.get("score"), "success": result.get("success"),
                    "errors": result.get("error-codes"),
                    "webdriver": page.evaluate("navigator.webdriver")}
        finally:
            context.close()


if __name__ == "__main__":
    setups = {
        "headless": {"headless": True},
        "headed": {"headless": False},
        "headed Chrome, saved profile": {"headless": False, "channel": "chrome",
                                         "profile_dir": "./v3-profile"},
    }
    for name, opts in setups.items():
        try:
            print(f"{name:30}", measure(**opts))
        except Exception as exc:  # no token in 30 s, missing Chrome channel, dead proxy...
            print(f"{name:30}", "failed:", str(exc).splitlines()[0])
Enter fullscreen mode Exit fullscreen mode

My run on 2026-09-29, from a consumer broadband line:

headless                       {'score': 0.9, 'success': True, 'errors': [], 'webdriver': True}
headed                         {'score': 0.9, 'success': True, 'errors': [], 'webdriver': True}
headed Chrome, saved profile   {'score': 0.9, 'success': True, 'errors': [], 'webdriver': True}
Enter fullscreen mode Exit fullscreen mode

Every setup scored 0.9, including plain headless Chromium with navigator.webdriver set to true. Sixteen back-to-back page loads from the same IP also all came back 0.9. From a clean residential IP, neither the automation flag nor the load rate pulled the score down on this page. That's why the IP is the first thing to test, not the last.

Why block enterprise.js? Without that line, most of my repeat loads never produced a token at all: the page's Enterprise setup took over and its v3 execute() call never resolved. Blocking the file made 16 loads out of 16 work. It only affects the demo page, not the score.

The same measurement in Node:

// v3-score.mjs: the same measurement in Node. npm i playwright && npx playwright install chromium
import { chromium } from "playwright";

const DEMO = "https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php";

async function measure({ headless = true, channel, profileDir, proxy } = {}) {
  const opts = { headless, channel, proxy }; // proxy: { server, username, password }
  const browser = profileDir ? null : await chromium.launch(opts);
  const context = profileDir
    ? await chromium.launchPersistentContext(profileDir, opts) // keeps cookies between runs
    : await browser.newContext();
  try {
    const page = await context.newPage();
    // the demo also loads enterprise.js, which can stop its v3 execute() from resolving
    await page.route("**/recaptcha/enterprise.js*", (route) => route.abort());
    const [response] = await Promise.all([
      page.waitForResponse((r) => r.url().includes("recaptcha-v3-verify.php"), { timeout: 30_000 }),
      page.goto(DEMO),
    ]);
    const result = await response.json();
    const webdriver = await page.evaluate(() => navigator.webdriver);
    return { score: result.score, success: result.success, errors: result["error-codes"], webdriver };
  } finally {
    await context.close();
    await browser?.close();
  }
}

const setups = {
  "headless": { headless: true },
  "headed": { headless: false },
  "headed Chrome, saved profile": { headless: false, channel: "chrome", profileDir: "./v3-profile" },
};
for (const [name, opts] of Object.entries(setups)) {
  try {
    console.log(name.padEnd(30), await measure(opts));
  } catch (err) { // no token in 30 s, missing Chrome channel, dead proxy...
    console.log(name.padEnd(30), "failed:", err.message.split("\n")[0]);
  }
}
Enter fullscreen mode Exit fullscreen mode
headless                       { score: 0.9, success: true, errors: [], webdriver: true }
headed                         { score: 0.9, success: true, errors: [], webdriver: true }
headed Chrome, saved profile   { score: 0.9, success: true, errors: [], webdriver: true }
Enter fullscreen mode Exit fullscreen mode

One caveat. Google says reCAPTCHA learns from each site's own traffic, so a 0.9 on the demo doesn't promise a 0.9 on your target. The demo is for comparing setups. If the same script scores 0.9 on your laptop and 0.1 on your CI runner, you've found your variable.

What pushes the score down

Google doesn't publish the model's inputs. These are the signals that keep coming up, and how to test each one with the script above:

Signal What tends to lower it How to test it
IP reputation cloud and datacenter ranges, busy shared proxies, VPN exits run the script on the machine where the job runs, then from a home connection, and compare
Session history a fresh, empty profile on every run compare profile_dir=None with a saved profile on its second and third run
Automation signals headless and automation markers the page can read compare headless with headed, and bundled Chromium with channel="chrome"

Google's docs also say v3 looks at how users interact with the site. That's the hardest signal to measure, so leave it until the three above are settled.

One thing that does not lower the score is the action name. To check, I called execute() on the demo with action: 'login' and sent the token to the demo's backend, which expects examples/v3scores. You can repeat it in Chrome. Open the demo with DevTools open, right-click the enterprise.js request in the Network tab, choose Block request URL, reload, and paste this into the console:

// DevTools console on the demo page (enterprise.js blocked, page reloaded)
const key = "6LdKlZEpAAAAAAOQjzC2v_d36tWxCl6dWsozdSy9";
for (const action of ["examples/v3scores", "login"]) {
  const token = await grecaptcha.execute(key, { action });
  const res = await fetch("/recaptcha-v3-verify.php?action=examples/v3scores&token=" + token);
  const { success, score, "error-codes": errors } = await res.json();
  console.log(action, JSON.stringify({ success, score, errors }));
}
Enter fullscreen mode Exit fullscreen mode
examples/v3scores {"success":true,"score":0.9,"errors":[]}
login {"success":false,"score":0.9,"errors":["action-mismatch"]}
Enter fullscreen mode Exit fullscreen mode

The score stayed at 0.9 and the token was rejected anyway. A wrong action gets the token turned away without touching the score. So copy the exact string the page passes to execute() (Google allows only letters, digits, slashes and underscores) instead of guessing a "better" one.

How to raise it, in order

  1. Get a baseline. Run the measuring script from the same machine, network and browser setup your job uses. If that already scores 0.7 or 0.9 and the site still refuses you, the score isn't the problem. Skip to "When the score isn't the problem" below.
  2. Change the IP first. If the job runs in a cloud region, measure again through a residential or mobile connection in the country the site's real users are in. In my runs the automation flag made no difference from a clean IP, which points at the IP as the bigger lever. It's also one argument to test (proxy=).
  3. Keep the session. Use a persistent profile (launch_persistent_context in Python, launchPersistentContext in Node) so cookies and history survive between runs, instead of a new empty context every time.
  4. Try a real browser build, headed. channel="chrome" drives the installed Google Chrome instead of the bundled Chromium. Measure, and keep it only if the number moves.
  5. Re-measure after every change. With four buckets, a change either crosses the site's threshold or it doesn't. Keep the ones that do and drop the rest, so you don't carry a pile of tweaks that never helped.

If it's your own site

If the low scores are hitting your own end-to-end tests, don't try to make the test browser look human. Google's FAQ says to create a separate key for testing environments because scores there "may not be accurate", and the v3 docs warn that staging scores can differ from production. Use a test key in staging and mock the siteverify call in the test environment, so the suite never depends on a score. If real users are getting 0.1 or 0.3, Google suggests running v3 without acting on the score at first. Log score and action for a while, then pick the threshold from your own traffic.

When the score isn't the problem

  • Action mismatch. Shown above. Copy the string exactly, including case and slashes.
  • Expired or reused token. Per Google's docs, a token is valid for two minutes and can be verified once. siteverify reports both cases as timeout-or-duplicate. Get a fresh token right before the request that uses it.
  • It isn't v3. A widget with data-size="invisible" is v2 invisible, and v3 parameters won't help against it. I covered how to tell them apart in reCAPTCHA v2 vs v3 vs Enterprise: how to tell which one you're fighting.
  • Enterprise rules. An Enterprise site gets a fuller assessment and can reject a token over hostname, IP or session rules even when the score is fine.

Getting the token from a solving API instead

Sometimes you've done the fundamentals and the setup still can't reach the site's threshold, or the job runs somewhere you can't put a real browser. The other route is to get the token from a solving service: its browser runs execute(), and you send the token the way the page would. Many services use the 2Captcha-style in.php/res.php API. For v3, the two parameters people forget are version=v3 and action. 2Captcha also takes a min_score, but the endpoint below has no such parameter. The token scores whatever Google gives the solver's session, and the site's threshold still decides.

# get_v3_token.py: fetch a reCAPTCHA v3 token from a 2Captcha-compatible in.php / res.php API
import os
import time

import requests

API = "https://ocr.captchaai.com"
KEY = os.environ.get("CAPTCHA_API_KEY", "YOUR_API_KEY")
TRANSIENT = {"ERROR_SERVER_ERROR", "ERROR_INTERNAL_SERVER_ERROR"}


def get_v3_token(sitekey, pageurl, action, timeout=120):
    if KEY in ("", "YOUR_API_KEY"):
        raise SystemExit("Set CAPTCHA_API_KEY first")
    r = requests.post(f"{API}/in.php", timeout=30, data={
        "key": KEY, "method": "userrecaptcha", "version": "v3",  # version=v3 is required
        "googlekey": sitekey, "pageurl": pageurl, "action": action, "json": 1}).json()
    if r["status"] != 1:  # ERROR_WRONG_USER_KEY, ERROR_ZERO_BALANCE, ERROR_WRONG_SITEKEY...
        raise RuntimeError(f"submit rejected: {r['request']}")
    task_id, deadline, delay = r["request"], time.time() + timeout, 5
    time.sleep(15)  # the API's v3 guide says to wait 15-20 s before the first poll
    while time.time() < deadline:
        r = requests.get(f"{API}/res.php", timeout=30, params={
            "key": KEY, "action": "get", "id": task_id, "json": 1}).json()
        if r["status"] == 1:
            return r["request"]
        if r["request"] == "CAPCHA_NOT_READY":
            delay = 5
        elif r["request"] in TRANSIENT:
            delay = min(delay * 2, 30)  # back off, keep polling
        else:  # ERROR_CAPTCHA_UNSOLVABLE, ERROR_WRONG_CAPTCHA_ID...
            raise RuntimeError(f"solve failed: {r['request']}")
        time.sleep(delay)
    raise TimeoutError(f"task {task_id}: no token after {timeout}s")


if __name__ == "__main__":
    page, action = "https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php", "examples/v3scores"
    started = time.time()
    token = get_v3_token("6LdKlZEpAAAAAAOQjzC2v_d36tWxCl6dWsozdSy9", page, action)
    print(f"token {token[:20]}... in {time.time() - started:.0f}s")
    # the demo's backend runs the verification and returns Google's answer, score included
    check = requests.get("https://recaptcha-demo.appspot.com/recaptcha-v3-verify.php",
                         params={"action": action, "token": token}, timeout=30).json()
    print({k: check.get(k) for k in ("success", "score", "action", "error-codes")})
Enter fullscreen mode Exit fullscreen mode

My run against the demo on 2026-09-29:

token 0cAFcWeA6dVunEBcVyiO... in 16s
{'success': True, 'score': 0.9, 'action': 'examples/v3scores', 'error-codes': []}
Enter fullscreen mode Exit fullscreen mode

Look at the second line. The token was created in the solver's browser and sent to the demo from Python requests, a different client on a different IP, and it still scored 0.9. That's "the score belongs to the token" in practice. The two-minute clock is also already running by the time res.php hands you the token, so use it straight away. The parameters match 2Captcha's, so moving an existing client over is mostly a base-URL change.

FAQ

Can I request a higher reCAPTCHA v3 score?

No. Nothing in reCAPTCHA's flow lets you ask Google for a score, and the API in the last script has no min_score parameter. The site's backend picks the threshold, and the token clears it or doesn't.

Why is my score always exactly 0.1, 0.3, 0.7 or 0.9?

Those are the four levels Google returns unless the site owner has added a billing account to their Google Cloud project, which unlocks all 11.

Does the action name change the score?

No. In the test above, the wrong action came back with the same 0.9 plus action-mismatch. The action decides whether the token is accepted, not how it scores.

Will hiding navigator.webdriver fix a 0.1?

Not by itself. On a clean IP, my runs scored 0.9 with it set to true. If you're at 0.1 from a datacenter IP, change the IP first and measure again.

Is reCAPTCHA v3 Enterprise scored differently?

It's the same 0.0 to 1.0 idea, loaded through enterprise.js. The site's backend gets a fuller assessment it can apply its own rules to. For a solving API it's the same request plus enterprise=1, and if the API returns a User-Agent with the token, send the token with that User-Agent.


I'm Bassem, and I run CaptchaAI, the ocr.captchaai.com endpoint in the last script. It returns reCAPTCHA v3 and v3 Enterprise tokens for the action you send, and enterprise sites can still apply their own rules, so test against your real target first. A free thread is enough for that: one thread for 30 days, no card. The parameters are in the reCAPTCHA v3 guide.

Top comments (0)