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
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
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
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
});
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"
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"),
}
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')}")
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)
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)
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}';
""")
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']}")
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
- Ignoring the action parameter — Sending action="verify" when the site expects "login" tanks your score
- Not requesting min_score — Default scores might be too low for the site's threshold
- Using the same token twice — v3 tokens are single-use, just like v2
- 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"))
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)