Skip to content
How to Bypass Cloudflare When Web Scraping
Article

How to Bypass Cloudflare When Web Scraping

Guide

Cloudflare 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.

By MrScraper Team 18 min read

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-Agent string 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:

  1. 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.
  2. 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.
  3. 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 TokensNew 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 impersonate target to re-pin every Chrome release, no navigator.webdriver patch list.
  • proxy_country handles 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

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:

  1. Headers — consistent, complete, browser-accurate
  2. TLS/HTTP2 fingerprintcurl_cffi with a matched impersonate target
  3. Browser fingerprint — a real browser with the automation tells patched out
  4. 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.

Summarize this post

Open it in your assistant of choice with the prompt ready to send.

Take a Taste of Easy Scraping!