DEV Community

Alex Chen
Alex Chen

Posted on

Understanding reCAPTCHA v3 Scores: Why Your Scraper Gets Blocked Even Without a Checkbox

reCAPTCHA v2 is straightforward — there's a checkbox, maybe an image challenge, and you get a token. reCAPTCHA v3 is completely different. There's no checkbox. No puzzle. No visible challenge at all.

Yet it blocks scrapers more effectively than v2 ever did. Here's how it works and how to handle it.

How reCAPTCHA v3 Works

reCAPTCHA v3 runs silently in the background and assigns every visitor a score from 0.0 (bot) to 1.0 (human). The site owner decides the threshold — typically 0.5 to 0.7.

Score 0.0-0.3 → Almost certainly a bot
Score 0.3-0.7 → Suspicious (site decides)
Score 0.7-1.0 → Probably human
Enter fullscreen mode Exit fullscreen mode

The score is based on:

  • Mouse movement patterns — humans move cursors in curves, bots in straight lines
  • Scroll behavior — how you scroll through the page
  • Time on page — bots are too fast
  • Browser fingerprint — plugins, screen size, fonts
  • Google cookie history — logged into Google? Higher score
  • Historical behavior — your IP's reputation across all reCAPTCHA-protected sites

Why This Is Harder Than v2

With v2, you know when you're blocked — there's a visible challenge. With v3, you might not even realize you're being scored. The site silently receives your score and decides what to do:

// Site owner's code (server-side)
const score = verifyRecaptcha(token).score;

if (score < 0.5) {
    // Block or add friction
    return res.status(403).json({error: "Verification failed"});
}
// Otherwise, proceed normally
Enter fullscreen mode Exit fullscreen mode

Your scraper gets a 403 or a redirect to a "please verify" page, and you have no idea why.

Detecting reCAPTCHA v3 on a Page

v3 is harder to detect because there's no visible widget:

import re

def detect_recaptcha_v3(html: str) -> dict | None:
    html_lower = html.lower()

    # Check for v3-specific indicators
    v3_indicators = [
        "recaptcha/api.js?render=",      # v3 loads with render param
        "grecaptcha.execute",             # v3 uses execute, not render
        "recaptcha-v3",
        "action:",                        # v3 requires an action parameter
    ]

    if not any(ind in html_lower for ind in v3_indicators):
        return None

    # Extract sitekey from render parameter
    match = re.search(
        r'recaptcha/api\.js\?render=([0-9A-Za-z_-]+)', html
    )
    if match:
        return {"version": "v3", "sitekey": match.group(1)}

    # Fallback: look in grecaptcha.execute calls
    match = re.search(
        r'grecaptcha\.execute\(["\']([0-9A-Za-z_-]+)', html
    )
    if match:
        return {"version": "v3", "sitekey": match.group(1)}

    return None
Enter fullscreen mode Exit fullscreen mode

Key difference from v2: the sitekey is in the render\ parameter of the script URL, not in a data-sitekey\ attribute.

The Action Parameter

reCAPTCHA v3 requires an "action" string that describes what the user is doing:

// Frontend code on the target site
grecaptcha.execute('SITEKEY', {action: 'login'}).then(token => {
    // Send token to server
});
Enter fullscreen mode Exit fullscreen mode

Common actions: login\, submit\, homepage\, purchase\, signup\

You need to find this action parameter and include it when solving. If you send the wrong action, the score drops.

def extract_action(html: str) -> str:
    """Find the action parameter in grecaptcha.execute calls."""
    import re
    match = re.search(
        r'grecaptcha\.execute\([^,]+,\s*\{action:\s*["\']([^"\']+)', html
    )
    return match.group(1) if match else "verify"
Enter fullscreen mode Exit fullscreen mode

Solving reCAPTCHA v3 via API

import httpx
import os

async def solve_recaptcha_v3(sitekey: str, page_url: str, 
                              action: str = "verify") -> dict:
    async with httpx.AsyncClient() as client:
        resp = await client.post(
            "https://api.passxapi.com/solve",
            json={
                "type": "recaptcha_v3",
                "sitekey": sitekey,
                "url": page_url,
                "action": action,
                "min_score": 0.7,  # request high score
            },
            headers={"x-api-key": os.getenv("PASSXAPI_KEY")},
            timeout=30,
        )
        result = resp.json()
        return {
            "token": result["token"],
            "score": result.get("score"),
        }
Enter fullscreen mode Exit fullscreen mode

Or with the SDK:

from passxapi import AsyncClient

solver = AsyncClient(api_key=os.getenv("PASSXAPI_KEY"))

result = await solver.solve(
    captcha_type="recaptcha_v3",
    sitekey="6Lc...",
    url="https://example.com/login",
    action="login",
    min_score=0.7,
)
print(f"Token: {result['token']}")
print(f"Score: {result.get('score', 'N/A')}")
Enter fullscreen mode Exit fullscreen mode

v3 Token Submission Patterns

Sites handle v3 tokens in different ways. You need to figure out which one:

Pattern 1: Hidden Form Field

async def submit_with_v3_token(client, url, form_data, token):
    """Token goes in a hidden input field."""
    form_data["g-recaptcha-response"] = token
    return await client.post(url, data=form_data)
Enter fullscreen mode Exit fullscreen mode

Pattern 2: HTTP Header

async def submit_with_header(client, url, data, token):
    """Some sites expect the token in a custom header."""
    headers = {"X-Recaptcha-Token": token}
    return await client.post(url, json=data, headers=headers)
Enter fullscreen mode Exit fullscreen mode

Pattern 3: JavaScript Callback

# When using Playwright/Selenium
async def inject_v3_token(page, token):
    """For sites that use a JS callback after execute()."""
    await page.evaluate(f"""
        // Find and call the then() callback
        const callbacks = window.__recaptcha_callbacks || [];
        callbacks.forEach(cb => cb('{token}'));

        // Also set hidden field as fallback
        const field = document.querySelector(
            'input[name="g-recaptcha-response"],' +
            'textarea#g-recaptcha-response'
        );
        if (field) field.value = '{token}';
    """)
Enter fullscreen mode Exit fullscreen mode

Finding Which Pattern a Site Uses

Use browser DevTools to figure out how the site sends the token:

# Quick check with Playwright
async def analyze_recaptcha_flow(url):
    """Watch network requests to see how the token is submitted."""
    from playwright.async_api import async_playwright

    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=False)
        page = await browser.new_page()

        requests_log = []
        page.on("request", lambda req: requests_log.append({
            "url": req.url,
            "method": req.method,
            "post_data": req.post_data,
            "headers": dict(req.headers),
        }))

        await page.goto(url)
        # Manually complete the form and submit...
        # Then check requests_log for g-recaptcha-response

        for req in requests_log:
            if req["post_data"] and "g-recaptcha-response" in str(req["post_data"]):
                print(f"Token sent via POST to: {req['url']}")
                print(f"In body: {req['post_data'][:200]}")
            if "recaptcha" in str(req["headers"]).lower():
                print(f"Token sent via header to: {req['url']}")
Enter fullscreen mode Exit fullscreen mode

v2 vs v3 Comparison

Feature reCAPTCHA v2 reCAPTCHA v3
Visible challenge Yes (checkbox/images) No (invisible)
User friction High None
Sitekey location data-sitekey attribute Script render param
Action parameter Not needed Required
Returns Token (pass/fail) Token + score (0-1)
Token field g-recaptcha-response g-recaptcha-response
Token TTL 120s 120s
Detection difficulty Easy (visible widget) Hard (no widget)
Solve approach Solve challenge Generate high-score token

Common Mistakes

  1. Ignoring the action parameter — Sending action="verify" when the site expects "login" tanks your score
  2. Not requesting min_score — Default scores might be too low for the site's threshold
  3. Using the same token twice — v3 tokens are single-use, just like v2
  4. Missing v3 entirely — No visible widget means you might not realize it's there until you get 403s

Full Working Example

import httpx
import asyncio
import os
import re

async def scrape_v3_protected_site(url: str) -> str:
    async with httpx.AsyncClient(follow_redirects=True) as client:
        # Load page
        resp = await client.get(url)

        # Detect v3
        v3_info = detect_recaptcha_v3(resp.text)
        if not v3_info:
            return resp.text  # No CAPTCHA

        # Extract action
        action = extract_action(resp.text)

        # Solve with high score
        solved = await solve_recaptcha_v3(
            sitekey=v3_info["sitekey"],
            page_url=url,
            action=action,
        )

        # Submit (try common patterns)
        resp = await client.post(url, data={
            "g-recaptcha-response": solved["token"],
        })

        if resp.status_code == 200:
            return resp.text

        # If form submission didn't work, try header
        resp = await client.post(url, headers={
            "X-Recaptcha-Token": solved["token"],
        })

        return resp.text

asyncio.run(scrape_v3_protected_site("https://example.com"))
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

reCAPTCHA v3 is sneakier than v2 — no visible challenge, score-based decisions, and an action parameter you need to get right. But once you understand the mechanics, solving it is straightforward: extract the sitekey and action from the page source, get a high-score token, and submit it.

Full SDK with v3 support: passxapi-python on GitHub


Have you been caught off-guard by reCAPTCHA v3 on a site? Share your experience below.

Top comments (0)