Technical

How to Bypass Cloudflare Without a Browser Using JA4 TLS Fingerprinting (2026 Guide)

Most scraping tools spin up headless Chrome to get past Cloudflare. For roughly 30-40% of protected sites, you don't need to. Here's how JA4 TLS fingerprinting works, how to use curl_cffi to mimic a real browser at the protocol level, and where this approach stops working.

Curtis Vaughan13 min read

If you've tried scraping a Cloudflare-protected site with requests, axios, or native fetch, you know the experience. 403 Forbidden, every time, no matter what headers you set. The standard advice is to spin up headless Chrome via Playwright or Puppeteer. That works, but a browser session costs 10-50x more in compute and 15-100x more in latency than an HTTP request.

Here's what nobody tells beginners: for a meaningful share of "protected" sites, you don't need a browser. You need to fix your TLS fingerprint.

This post covers how Cloudflare's TLS detection actually works, why your HTTP client gets fingerprinted before you ever send a single header, and how to use curl_cffi to make HTTP requests that look identical to real Chrome at the protocol level. By the end you'll know when JA4 is sufficient, when it isn't, and how to verify your setup.

What Cloudflare Sees Before Your First Header

When your HTTP client connects to a Cloudflare-protected site, here's what happens in order:

  1. TCP handshake (the boring part)
  2. TLS handshake (where you get fingerprinted)
  3. HTTP/2 connection setup (where you might get fingerprinted again)
  4. Your actual HTTP request (where you finally get to send headers)

Most developers think about step 4. They set a realistic User-Agent, add Accept-Language, rotate proxies. They get blocked anyway and conclude that Cloudflare is magic.

The actual blocking happens in step 2, before your request leaves the wire.

The TLS ClientHello Message Explained

When two parties negotiate a TLS connection, the client sends a message called ClientHello. This message announces what the client supports: TLS versions, cipher suites, extensions, elliptic curves, signature algorithms, and more. Every TLS implementation produces a slightly different ClientHello based on which features it supports and the order in which it lists them.

This is true for every HTTP client you've ever used. Chrome's ClientHello looks one way. Firefox's looks different. Python requests (which uses urllib3 which uses ssl from the standard library) produces a third pattern. Go's net/http produces a fourth. Node.js fetch produces a fifth.

These differences are mostly invisible to you because they don't affect functionality. But they're cryptographically distinct, which means they're hashable. Which means they're fingerprintable.

What is JA3 and Why Did It Stop Working?

In 2017, Salesforce engineers John Althouse, Jeff Atkinson, and Josh Atkins published JA3: a method to fingerprint TLS clients by hashing specific fields from the ClientHello message — TLS version, accepted cipher suites in order, extensions in order, elliptic curves, and elliptic curve point formats.

The result is a 32-character MD5 hash like e7d705a3286e19ea42f587b344ee6865. Every Python requests install on the planet produces the same JA3 hash. Same for curl. Same for Go's standard HTTP client. Different from any version of Chrome, Firefox, or Safari.

For about five years, JA3 was the workhorse of TLS fingerprint-based bot detection. Cloudflare, DataDome, PerimeterX, Akamai — they all built on JA3 or close variants.

Then Chrome 110 shipped in early 2023 and broke it. Chrome started randomizing the order of TLS extensions in the ClientHello, which meant the JA3 hash for Chrome was no longer stable. Different Chrome instances would produce different JA3 hashes. JA3 stopped being a reliable identifier.

Cloudflare needed something new. They got JA4.

What is JA4 and How Does It Work?

In late 2023, FoxIO published JA4, along with related fingerprints called JA4+, JA4H, JA4S, and JA4X that together fingerprint different parts of the TLS and HTTP/2 handshake. JA4 was specifically designed to survive Chrome's extension randomization.

The key changes:

  • Extensions are sorted before hashing, so randomization doesn't break the hash
  • The fingerprint includes more dimensions (HTTP version, ALPN, signature algorithms)
  • The output is human-readable, not an opaque hash

A JA4 fingerprint looks like this:

code
t13d1516h2_8daaf6152771_02713d6af862

Three parts separated by underscores:

  • t13d1516h2 — TLS 1.3, destination, 15 ciphers, 16 extensions, ALPN h2 (HTTP/2)
  • 8daaf6152771 — hash of cipher suites, sorted
  • 02713d6af862 — hash of extensions and signature algorithms, sorted

That specific JA4 above is Chrome 131 on macOS, by the way. We'll come back to it.

JA4H is a related fingerprint that hashes HTTP request properties: header order, header casing, cookie order. Real browsers send headers in specific orders that most HTTP libraries don't match. JA4H catches the mismatch.

In 2026, Cloudflare uses JA4 and JA4H (among other signals) as part of its bot detection stack. If your TLS handshake doesn't match a real browser's JA4, you're flagged before your request reaches the application layer.

Why Spoofing Headers Doesn't Bypass Cloudflare

Here's the trap a lot of scrapers fall into. They set:

code
headers = {
    "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
    "Accept-Language": "en-US,en;q=0.9",
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"
}
response = requests.get("https://example-protected-site.com", headers=headers)

This produces a request that says "I am Chrome 131" in the headers, but Cloudflare already knows you're not Chrome because your TLS fingerprint is python-requests/urllib3. The mismatch between your TLS fingerprint and your User-Agent is itself a strong bot signal — Cloudflare doesn't even need to know what you are. It just needs to know you're lying about it.

This is why "I rotated proxies and it didn't help" and "I tried different User-Agents" are common laments in scraping forums. The fingerprint mismatch is the block signal. Headers don't fix it.

How to Use curl_cffi to Bypass Cloudflare TLS Detection

curl_cffi is a Python library by yifeikong that wraps libcurl with the ability to impersonate specific browsers' TLS and HTTP/2 fingerprints. Under the hood, it uses a patched libcurl (curl-impersonate) that produces a ClientHello message identical to a specific browser version.

Installation:

code
pip install curl_cffi

Usage looks almost identical to requests:

code
from curl_cffi import requests
 
response = requests.get(
    "https://example-protected-site.com",
    impersonate="chrome131"
)
print(response.status_code)
print(response.text[:500])

The impersonate="chrome131" parameter tells curl_cffi to mimic Chrome 131's exact TLS fingerprint, HTTP/2 settings, and header ordering. The site sees a JA4 of t13d1516h2_8daaf6152771_02713d6af862 — indistinguishable from real Chrome.

Available impersonation profiles include chrome131, chrome124, chrome120, firefox133, safari17_2, and several others. New ones are added as browsers ship new versions.

The User-Agent Mismatch Gotcha That Breaks Most curl_cffi Setups

The most frequent mistake when starting with curl_cffi is overriding headers in a way that re-introduces the fingerprint mismatch you were trying to fix. Watch this:

code
# WRONG — re-introduces the mismatch
response = requests.get(
    "https://example-protected-site.com",
    impersonate="chrome131",
    headers={
        "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) Chrome/120.0.0.0",
        "Accept": "*/*",
    }
)

You're now sending Chrome 131's TLS fingerprint with Chrome 120's User-Agent. Cloudflare's detection notices that the TLS handshake claims Chrome 131 but the User-Agent claims Chrome 120. That's a stronger bot signal than either spoof attempt would be alone.

The right pattern: when you impersonate a browser, let curl_cffi set the headers it would naturally send. Add custom headers only when they make sense (auth tokens, content type for POSTs, etc.), and don't override User-Agent unless you have a specific reason and you've confirmed the version matches the impersonation profile.

code
# RIGHT — let curl_cffi handle browser-aligned headers
response = requests.get(
    "https://example-protected-site.com",
    impersonate="chrome131",
    headers={
        "Authorization": "Bearer your_token",  # custom header, fine
    }
)

Which Sites Does JA4 Bypass Work On?

JA4 fingerprinting solves one specific layer of bot detection: TLS-level fingerprinting. It works for sites that primarily check TLS, which is a meaningful subset of Cloudflare-protected sites.

In our testing across the DreamScrape scorecard, switching from native Node fetch to curl_cffi with chrome131 turns 403s into 200s on roughly 30-40% of sites that block plain HTTP. Specific sites where JA4 alone works:

  • Basketball Reference
  • HLTV
  • pump.fun
  • Most static news sites behind Cloudflare's basic protection
  • Sports stats and reference databases
  • Many e-commerce category pages

You can verify which sites work with JA4 alone via the DreamScrape Intel Database — it lists 60+ domains with their actual routing tier and success rates from real traffic.

Where JA4 Stops Working

JA4 is not magic. The categories of sites where TLS fingerprinting alone won't get you in:

Sites that require JavaScript execution. If the page is a React SPA that loads data via XHR after the initial HTML loads, no amount of TLS spoofing will populate the DOM. You need a browser engine like Playwright or Camoufox.

Sites with browser fingerprinting via JavaScript. Cloudflare's harder challenge pages run JavaScript that probes canvas rendering, WebGL renderer strings, audio context, font enumeration, and dozens of other browser-specific signals. JA4 doesn't help here. You need a real browser, ideally one with stealth patches like Camoufox.

Sites with behavioral detection. Some Cloudflare configurations score the entire session — request timing, mouse movement patterns, scroll behavior, time-on-page. A pure HTTP client has no behavior to score, which is itself the signal. Bypassing this layer requires running a real browser and simulating realistic interaction.

Cloudflare Turnstile and managed challenges. These are the interstitial pages with "Verify you are human" widgets. They run client-side JavaScript that produces a signed cookie, and they specifically expect a real JS engine running in a real browser environment. Headless approaches struggle. Pure HTTP approaches are not viable.

The honest framing: JA4 is a cheap, fast bypass for one layer of the stack. It's not magic. The sites where JA4 alone is sufficient tend to be ones where Cloudflare has been deployed at low security settings — either because the operator just turned it on without tuning, or because the site doesn't care about scraping enough to escalate.

How to Pick the Right Engine for Each Site

The pragmatic workflow when you encounter a new site to scrape:

1. Try plain HTTP. If you get a 200 with real content, you're done. Cost: nothing.

2. If you get a 403, try curl_cffi with impersonate="chrome131". If you get a 200 with real content, you're done. Cost: marginal.

3. If you still get a 403 or you get a 200 with a JavaScript shell page (no real data in the HTML), you need a browser. Use Playwright with stealth plugins like playwright-extra and puppeteer-extra-plugin-stealth.

4. If Playwright stealth gets detected, you need a more aggressive anti-detect tool. Camoufox is a patched Firefox that handles fingerprinting at the C++ level instead of via JS injection. Harder to detect than JS-patched stealth.

5. If even Camoufox gets detected, you need residential proxies on top of it, and possibly session warming — visiting the site organically for a few clicks before requesting your target URL.

Each escalation step costs more in time and infrastructure. The win isn't skipping straight to step 5 for every site. The win is starting at step 1 and escalating only as needed, then remembering for next time which step worked so you don't re-test from scratch on every request.

This is the core problem we're solving with DreamScrape's tiered engine router: given a URL, automatically pick the cheapest engine that works for that domain, and remember the answer so subsequent requests skip the discovery phase. JA4 sits at tier 0 in the router — fast, cheap, and surprisingly effective for the share of sites where it's enough. You can see the routing decisions in real time on the playground.

How to Verify Your JA4 Fingerprint

A useful trick when debugging: there are public TLS echo services that show you exactly what JA4 your client is sending. The one we use for verification is tls.peet.ws/api/all. Hit it with any HTTP client and you'll get back a JSON response with your TLS fingerprint, JA4 hash, JA4H hash, and a bunch of other detection-relevant data.

code
from curl_cffi import requests
import json
 
response = requests.get(
    "https://tls.peet.ws/api/all",
    impersonate="chrome131"
)
data = response.json()
print(f"JA4: {data['tls']['ja4']}")
print(f"JA4H: {data['http2']['akamai_fingerprint_hash']}")

If you're impersonating Chrome 131 correctly, you should see t13d1516h2_8daaf6152771_02713d6af862 for the JA4. If you see something different, your impersonation profile may be out of date — check that you have the latest curl_cffi and that the profile name matches a current version.

For production scraping: pin to a specific curl_cffi version and a specific browser profile, then run a weekly canary that checks the JA4 against the expected value. If the hash drifts, you'll know before your block rate spikes. DreamScrape runs this canary automatically — you can see the verified profiles on the intel database.

Frequently Asked Questions

Does curl_cffi work with proxies?

Yes. curl_cffi accepts a proxies parameter the same way requests does:

code
response = requests.get(
    "https://example.com",
    impersonate="chrome131",
    proxies={"http": "http://user:pass@proxy:port", "https": "http://user:pass@proxy:port"}
)

Combining JA4 fingerprinting with residential proxies covers more sites than either alone.

Can curl_cffi handle JavaScript-heavy sites?

No. curl_cffi is an HTTP client, not a browser. It doesn't execute JavaScript. For JS-heavy sites you need Playwright, Puppeteer, or a similar browser automation tool.

What's the difference between JA3 and JA4?

JA3 (2017) hashes the TLS ClientHello including cipher suites and extensions in their original order. Chrome 110+ randomized extension order, breaking JA3's reliability for fingerprinting modern Chrome.

JA4 (2023) sorts extensions before hashing, surviving randomization. It also includes more dimensions like HTTP version and ALPN. JA4 is the current standard for TLS-based bot detection in 2026.

How often do I need to update curl_cffi?

Pin to a specific version in production, then update when Chrome ships a major new version (typically every 4-6 weeks). The curl_cffi maintainers usually publish updated impersonation profiles within 1-2 weeks of new Chrome releases. If you're not pinning, you risk a curl_cffi upgrade silently changing your fingerprint and breaking things.

Will Cloudflare eventually block curl_cffi specifically?

Possibly. Any specific impersonation profile, once popular enough to attract attention, becomes a fingerprint Cloudflare can flag. The mitigation is using rotation across multiple profiles (chrome131, firefox133, etc.) and treating JA4 as one layer in a stack rather than a permanent solution.

Cloudflare's terms of service for sites using their service apply to those sites' relationships with Cloudflare, not to third parties accessing those sites. The legality of scraping a site behind Cloudflare depends on the site's own terms, the data being scraped, and your jurisdiction. Public data, no authentication bypass, polite request rates, no copyrighted content reuse — these are the factors that determine legal exposure, not the bypass technique. (I'm not a lawyer; talk to one if you're operating at scale.)

Does DreamScrape use curl_cffi?

Yes. DreamScrape's tier 0 router uses curl_cffi with pinned chrome131 and firefox133 profiles for the JA4 layer, plus weekly canary verification against tls.peet.ws. You can see which engine a given URL routes to in the playground or check the scorecard for current bypass rates across our test suite.

The Bottom Line

JA4 TLS fingerprinting is one of those topics where understanding the why makes the how trivial. Cloudflare and similar services check your TLS fingerprint before your request reaches their application layer. Standard HTTP libraries produce fingerprints that don't match any real browser, so even perfectly-spoofed headers can't save you. curl_cffi lets you produce a TLS handshake that's byte-identical to real Chrome, which gets you past one specific layer of detection at HTTP-level cost rather than browser-level cost.

It's not a universal solution. It's one tool in a stack, and the right tool for a meaningful share of sites — perhaps 30-40% of "protected" sites in our testing fall into the category where JA4 alone is enough.

If you're building scraping infrastructure, the practical takeaway is: try cheap before you try expensive. Plain HTTP is free, JA4 is nearly free, and the savings compound when you're scraping at any volume. The browser tier should be a fallback, not a default.

If you'd rather not maintain this yourself, DreamScrape handles the routing, JA4 verification, and engine escalation for you. Free tier is 2,000 scrapes per month — try the playground without signing up.