Simplescraper
Skip to content

How to bypass Cloudflare in Puppeteer

How to bypass Cloudflare in Puppeteer

Updated 2026-06-25 · 6 min read

If your Puppeteer scrape lands on a "Checking your browser before you continue" page or a "Just a moment..." screen that never resolves, the site is fronted by Cloudflare and the default headless browser is failing its challenge. Stock headless Chrome sends a few signals that standard desktop Chrome does not, and Cloudflare's interstitial reads them on the very first navigation, so the page you wanted never renders.

The fix is to launch a Puppeteer build that does not leak the automation signal Cloudflare keys on, then wait for the challenge to resolve and capture the cookie that lets later requests skip it. We'll build a small script that launches Chrome through a drop-in Puppeteer fork so the first navigation does not announce automation, sets a current desktop Chrome User-Agent and hides the navigator.webdriver flag so the browser looks like a normal one, polls for the "Just a moment..." interstitial to clear instead of guessing at a fixed sleep, and reads the cf_clearance cookie Cloudflare sets on success so cheaper HTTP requests can reuse it for the rest of the session. It runs in about 70 lines of Node.js with rebrowser-puppeteer, a drop-in replacement that ships the Runtime.Enable fix Cloudflare reads on the first navigation.

The complete script

js
// bypass-cloudflare.mjs
import puppeteer from 'rebrowser-puppeteer'

const url = 'https://your-authorized-target.example.com/'

// Cloudflare's interstitial sets a "Just a moment..." title and a #challenge-form
// element. the real page has neither once the challenge clears.
const CHALLENGE_TITLES = new Set(['Just a moment...', 'Attention Required! | Cloudflare'])

const isStillChallenged = async (page) => {
  const title = await page.title()
  if (CHALLENGE_TITLES.has(title)) return true
  // the interactive Turnstile widget lives inside #challenge-form / #challenge-stage.
  return page.evaluate(() => Boolean(document.querySelector('#challenge-form, #challenge-stage')))
}

const patchWebdriver = () => {
  // navigator.webdriver reports `true` under automation; normal browsing reports `false`.
  Object.defineProperty(navigator, 'webdriver', { get: () => false })
}

const browser = await puppeteer.launch({
  headless: true,
  args: [
    '--disable-blink-features=AutomationControlled', // drops the Blink automation flag
    '--no-sandbox'
  ]
})

const page = await browser.newPage()

// match the major version to the Chromium build rebrowser-puppeteer launches.
await page.setUserAgent(
  'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
  '(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
)

// run before the page's own scripts on the first navigation.
await page.evaluateOnNewDocument(patchWebdriver)

await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 })

// poll for the non-interactive interstitial to resolve. cap the wait at ~15s;
// if it has not cleared by then the challenge is interactive and out of scope here.
const deadline = Date.now() + 15_000
while (Date.now() < deadline && (await isStillChallenged(page))) {
  await new Promise((resolve) => setTimeout(resolve, 1000))
}

if (await isStillChallenged(page)) {
  console.log('Interstitial did not clear: likely an interactive challenge. Stopping.')
  await browser.close()
  process.exit(0)
}

// capture the cf_clearance cookie so cheaper follow-up requests can reuse it.
const cookies = await page.cookies()
const clearance = cookies.find((cookie) => cookie.name === 'cf_clearance')
console.log('cf_clearance:', clearance ? clearance.value : '(none set)')

const title = await page.title()
console.log('Resolved page title:', title)

await browser.close()
bash
npm install rebrowser-puppeteer
REBROWSER_PATCHES_RUNTIME_FIX_MODE=addBinding node bypass-cloudflare.mjs

How it works

Launch through rebrowser-puppeteer. The import is the only change from stock Puppeteer. The fork keeps the same API and applies the Runtime.Enable patch when REBROWSER_PATCHES_RUNTIME_FIX_MODE=addBinding is set in the environment, so your page.goto and page.evaluate calls work unchanged while the CDP leak Cloudflare reads is closed. The fix mode defaults to a value that can leave the leak observable depending on version, so set the environment variable on the run command and confirm the check passes against rebrowser-bot-detector.

Drop the automation flags before navigation. The --disable-blink-features=AutomationControlled arg stops Chrome from advertising itself as automated at the Blink layer, and overriding navigator.webdriver with evaluateOnNewDocument hides the JavaScript property. Both run before the first request, because the interstitial reads them on the page Cloudflare serves first.

Set a desktop Chrome User-Agent. Default headless Chrome sends a UA containing the literal token HeadlessChrome. Replace it with a current desktop Chrome string and keep the Chrome major version matched to the Chromium build the fork launches, so the UA does not contradict the engine behind it. A hardcoded Chrome/126 against a newer Chromium build is itself a signal the interstitial can read, so either read the live version with await browser.version() and build the string from it, or pin the rebrowser-puppeteer version and keep the major in step with it.

Poll for the interstitial instead of sleeping a fixed time. The non-interactive "Just a moment..." check usually resolves within a few seconds. The loop re-reads the page title and looks for #challenge-form once a second up to a 15-second cap, so it returns as soon as the real page appears and gives up cleanly when it does not. Navigate with waitUntil: 'domcontentloaded', not 'networkidle2': the interstitial keeps polling its own challenge endpoint, so the network never goes idle and a goto waiting on network quiet hangs until it times out. If the loop runs to its cap with the title unchanged, the challenge is interactive (a Turnstile widget that expects a human click), and the script stops so you can route those targets to a flow like puppeteer-real-browser that drives a non-headless window.

Capture cf_clearance for reuse. Once the challenge passes, Cloudflare sets a cf_clearance cookie. Reading it with page.cookies() lets you send it on later HTTP requests to the same host, so you run the heavy browser once and then fetch with a lighter client until the cookie expires. Cloudflare ties the cookie to the IP address and User-Agent that earned it, so reuse it only from the same IP and the same User-Agent header and re-run the browser pass when either changes. A clean browser on a datacenter IP can still be handed the interstitial on every request, because Cloudflare scores network reputation separately from the fingerprint, so route through a residential proxy when the target keeps challenging you.

Use this when

You run authorized, permitted data collection on a Cloudflare-fronted target you own or have written permission to scrape, and the non-interactive "Just a moment..." interstitial is blocking a request you are entitled to make. Respect the site's robots.txt, terms of service, and rate limits before reaching for any of this.

Skip this when

Skip it when a plain fetch already returns the page (you do not need a browser), when the data has an official API or export (use that), when the block is an interactive managed challenge or Turnstile widget (drive it with puppeteer-real-browser rather than this poll loop), and when the target's terms forbid automated access (do not scrape it).

Skip the code, just get the data

Simplescraper turns any website into structured data in seconds.