DEV Community

KazKN
KazKN

Posted on • Edited on

How to Bypass Cloudflare with TLS Fingerprinting in Node.js

How to Bypass Cloudflare with TLS Fingerprinting in Node.js

Last updated: February 15, 2026 | Reading time: 14 min

Your Node.js scraper returns a 403. Or worse — a Cloudflare challenge page. You've tried rotating user agents, adding headers, even using headless browsers. Nothing works consistently.

The problem isn't your headers or your IP. It's your TLS fingerprint. Cloudflare identifies your scraper at the TLS handshake level — before a single HTTP header is sent. Your Node.js https module has a unique TLS fingerprint that screams "I'm not a real browser."

Bypassing Cloudflare with TLS fingerprinting requires spoofing the JA3/JA4 fingerprint that your HTTP client presents during the TLS handshake. This guide shows you exactly how, with working code.

What you'll learn:

  • What TLS fingerprinting is and why Cloudflare uses it
  • JA3 and JA4 fingerprint formats explained
  • How to spoof browser TLS fingerprints in Node.js using got-scraping
  • Session rotation strategies for sustained scraping
  • Real-world example: scraping Cloudflare-protected Vinted

Table of Contents


What Is TLS Fingerprinting?

TLS fingerprinting is a technique that identifies HTTP clients based on the specific parameters they send during the TLS (Transport Layer Security) handshake. When your client connects to a server over HTTPS, it sends a ClientHello message containing:

  • Supported cipher suites — the encryption algorithms your client supports
  • TLS extensions — additional capabilities (SNI, ALPN, supported groups, etc.)
  • Extension order — the sequence in which extensions appear
  • Supported TLS versions — TLS 1.2, 1.3, etc.
  • Elliptic curve groups — which curves your client prefers
  • Signature algorithms — how your client verifies certificates

Every HTTP client — Chrome, Firefox, Safari, Node.js https, Python requests, Go net/http — sends a unique combination of these parameters. This combination is the TLS fingerprint.

The crucial insight: you can't fake a TLS fingerprint by changing HTTP headers. The fingerprint is established at the TLS layer, below HTTP. By the time your headers reach the server, Cloudflare has already identified your client type from the TLS handshake.

According to research by Salesforce (who created the JA3 method), TLS fingerprinting can identify client applications with 99.4% accuracy across billions of connections.

graph TD
    A[Client connects to server] --> B[TLS ClientHello]
    B --> C{Server checks<br>TLS fingerprint}
    C -->|"Matches Chrome/Firefox"| D[Allow connection]
    C -->|"Matches Node.js/Python"| E[Block / Challenge]
    D --> F[HTTP Request proceeds]
    E --> G[403 or Cloudflare page]

    style E fill:#ff6b6b
    style D fill:#51cf66
Enter fullscreen mode Exit fullscreen mode

How Cloudflare Uses TLS Fingerprints

Cloudflare's bot detection stack operates on multiple layers. TLS fingerprinting is the first and hardest to bypass:

Layer 1: TLS Fingerprint (Pre-HTTP)

  • Captures JA3/JA4 hash of the ClientHello
  • Compares against known browser fingerprints
  • Node.js default: immediate flag as suspicious

Layer 2: HTTP/2 Fingerprint

  • Frame ordering, header compression settings, window sizes
  • Different between browsers and HTTP libraries

Layer 3: HTTP Headers

  • User-Agent, Accept, Accept-Language order and values
  • Header order matters — browsers have consistent ordering

Layer 4: JavaScript Challenge

  • Tests JavaScript execution capability
  • Validates browser APIs and timing
  • Only reached if Layers 1-3 pass

Most scrapers focus on Layers 3 and 4, ignoring the fact that Cloudflare has already flagged them at Layer 1. This is why rotating user agents and adding realistic headers doesn't work — your TLS fingerprint already betrayed you.

JA3 and JA4: The Fingerprint Formats

JA3 (2017)

JA3 is a method for creating a fingerprint from the TLS ClientHello. It concatenates five fields from the ClientHello, separated by commas:

JA3 = MD5(TLSVersion,Ciphers,Extensions,EllipticCurves,EllipticCurvePointFormats)
Enter fullscreen mode Exit fullscreen mode

Example JA3 for Chrome 120:

SSLVersion: 771
Ciphers: 4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53
Extensions: 0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513-21
EllipticCurves: 29-23-24
EllipticCurvePointFormats: 0

JA3 Hash: cd08e31494f9531f560d64c695473da9
Enter fullscreen mode Exit fullscreen mode

Example JA3 for Node.js 20 https:

JA3 Hash: eb67d79ef3a33513cce1b9a1b1084995
Enter fullscreen mode Exit fullscreen mode

Cloudflare maintains a database of known JA3 hashes. cd08e31494f9531f560d64c695473da9 → Chrome → allow. eb67d79ef3a33513cce1b9a1b1084995 → Node.js → challenge or block.

JA4 (2023)

JA4 is the successor to JA3, created by FoxIO. It improves on JA3 by:

  • Separating fingerprint into components (not just one hash)
  • Being more resilient to minor TLS stack changes
  • Supporting TLS 1.3 better

JA4 format:

JA4 = t{TLS version}d{SNI}{# of ciphers}{# of extensions}_{cipher hash}_{extension hash}
Enter fullscreen mode Exit fullscreen mode

Example:

JA4: t13d1517h2_8daaf6152771_b0da82dd1658  (Chrome)
JA4: t13d1512h2_5b57614c22b0_7dc74d184f4c  (Node.js)
Enter fullscreen mode Exit fullscreen mode

Modern Cloudflare deployments check both JA3 and JA4.

Why Standard Node.js HTTP Fails

Node.js's built-in https module and popular libraries like axios, node-fetch, and undici all use Node.js's default TLS implementation. This creates a distinctive TLS fingerprint that doesn't match any real browser.

// This will get blocked by Cloudflare
import https from 'node:https';

const req = https.get('https://cloudflare-protected-site.com', (res) => {
  // res.statusCode === 403 🚫
});
Enter fullscreen mode Exit fullscreen mode

The problem is fundamental: you can't change the cipher suite order, extension list, or TLS parameters using Node.js's standard API. The tls module exposes limited configuration — nowhere near enough to replicate Chrome's ClientHello.

Even Puppeteer and Playwright have this issue in headless mode. While they run real Chromium, headless browsers have subtle TLS differences that Cloudflare can detect.

What Doesn't Work

Approach Why It Fails
Rotating User-Agent Cloudflare checks TLS before reading headers
Adding realistic headers Same — TLS fingerprint is checked first
Axios/node-fetch Same Node.js TLS stack = same fingerprint
Headless Chrome Headless-specific TLS differences detected
Puppeteer stealth plugin Helps with JS challenges but not TLS layer
cURL from Node.js Works but spawning processes is slow and resource-heavy

got-scraping: The Solution

got-scraping is an HTTP client built by Apify specifically to solve the TLS fingerprinting problem. It's based on got but adds:

  1. Browser-like TLS fingerprints — mimics Chrome, Firefox, or Safari at the TLS layer
  2. Automatic header generation — realistic browser headers in correct order
  3. HTTP/2 fingerprint spoofing — matches browser HTTP/2 behavior
  4. Session management — maintains cookies and state across requests

Installation

npm install got-scraping
Enter fullscreen mode Exit fullscreen mode

Basic Usage

import { gotScraping } from 'got-scraping';

const response = await gotScraping({
  url: 'https://cloudflare-protected-site.com',
  // Automatically uses a browser-like TLS fingerprint
});

console.log(response.statusCode); // 200 ✅
console.log(response.body); // Actual page content
Enter fullscreen mode Exit fullscreen mode

That's it. got-scraping handles TLS fingerprint spoofing, header ordering, and HTTP/2 settings automatically. No configuration needed for basic usage.

How It Works Under the Hood

got-scraping uses a custom TLS layer that:

  1. Patches the cipher suite list to match Chrome's order
  2. Reorders TLS extensions to match browser patterns
  3. Sets ALPN protocols correctly (h2, http/1.1)
  4. Configures elliptic curves in browser order
  5. Handles GREASE (Generate Random Extensions And Sustain Extensibility) — random values Chrome adds that many scrapers miss

The result: when Cloudflare computes the JA3/JA4 hash of your connection, it matches a real Chrome browser.

graph LR
    subgraph "Standard Node.js"
    A1[Your Code] --> B1[node:https]
    B1 --> C1[Node TLS Stack]
    C1 -->|"JA3: Node.js ❌"| D1[Cloudflare]
    D1 --> E1[403 Blocked]
    end

    subgraph "got-scraping"
    A2[Your Code] --> B2[got-scraping]
    B2 --> C2[Patched TLS Stack]
    C2 -->|"JA3: Chrome ✅"| D2[Cloudflare]
    D2 --> E2[200 OK]
    end
Enter fullscreen mode Exit fullscreen mode

Building a Cloudflare-Resistant Scraper

Here's a complete scraper that handles Cloudflare-protected sites with session management, retry logic, and proxy rotation:

import { gotScraping } from 'got-scraping';

class CloudflareResistantScraper {
  constructor(options = {}) {
    this.proxyUrl = options.proxyUrl;
    this.maxRetries = options.maxRetries || 3;
    this.sessionCount = 0;
    this.maxRequestsPerSession = options.maxRequestsPerSession || 75;
    this.requestCount = 0;
  }

  async request(url, options = {}) {
    if (this.requestCount >= this.maxRequestsPerSession) {
      this.rotateSession();
    }

    for (let attempt = 0; attempt < this.maxRetries; attempt++) {
      try {
        const response = await gotScraping({
          url,
          proxyUrl: this.proxyUrl,
          useHeaderGenerator: true,
          headerGeneratorOptions: {
            browsers: ['chrome'],
            operatingSystems: ['macos', 'windows'],
            locales: ['en-US', 'en-GB', 'fr-FR', 'de-DE'],
          },
          timeout: { request: 30000 },
          retry: { limit: 0 }, // We handle retries ourselves
          ...options,
        });

        this.requestCount++;

        if (response.statusCode === 403 || response.statusCode === 503) {
          console.log(`Cloudflare challenge on attempt ${attempt + 1}`);
          this.rotateSession();
          continue;
        }

        return response;
      } catch (error) {
        console.error(`Request failed (attempt ${attempt + 1}):`, error.message);
        if (attempt === this.maxRetries - 1) throw error;
        await this.delay(2000 * (attempt + 1)); // Exponential backoff
      }
    }
  }

  rotateSession() {
    this.sessionCount++;
    this.requestCount = 0;
    console.log(`Rotated to session #${this.sessionCount}`);
    // If using proxies, rotate the proxy here
  }

  delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

// Usage
const scraper = new CloudflareResistantScraper({
  proxyUrl: 'http://user:pass@proxy.example.com:8080',
  maxRequestsPerSession: 50,
});

const response = await scraper.request('https://www.vinted.fr/catalog?search_text=nike');
console.log(`Status: ${response.statusCode}`);
Enter fullscreen mode Exit fullscreen mode

Key Design Decisions

  1. Session rotation every 50-100 requests — Cloudflare tracks behavior per session. Too many requests from one "browser session" triggers detection.

  2. Residential proxies — Datacenter IPs are largely blocked. Residential proxies (from real ISPs) have much higher success rates with Cloudflare.

  3. Randomized browser profilesheaderGeneratorOptions rotates between different Chrome versions, operating systems, and locales to avoid fingerprint consistency.

  4. Exponential backoff — If Cloudflare challenges you, wait longer before retrying. Aggressive retries increase detection risk.

Session Rotation Strategies

Session rotation is critical for sustained Cloudflare scraping. Here are three strategies ranked by effectiveness:

Strategy 1: Request Count-Based (Simple)

Rotate after a fixed number of requests:

const MAX_REQUESTS = 75;
let requestCount = 0;

async function makeRequest(url) {
  if (requestCount >= MAX_REQUESTS) {
    // Reset cookies, rotate proxy, new browser fingerprint
    requestCount = 0;
  }
  requestCount++;
  return gotScraping({ url });
}
Enter fullscreen mode Exit fullscreen mode

Pros: Simple, predictable. Cons: Cloudflare can detect regular patterns.

Strategy 2: Time-Based with Jitter (Better)

Rotate at random intervals:

function getNextRotation() {
  // Rotate every 3-8 minutes with random jitter
  return (3 + Math.random() * 5) * 60 * 1000;
}
Enter fullscreen mode Exit fullscreen mode

Pros: Less predictable. Cons: May waste sessions on quiet periods.

Strategy 3: Adaptive (Best)

Monitor response codes and rotate on signs of detection:

async function adaptiveRequest(url) {
  const response = await gotScraping({ url });

  if (response.statusCode === 429 || response.statusCode === 503) {
    // Detected — rotate immediately
    rotateSession();
    await delay(5000 + Math.random() * 5000);
    return adaptiveRequest(url); // Retry with new session
  }

  if (response.body.includes('challenge-platform')) {
    // Cloudflare JS challenge — rotate and wait
    rotateSession();
    await delay(10000);
    return adaptiveRequest(url);
  }

  return response;
}
Enter fullscreen mode Exit fullscreen mode

Recommended: Use Strategy 3 (adaptive) with Strategy 1 (count-based) as a fallback. Rotate after 50-100 requests OR whenever you detect a Cloudflare challenge, whichever comes first.

Real-World Example: Scraping Vinted

Vinted uses Cloudflare's full protection stack — TLS fingerprinting, JavaScript challenges, and behavioral analysis. It's one of the harder sites to scrape, which makes it a perfect test case.

The Vinted Smart Scraper on Apify implements all the techniques described in this guide:

  1. got-scraping for TLS fingerprint spoofing
  2. Residential proxy rotation with session management (50-100 requests per session)
  3. Cookie persistence within sessions for state management
  4. Adaptive rate limiting — backs off when Cloudflare responds with challenges

The scraper handles all of this internally — you provide a search query and get structured JSON results in 12 seconds. No proxy configuration, no fingerprint management, no session rotation logic needed on your end.

// Using Vinted Smart Scraper via Apify API
import { ApifyClient } from 'apify-client';

const client = new ApifyClient({ token: 'YOUR_APIFY_TOKEN' });

const run = await client.actor('kazkn/vinted-smart-scraper').call({
  search: 'Nike Air Force 1',
  country: 'fr',
  sort: 'price_low_to_high',
  limit: 50,
});

const { items } = await client.dataset(run.defaultDatasetId).listItems();
console.log(`Found ${items.length} listings`);
items.forEach(item => {
  console.log(`${item.title} — €${item.price} (${item.condition})`);
});
Enter fullscreen mode Exit fullscreen mode

If you want the raw scraping experience, read our Vinted scraper tutorial. If you want AI-powered queries, check the Vinted MCP Server.


🎯 Skip the Cloudflare headaches
Vinted Smart Scraper handles TLS fingerprinting, proxies, and session rotation for you. $5 free monthly credits.


Advanced Techniques

HTTP/2 Fingerprinting

Beyond TLS, Cloudflare also analyzes HTTP/2 settings. Browsers have specific:

  • SETTINGS frame values (HEADER_TABLE_SIZE, MAX_CONCURRENT_STREAMS, etc.)
  • WINDOW_UPDATE sizes
  • Priority frame ordering

got-scraping handles most HTTP/2 fingerprint spoofing, but for maximum stealth:

const response = await gotScraping({
  url: 'https://target.com',
  http2: true, // Force HTTP/2
  headerGeneratorOptions: {
    browsers: ['chrome'],
    browserVersionRange: { chrome: [120, 125] }, // Specific version range
  },
});
Enter fullscreen mode Exit fullscreen mode

Canvas and WebGL Fingerprinting (JavaScript Level)

If you pass TLS and HTTP/2 checks but still get JavaScript challenges, Cloudflare may be checking browser-level fingerprints:

  • Canvas fingerprint — rendering differences between browsers/GPUs
  • WebGL renderer — GPU identification
  • Audio context — audio processing fingerprint

These require full browser automation (Puppeteer/Playwright) rather than HTTP-level tools. For most scraping tasks, TLS fingerprint spoofing with got-scraping is sufficient.

GREASE Extensions

Chrome adds random GREASE (Generate Random Extensions And Sustain Extensibility) values to TLS ClientHello messages. These are deliberately invalid extension types that well-behaved servers ignore. Cloudflare checks for their presence — if your client doesn't send GREASE values, it's not Chrome.

got-scraping includes GREASE support by default. If building a custom solution, ensure your TLS stack sends GREASE values in the correct positions.

FAQ

What is a JA3 fingerprint?

A JA3 fingerprint is an MD5 hash computed from five fields of a TLS ClientHello message: TLS version, cipher suites, extensions, elliptic curves, and point formats. Created by Salesforce's research team, it uniquely identifies HTTP clients with 99.4% accuracy. Cloudflare uses JA3 (and its successor JA4) to distinguish real browsers from scrapers.

Does got-scraping work for all Cloudflare-protected sites?

got-scraping works for most Cloudflare-protected sites that rely on TLS fingerprinting as their primary detection method. Sites with aggressive JavaScript challenges (Cloudflare's "Under Attack" mode) may require additional measures like full browser automation. For Vinted, Amazon, and most e-commerce sites, got-scraping is sufficient.

Is TLS fingerprint spoofing legal?

TLS fingerprint spoofing itself is a technical method, not inherently illegal. Legality depends on what you're scraping and the jurisdiction. Scraping publicly available data is generally legal in the EU (CJEU rulings) and the US (hiQ v LinkedIn). Always review the target site's Terms of Service.

How many requests can I make per session before rotating?

For Cloudflare-protected sites, 50-100 requests per session is the safe range. Some sites allow more, others flag you after 30 requests. Start with 50, monitor for 403/503 responses, and adjust. The Vinted Smart Scraper uses adaptive session rotation based on response monitoring.

Can I use free proxies for Cloudflare bypass?

Free proxies almost never work for Cloudflare bypass. They're datacenter IPs that are already blocklisted. You need residential proxies — IPs from real ISPs. Services like Bright Data, Oxylabs, or Apify's built-in proxy provide residential IPs. The Vinted Smart Scraper includes residential proxy rotation in its pricing.

Why does Puppeteer stealth plugin still get blocked?

Puppeteer stealth (puppeteer-extra-plugin-stealth) patches JavaScript-level detection but doesn't change the underlying TLS fingerprint. Headless Chromium has a different TLS fingerprint than headed Chrome. Cloudflare detects this difference at the TLS layer before any JavaScript runs. Combine Puppeteer with a TLS proxy or use got-scraping for HTTP-level scraping.

What's the difference between JA3 and JA4?

JA3 (2017) creates a single MD5 hash from TLS ClientHello parameters. JA4 (2023) creates a multi-part fingerprint with more detail: TLS version, SNI, cipher count, extension count, plus separate hashes for ciphers and extensions. JA4 is more robust against evasion techniques and better handles TLS 1.3. Cloudflare now checks both.

How does got-scraping differ from curl-impersonate?

Both tools spoof TLS fingerprints. curl-impersonate is a modified cURL binary — faster but requires spawning a system process from Node.js (slow, resource-heavy). got-scraping is a native Node.js library — integrates directly into your code, supports async/await, and includes automatic header generation. For Node.js projects, got-scraping is the better choice.

Can Cloudflare detect got-scraping?

Cloudflare continuously updates detection methods. As of February 2026, got-scraping with residential proxies and session rotation successfully bypasses Cloudflare on the vast majority of protected sites. However, no tool guarantees 100% bypass forever — it's an ongoing arms race. Using managed solutions like the Vinted Smart Scraper ensures you benefit from ongoing updates without maintaining the bypass logic yourself.

Resources


Related articles:

Built with Vinted Smart Scraper | Vinted MCP Server | GitHub | npm

Top comments (0)