CAPTCHAs, Challenges & Solving Ethically
On this page
When the Robot Test Appears#
At some point in a real crawl, a page stops returning data and starts returning a puzzle, a "checking your browser" interstitial, or a quiet 403. CAPTCHAs and challenges are how a site says "we think this traffic is automated, and we want to slow it down or stop it". This lesson is not a guide to defeating them. It covers what the different kinds are, why your scraper triggered one, how to detect challenges automatically so your pipeline does not store garbage, and the legitimate ways to keep getting the data.
The Types You Will Actually Meet#
| Type | What the user sees | What it actually checks |
|---|---|---|
| Classic text or image CAPTCHA | Distorted characters to type | Whether the answer is correct |
| Image-grid challenge (reCAPTCHA v2, hCaptcha) | "Select all images with bicycles", or just a checkbox | The answer plus browser, cookie and interaction signals |
| Score-based, invisible (reCAPTCHA v3) | Nothing | Produces a risk score from 0.0 to 1.0; the site decides what to do with it |
| Managed or non-interactive (Cloudflare Turnstile and similar) | Usually nothing, sometimes a checkbox | Runs browser checks in the background, escalates only when suspicious |
| JavaScript or proof-of-work interstitial | "Checking your browser..." for a few seconds | That a real JavaScript engine ran and solved a computation, then sets a clearance cookie |
| Block page | "Access denied" with a reference ID | Nothing to solve; the request was already judged |
The key point is that modern challenges are rarely about the puzzle. The visible task is the last step of a risk assessment that already considered your IP address, TLS and HTTP/2 fingerprint, headers, cookies, request rate and navigation pattern. That is why "solving" the puzzle often just produces another one: the risk score that caused it did not change. The machinery behind these scores is covered in how anti-bot systems work.
Why Your Scraper Triggered One#
Diagnose before doing anything else. When the challenge appears tells you what caused it:
| When it appears | Likely cause |
|---|---|
| On the very first request | IP reputation (datacenter or shared proxy ranges), or a client fingerprint that no browser produces |
| After some number of requests | Rate: you are faster or more regular than people are |
| Only on certain paths (search, login, checkout) | Those endpoints are protected for everyone; listing pages may not be |
| Only for some URLs in a crawl | Deep, rarely visited URLs requested without the navigation that normally precedes them |
| After a cookie was dropped | Your client did not keep the session or clearance cookie between requests |
Most of these causes are fixed by behaving like a considerate client, not by any solving code: keep cookies in a session, send a coherent set of headers, slow down to a pace a site can absorb (see rate limiting and politeness), add jitter so requests are not perfectly periodic, and avoid crawling at a rate that dwarfs the site's human traffic. A crawler that respects robots.txt and crawl delays meets far fewer challenges than one that does not.
Detect Challenges Automatically#
The dangerous case is not a crash. It is a challenge page returned with status 200 that your parser treats as a product page with no products, so the dataset silently fills with empty records. Classify every response before parsing it:
CHALLENGE_MARKERS = (
"g-recaptcha", "recaptcha/api.js",
"hcaptcha.com", "h-captcha",
"cf-turnstile", "challenges.cloudflare.com", "/cdn-cgi/challenge-platform",
"captcha", "are you a robot", "verify you are human",
)
def classify(resp, expect_json=False, required_selector_hit=True):
ctype = resp.headers.get("content-type", "")
if resp.headers.get("cf-mitigated") == "challenge":
return "challenge"
if resp.status_code == 429:
return "rate_limited"
if expect_json and "json" not in ctype:
return "challenge" if resp.status_code in (200, 403, 503) else "error"
body = resp.text[:20000].lower() if "html" in ctype else ""
if any(m in body for m in CHALLENGE_MARKERS):
return "challenge"
if resp.status_code in (401, 403):
return "blocked"
if resp.status_code >= 500:
return "error"
if not required_selector_hit:
return "unexpected_page" # 200, no markers, but the data is not there
return "ok"
Notes on the choices:
- Checking
content-typefirst is the cheapest reliable signal for APIs: an endpoint that normally returns JSON and suddenly returns HTML is almost always a challenge or a block page. - Cloudflare documents a
cf-mitigated: challengeresponse header on challenge pages, which is more reliable than searching the body. - The generic words ("captcha", "verify you are human") can appear on normal pages, for example in a site's help text. Treat a marker hit as a strong hint and combine it with "the selector I expected is missing".
unexpected_pagecatches everything else: consent walls, country selectors, login redirects. Save a sample of each unexpected page to disk so a human can look at it.
Back Off, Don't Push Through#
When challenges start, continuing at the same rate makes the problem worse: the IP or session collects more negative signals and the block becomes longer. Treat challenges as a signal to reduce load on that host, using a per-host circuit breaker:
import time
from collections import defaultdict, deque
class HostBreaker:
def __init__(self, threshold=3, window=300, base_pause=600, max_pause=6 * 3600):
self.events = defaultdict(deque)
self.paused_until = {}
self.trips = defaultdict(int)
self.threshold, self.window = threshold, window
self.base_pause, self.max_pause = base_pause, max_pause
def allowed(self, host):
return time.time() >= self.paused_until.get(host, 0)
def record(self, host, outcome):
now = time.time()
if outcome == "ok":
self.trips[host] = 0
return
if outcome not in ("challenge", "blocked", "rate_limited"):
return
q = self.events[host]
q.append(now)
while q and now - q[0] > self.window:
q.popleft()
if len(q) >= self.threshold:
pause = min(self.max_pause, self.base_pause * 2 ** self.trips[host])
self.paused_until[host] = now + pause
self.trips[host] += 1
q.clear()
print(f"{host}: {self.threshold} challenges in {self.window}s, pausing {pause // 60:.0f} min")
Three challenges in five minutes pause the host for ten minutes, then twenty, then forty, up to six hours. While paused, the scheduler works on other hosts. Every trip should also raise an alert, because repeated trips mean your crawler's behaviour needs to change, not that it needs more patience. Lower the concurrency for that host when it resumes. The error handling lesson covers where this fits among other retry decisions.
The Legitimate Paths Forward#
A challenge on data you need is a prompt to look for a door the site actually opened:
- An official API or data export. Many sites offer one, sometimes free with a key and rate limits, sometimes paid. It is stable, documented and legal to use within its terms. See official APIs and alternative data.
- Ask. A short email explaining who you are, what data you need, how often, and at what rate gets a yes more often than people expect, sometimes with an allowlisted IP or a dedicated feed. Put a contact address in your
User-Agentso the site can reach you, too. - Other sources for the same data. Sitemaps, RSS feeds, public datasets, government registries and aggregators often carry the same facts without a challenge.
- A human in the loop, for your own authorised access. For internal tools working on an account you legitimately hold, a person can complete the challenge once in a real browser and the automated session continues at a low rate with that session's cookies. That pattern is described in browser session handoff. It does not change the site's terms: if they forbid automated access, this does not make it permitted.
Commercial CAPTCHA-solving services also exist, where people or models solve challenges for a fee. Outsourcing the solving does not change the legal or terms-of-service position at all; it also means sending the target page's details to a third party. How these services work and why sites detect them is covered in CAPTCHA solving.
The Legal and Ethical Line#
A CAPTCHA is an unambiguous statement of what the site operator wants. Deliberately circumventing it to continue automated collection typically violates the site's terms, and in some jurisdictions circumventing a technical access control can weaken any argument that your access was authorised. Scraping personal data behind a challenge adds data protection obligations on top. Read legal considerations before deciding, and when in doubt, choose a different source.
If You Run the Site: Verify on the Server#
The defensive side has one classic mistake worth knowing because scrapers exploit it: checking the CAPTCHA only in the browser. The widget produces a token; your backend must send that token to the provider's verification endpoint with your secret key and reject the request if verification fails. For reCAPTCHA:
import httpx
def verify_recaptcha(token: str, secret: str, remote_ip: str | None = None) -> bool:
data = {"secret": secret, "response": token}
if remote_ip:
data["remoteip"] = remote_ip
r = httpx.post("https://www.google.com/recaptcha/api/siteverify", data=data, timeout=10)
result = r.json()
return bool(result.get("success"))
Check more than success: tokens are single-use and short-lived, the response includes the hostname the token was issued for (confirm it is yours), and score-based versions return a score and action that you should compare against a threshold and the action you expected. Providers offer test keys that always pass, so automated tests of your own forms do not need to solve anything.
Practice#
Add classify() and HostBreaker to an existing scraper. Replay a saved challenge page and a saved normal page through classify() to confirm both are recognised. Then run the scraper against your own test site with a deliberately aggressive rate limiter in front of it, and confirm the breaker pauses the host instead of storing empty records.
Check your understanding
4 quick questions on this lesson. Get them all right and the lesson is marked complete.