How to Bypass Cloudflare When Web Scraping
GuideCloudflare blocks scrapers at four different layers. Here is what each one checks, how to defeat them in Python, and when to stop building your own bypass.
You wrote twelve lines of requests and BeautifulSoup, ran them against a product page, and got this back:
<html><head><title>Just a moment...</title></head>
Or worse, a bare 403 Forbidden with an cf-ray header and no explanation.
Cloudflare sits in front of roughly one in five websites on the internet, including most of the e-commerce, travel and real-estate targets developers actually want data from. It does not have a single "bot check" you can switch off. It has four independent layers, each inspecting something different, and passing one tells you nothing about the next.
This guide walks through all four, in the order Cloudflare evaluates them, with working Python for each. By the end you will know exactly which layer is blocking you, how to get past it, and roughly what each fix costs in engineering time.
What you'll learn:
- How to read Cloudflare's error codes to identify the blocking layer
- Why a perfect
User-Agentstring still gets you a 403 (TLS fingerprinting) - How to defeat JA3/JA4 fingerprinting with
curl_cffi - When a headless browser helps and when it makes things worse
- How to rotate residential proxies without getting the whole pool burned
- When building your own bypass stops being worth it
Prerequisites
python -m venv venv && source venv/bin/activate
pip install requests curl_cffi playwright beautifulsoup4
playwright install chromium
Everything below is Python 3.10+. No paid account is required until the last section.
1. First, identify what is actually blocking you
Do not start writing bypass code until you know which layer failed. Cloudflare tells you, if you read the response instead of just the status code.
import requests
resp = requests.get("https://example-store.com/product/12345", timeout=30)
print("status:", resp.status_code)
print("cf-ray:", resp.headers.get("cf-ray"))
print("cf-mitigated:", resp.headers.get("cf-mitigated"))
print("server:", resp.headers.get("server"))
print("body starts:", resp.text[:200])
Map the result against this table:
| Signal | What it means | Layer blocking you |
|---|---|---|
403 + cf-mitigated: challenge |
Managed challenge issued, not solved | Layer 2 — browser integrity |
403 + "Error 1020: Access denied" |
A WAF firewall rule matched you | Layer 4 — IP / ASN reputation |
403 + "Error 1010: browser signature banned" |
Your automation was fingerprinted | Layer 3 — browser fingerprint |
429 + "Error 1015: rate limited" |
Too many requests per IP | Layer 4 — rate limiting |
503 + "Just a moment..." |
JS challenge page (interstitial) | Layer 2 — JS execution required |
200 + Turnstile widget in HTML |
Interactive CAPTCHA | Layer 2/3 combined |
200 + real content but wrong data |
You got a bot-served decoy page | Layer 3 — silent fingerprint fail |
That last row is the one that quietly ruins datasets. Cloudflare-fronted sites increasingly return a valid 200 with degraded content — stale prices, missing stock, no reviews — rather than an obvious block. Always assert on the data itself, not the status code:
def looks_real(html: str) -> bool:
markers = ["Just a moment", "cf-browser-verification", "Attention Required"]
if any(m in html for m in markers):
return False
return len(html) > 20_000 # tune per target
2. Layer 1: HTTP headers (the easy 10%)
The default requests header set announces itself immediately:
User-Agent: python-requests/2.32.3
Accept: */*
Accept-Encoding: gzip, deflate
No browser has ever sent that. Fix it by sending a complete, internally consistent header set — order matters, and so does sending the headers a real Chrome actually sends:
import requests
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
),
"Accept": (
"text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,image/apng,*/*;q=0.8"
),
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"sec-ch-ua": '"Chromium";v="126", "Not(A:Brand";v="24", "Google Chrome";v="126"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
}
session = requests.Session()
session.headers.update(HEADERS)
resp = session.get("https://example-store.com/product/12345", timeout=30)
print(resp.status_code)
The classic mistake: claiming sec-ch-ua-platform: "Windows" while your User-Agent says macOS, or advertising Chrome/126 with Chrome 120's client hints. Cloudflare cross-checks these. An inconsistent header set is a stronger bot signal than the honest python-requests default.
This gets you past the laziest configurations. On anything that has actually tuned its bot-fight settings, you will still get a 403 — because Cloudflare never even read your headers.
3. Layer 2: TLS fingerprinting (why perfect headers still fail)
Here is the part most tutorials skip.
Before a single HTTP header is transmitted, your client performs a TLS handshake. In the ClientHello packet it advertises its cipher suites, supported TLS extensions, elliptic curves and their exact ordering. Hash that and you get a JA3 (or newer JA4) fingerprint.
Python's requests uses OpenSSL. Chrome uses BoringSSL. They produce completely different fingerprints. Cloudflare knows Chrome 126's JA4 by heart, and it knows yours claims to be Chrome 126 while handshaking like a Python script. That contradiction is enough to block you before your headers are ever parsed.
You cannot fix this with requests. You need a client that mimics a real browser's TLS stack. curl_cffi binds to curl-impersonate and does exactly that:
from curl_cffi import requests as cffi_requests
resp = cffi_requests.get(
"https://example-store.com/product/12345",
impersonate="chrome126", # matches TLS + HTTP/2 fingerprint
timeout=30,
)
print(resp.status_code)
print(len(resp.text))
impersonate also handles the HTTP/2 fingerprint — the SETTINGS frame values, window size and header-frame pseudo-header ordering — which is a second, independent fingerprint Cloudflare checks.
Keep your impersonation target and your User-Agent aligned:
IMPERSONATE = "chrome126"
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
resp = cffi_requests.get(url, impersonate=IMPERSONATE, headers={"User-Agent": UA})
Announcing chrome126 TLS with a Chrome 120 User-Agent recreates the exact contradiction you just fixed.
Maintenance cost: Chrome ships a stable release roughly every four weeks. Each one shifts the fingerprint. curl_cffi targets lag real Chrome by weeks, and during that window you are impersonating a version that is no longer common in the wild — itself an anomaly. Budget for this being a recurring chore, not a one-time fix.
4. Layer 3: browser and behavioral fingerprinting
If the target issues a JS challenge — the "Just a moment..." interstitial — no HTTP client will pass it. The page runs JavaScript that probes the runtime and posts the result back before granting a cf_clearance cookie.
That means a real browser. Playwright, with a few corrections:
from playwright.sync_api import sync_playwright
def fetch(url: str) -> str:
with sync_playwright() as p:
browser = p.chromium.launch(
headless=True,
args=[
"--disable-blink-features=AutomationControlled",
"--disable-dev-shm-usage",
"--no-sandbox",
],
)
context = browser.new_context(
viewport={"width": 1920, "height": 1080},
locale="en-US",
timezone_id="America/New_York",
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
),
)
# navigator.webdriver is the single loudest automation tell
context.add_init_script(
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
)
page = context.new_page()
page.goto(url, wait_until="domcontentloaded", timeout=60_000)
# Wait out the interstitial rather than a fixed sleep
try:
page.wait_for_selector("#product-title", timeout=25_000)
except Exception:
pass
html = page.content()
browser.close()
return html
What Cloudflare's challenge script actually inspects:
| Probe | Automation tell | Mitigation |
|---|---|---|
navigator.webdriver |
true under automation |
Override in an init script |
| Canvas / WebGL rendering hash | Headless renders differently from GPU-backed Chrome | Use headed mode or a GPU-enabled container |
navigator.plugins, mimeTypes |
Empty arrays in headless | Patch, or use headed Chrome |
| Screen vs. viewport geometry | Impossible combinations | Set a realistic viewport |
| Timezone vs. proxy exit IP geo | Proxy in Frankfurt, Intl says New York |
Match timezone to proxy region |
| Font enumeration | A container has ~8 fonts, a laptop has ~200 | Install a font package in the image |
| Mouse movement, scroll cadence | No events at all before submit | Emit real events |
| Time-to-first-interaction | Sub-100ms on every page | Add human-scaled jitter |
You can patch each one. Teams do — playwright-stealth and friends bundle a few dozen such patches. But note what has happened to your codebase: you now own a browser-fingerprint spoofing layer that breaks every time Chrome or Cloudflare ships an update, and you have added ~1.5 GB of Chromium and 300–800 MB RAM per concurrent worker.
And you still have not solved the hardest layer.
5. Layer 4: IP reputation (the one you cannot code around)
Every AWS, GCP, Azure, DigitalOcean and Hetzner IP range is public, published in machine-readable JSON by the cloud providers themselves. Cloudflare ingests those lists. A request from a datacenter ASN starts with a reputation deficit no amount of fingerprint patching will repay.
This is why the frustrating pattern happens: your scraper works flawlessly on your laptop, then gets a wall of Error 1020 the moment you deploy it.
The fix is residential IPs — real consumer connections from real ISPs — rotated properly:
import random
from curl_cffi import requests as cffi_requests
PROXY_USER = "your-user"
PROXY_PASS = "your-pass"
PROXY_HOST = "residential.example.net:8000"
def session_proxy(session_id: str, country: str = "us") -> dict:
"""Sticky session: same exit IP for the whole logical session."""
user = f"{PROXY_USER}-country-{country}-session-{session_id}"
url = f"http://{user}:{PROXY_PASS}@{PROXY_HOST}"
return {"http": url, "https": url}
def fetch(url: str, country: str = "us") -> str:
sid = f"s{random.randint(10_000, 99_999)}"
resp = cffi_requests.get(
url,
impersonate="chrome126",
proxies=session_proxy(sid, country),
timeout=45,
)
resp.raise_for_status()
return resp.text
Three rules people get wrong:
- Sticky sessions for multi-step flows. If you log in on IP A and load page 2 from IP B in Brazil, you have just handed Cloudflare a perfect anomaly. Keep one exit IP for the duration of a logical session.
- Match geography to the target. Requesting a US-only retailer from a Vietnamese residential IP is legitimate traffic that still looks wrong. Pin the country.
- Rotate on failure, not on every request. Burning a fresh IP per request torches your pool's reputation and multiplies bandwidth cost. Reuse until a request fails, then rotate.
Residential bandwidth is billed per GB, and a single JS-heavy product page with images can pull 2–5 MB. Rendering 100,000 pages a month through a browser is a real bandwidth line item — one that is easy to under-forecast by an order of magnitude.
6. Counting the true cost
Here is the honest accounting after you have built all four layers yourself:
| Layer | Fix | Build time | Ongoing maintenance |
|---|---|---|---|
| HTTP headers | Consistent header set | 1 hour | Low |
| TLS / HTTP2 fingerprint | curl_cffi impersonation |
2–4 hours | Re-pin every Chrome release |
| JS challenge | Playwright + patches | 2–5 days | Breaks on Chrome + Cloudflare updates |
| Browser fingerprint | ~30 individual patches | 1–2 weeks | Continuous |
| IP reputation | Residential proxy + rotation logic | 2–3 days | Pool tuning, per-GB spend |
| Retry / backoff / alerting | Custom orchestration | 3–5 days | Ongoing |
Roughly three to five engineer-weeks to build, then permanent maintenance, and you have produced zero business value — you have produced parity with the starting line. Meanwhile the target site A/B tests its DOM and your selectors break anyway.
Whether that is worth it comes down to one question: is unblocking part of your product, or part of your overhead? If you are building a scraping vendor, own all four layers — it is your moat. For everyone else, the layers are a tax on getting to the data, and the honest move is to rent them.
7. The managed path: one request, all four layers
The four layers do not disappear when you outsource them; they move server-side. MrScraper runs TLS impersonation, browser rendering, fingerprint management, CAPTCHA handling and residential rotation on every request by default — not as add-on products with separate meters, which is the usual place this gets expensive.
What changes for you is the shape of the code. Instead of assembling four layers of evasion, you describe the data you want. Everything from sections 2 through 5 collapses into one call.
There is an official Python SDK, so this stays Python rather than becoming hand-rolled HTTP:
pip install mrscraper-sdk
Token comes from the dashboard (Profile → API Tokens → New Token). The SDK is async throughout:
import asyncio
import os
from mrscraper import MrScraper
client = MrScraper(token=os.getenv("MRSCRAPER_API_TOKEN"))
async def main():
run = await client.create_scraper(
url="https://example-store.com/product/12345",
message=(
"Extract the following in JSON format: product_name, price, "
"currency, rating, number_of_reviews, in_stock, product_link."
),
agent="general", # "general" | "listing" | "map"
proxy_country="US", # geo-match the exit IP — Layer 4, solved
)
print(run["status"], run["runtime"], run["tokenUsage"])
print(run["data"])
print(run["scraperId"]) # keep this — it is how you rerun later
asyncio.run(main())
{
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"scraperId": "6695bf87-aaa6-46b0-b1ee-88586b222b0b",
"type": "AI",
"status": "Finished",
"runtime": "8.678",
"tokenUsage": 3,
"data": {
"product_name": "Wireless Noise-Cancelling Headphones",
"price": 179.99,
"currency": "USD",
"rating": 4.4,
"number_of_reviews": 2317,
"in_stock": true,
"product_link": "https://example-store.com/product/12345"
}
}
}
Map that back against the four layers:
- Layers 1–3 are gone from your code entirely. No header set, no
impersonatetarget to re-pin every Chrome release, nonavigator.webdriverpatch list. proxy_countryhandles Layer 4 — the exit IP is geo-matched without you sizing a pool or writing sticky-session logic.- The selector is gone too. The AI parser reads the rendered page, so the DOM redesign that breaks your parser next month is no longer your problem either — the other half of the maintenance bill this article has been adding up.
- Keep the returned
scraperId. Creating a scraper is a one-time act; every subsequent URL reruns it.
create_scraper() maps to POST /api/v1/scrapers-ai if you would rather call the REST API directly.
Debugging the decoy-page problem
Remember the worst row in the diagnostic table back in section 1 — a 200 with quietly degraded content. The response envelope is built for exactly that:
if run["status"] != "Finished": # Finished | Processing | Failed
raise RuntimeError(run.get("error") or f"status={run['status']}")
print(run["screenshotPath"]) # what the page looked like at fetch time
print(run["htmlPath"]) # raw HTML, re-parseable offline
print(run["recordingPath"]) # session recording, when available
Two habits worth forming. First, assert on status, not on the HTTP code — a Failed run still returns HTTP 200 with a populated envelope, so raise_for_status() alone will hand you an empty payload and no error. Second, when a page comes back suspiciously thin, open screenshotPath before you touch any code: it answers "was I challenged, geo-redirected, or just wrong about the selector" in about three seconds, which is the question that otherwise eats an afternoon.
Reusing the scraper
Once created, run the same extraction against any number of URLs without re-describing the schema:
run = await client.rerun_scraper(
scraper_id="6695bf87-aaa6-46b0-b1ee-88586b222b0b",
url="https://example-store.com/product/67890",
)
print(run["data"])
For more than a handful of URLs, submit them as one asynchronous job rather than looping — you get per-URL status instead of an all-or-nothing script:
job = await client.bulk_rerun_ai_scraper(
scraper_id=scraper_id,
urls=product_urls,
)
print(job["type"]) # Bulk-AI
print(job["data"]["summary"]["estimatedFinishAt"])
# poll until it settles
while job["status"] not in ("Finished", "Failed"):
await asyncio.sleep(15)
job = await client.get_result_by_id(job["id"])
Then read data.summary for counts and data.urlDetails for per-URL outcomes. That last field is the one that matters on protected targets: when three URLs out of 500 fail, you want those three isolated and retried, not a batch you rerun from zero. data.mergedData holds the extracted rows, already flattened.
Endpoint reference: single rerun · bulk rerun · response schema
Verified against the sites that actually block
In an internal benchmark across 20 heavily-protected live URLs — Amazon, eBay, Walmart, SHEIN, Tokopedia, Lazada, Booking, Agoda, Airbnb, Zillow, Redfin, Tripadvisor — MrScraper returned usable data on 20 of 20, averaging 32.76 seconds per page with no run exceeding 73 seconds. Two widely-used competitors in the same run came back blocked on 3 and 4 URLs respectively.
Scaling to a listing
The bypass problem multiplies when you need a whole category, not one page. Create the scraper with agent: "listing" — it handles discovery, pagination and per-item extraction rather than parsing a single page:
listing_run = await client.create_scraper(
url="https://example-store.com/category/headphones",
message=(
"Extract every product in the listing: product_name, price, "
"currency, rating, number_of_reviews, product_link."
),
agent="listing",
proxy_country="US",
)
listing_scraper_id = listing_run["scraperId"]
Then rerun that scraper against other categories, bounding the crawl so it does not wander:
run = await client.rerun_scraper(
scraper_id=listing_scraper_id,
url="https://example-store.com/category/earbuds",
max_depth=2, # listing -> product page
max_pages=200, # default 50
include_patterns=r"/product/\d+",
exclude_patterns=r"/(reviews|questions)/",
limit=1000,
)
print(run["status"]) # Finished
include_patterns and exclude_patterns are regexes applied to discovered URLs — the single most effective way to stop a crawl from wandering into review pages and burning your budget. Depth defaults to 2 and pages to 50, so raise them deliberately rather than discovering the ceiling mid-run.
A listing run returns one object per page, not a flat list:
for page in run["data"]["response"]:
print(f"page {page['page_num']}: {page['total_items']} items, "
f"next: {page['next_found']}")
next_found is your pagination canary. A protected site that decides you are a bot will often serve page 1 and then quietly stop offering a "next" link rather than issuing a visible block — so a run reporting Finished with next_found: False on page 0 is a soft block, not an empty category. Alert on it.
Attach a Scheduler to that scraper and it reruns on a cron, pushing results to your SQL database, S3 bucket or webhook without an orchestration layer of your own. For multi-step targets — log in, set a filter, dismiss a modal, paginate — the Workflow builder chains those steps visually, and each step's output feeds the next.
8. Troubleshooting reference
| Symptom | Most likely cause | Fix |
|---|---|---|
| 403 on the very first request, from any IP | TLS/JA4 fingerprint | Switch to curl_cffi with impersonate |
| Works locally, 1020 in production | Datacenter ASN reputation | Route through residential proxies |
| Passes 50 requests then blocks | Per-IP rate limiting (1015) | Add backoff + widen the IP pool |
| "Just a moment..." never resolves | JS challenge needs real execution | Use a real browser or a managed API |
| 200 OK but fields are empty/stale | Silently served decoy page | Assert on data, not status; escalate the path |
| Turnstile widget appears | Interactive challenge triggered | Managed solving; do not roll your own |
| Random failures after a Chrome release | Impersonation target is stale | Re-pin the impersonate version |
Selectors return None after a redesign |
DOM changed, not a block | Move to prompt-based AI extraction |
Frequently Asked Questions
Is bypassing Cloudflare legal?
Cloudflare is infrastructure, not a rights holder. What matters is the target site's terms of service, whether the data is public or behind a login, whether it contains personal data (GDPR/CCPA), and how hard you hit the server. Scraping publicly available product data at a respectful rate is broadly established practice; scraping personal data or circumventing authentication is not. Consult counsel for your specific case.
Why does my scraper work locally but fail on the server?
Almost always IP reputation. Your home connection is a residential ISP IP; your server sits in a published datacenter range that Cloudflare scores as high-risk before it inspects anything else. Route production traffic through residential proxies.
Do I need a headless browser for every Cloudflare site?
No — and defaulting to one is expensive. Many sites only enforce TLS fingerprinting and headers, which curl_cffi handles at a fraction of the CPU and bandwidth. Escalate to a browser only when you actually see a JS challenge.
What is the difference between JA3 and JA4?
Both hash the TLS ClientHello. JA4 is the newer scheme, more resistant to trivial evasion and covering more of the handshake. Practically: modern anti-bot fingerprints on JA4, so an impersonation library that only matches JA3 is no longer sufficient.
How do I handle Cloudflare Turnstile?
Turnstile is an interactive challenge tied to a fingerprint and a token exchange. Solving it reliably at scale means a real browser plus a solving service plus token lifecycle management. This is the point where in-house bypass stops being economical for most teams — a managed API that includes CAPTCHA handling is usually the cheaper answer.
Will these techniques still work in a year?
The specific techniques, partially. The layered model — headers, TLS, browser, IP — is stable and worth internalizing. The individual patches expire on Cloudflare's release schedule, not yours, which is the strongest argument for putting this layer behind someone whose full-time job is maintaining it.
Wrapping up
Cloudflare is not one wall. It is four, and you have to pass all of them:
- Headers — consistent, complete, browser-accurate
- TLS/HTTP2 fingerprint —
curl_cffiwith a matchedimpersonatetarget - Browser fingerprint — a real browser with the automation tells patched out
- IP reputation — residential IPs, sticky sessions, geo-matched
Building all four is a few engineer-weeks and a permanent maintenance stream. Renting them is one API call — and it frees you to work on the part that actually differentiates your product: what you do with the data.
Ready to skip the bypass engineering? Try MrScraper free — all four layers handled, no credit card required.
Related reading
- Amazon Scraper: How to Scrape Amazon Product Data With Python — Put these bypass techniques into practice on one of the most heavily protected e-commerce targets.
- Residential Proxy Rotation for Scraping — Deep dive into IP rotation strategies that keep Layer 4 from blocking your scraper.
- How to Avoid Triggering CAPTCHA Challenges — CAPTCHAs are the endpoint of Cloudflare's escalation chain. Learn how to stay below the threshold.
- How to Scrape Websites Without Getting Blocked — General anti-detection strategies that complement the Cloudflare-specific techniques in this guide.
- Best Residential Proxies for Web Scraping — Comparison of residential proxy providers, including MrScraper's built-in pool.
- Sticky vs. Rotating Residential Proxies — When to hold an IP and when to rotate — critical for Cloudflare's session-based detection.
- Bypassing Anti-Bot Protections with JavaScript — Browser-level evasion techniques using JavaScript injection and fingerprint patching.
- MrScraper Documentation — Full API reference for the AI scraper, Python SDK, proxy endpoints and integrations.
Summarize this post
Open it in your assistant of choice with the prompt ready to send.
Take a Taste of Easy Scraping!
Find more insights here

Amazon Scraper: How to Scrape Amazon Product Data With Python
Build an Amazon scraper in Python using the MrScraper Python SDK.

What Is a Proxy Server? How It Works and When to Use One
A proxy server is an intermediary that routes your internet requests through a different IP address.…

Data Extraction Guide: Tools, Methods, and Best Practices
A complete guide to data extraction — methods (web scraping, APIs, ETL, OCR), tools for every use ca…