How to bypass Cloudflare in Puppeteer
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.
Clearing the interstitial on an authorized target comes down to two moves: launch a Puppeteer build that does not leak the automation signal Cloudflare keys on, then wait for the challenge to resolve and capture the cf_clearance cookie so later requests skip it. It runs in about 70 lines of Node.js with rebrowser-puppeteer, a drop-in replacement that ships the rebrowser-patches Runtime.Enable fix, the same leak the puppeteer-extra-plugin-stealth evasions target.
Key terms
- Cloudflare interstitial. The "Checking your browser" / "Just a moment..." page Cloudflare serves before the real content, while it runs a JavaScript challenge against the visiting browser.
Runtime.Enableleak. A Chrome DevTools Protocol call stock Puppeteer makes to get an execution context per frame, observable from inside the page; Cloudflare reads it on the first navigation to flag automation.cf_clearancecookie. The cookie Cloudflare sets once a browser passes the challenge, which lets subsequent requests through without re-running it until it expires.- rebrowser-puppeteer. A fork of Puppeteer with the same API, pre-patched to neutralize the
Runtime.Enableleak whenREBROWSER_PATCHES_RUNTIME_FIX_MODE=addBindingis set. - Managed challenge. The interactive Cloudflare check (a Turnstile widget) that asks for a click; it does not resolve on its own and is out of scope for this script.
Here is what the script does:
- Launch Chrome through rebrowser-puppeteer, a drop-in import that closes the
Runtime.Enableleak Cloudflare reads whenREBROWSER_PATCHES_RUNTIME_FIX_MODE=addBindingis set. - Set a current desktop Chrome User-Agent and override
navigator.webdriverbefore the page loads, so the first navigation does not announce automation. - Navigate to the target and poll for the interstitial to clear, watching the page title and a Cloudflare marker rather than a fixed sleep.
- Read the
cf_clearancecookie once the challenge passes, so you can hand it to cheaper HTTP requests for the rest of the session.
The complete script
// 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()npm install rebrowser-puppeteer
REBROWSER_PATCHES_RUNTIME_FIX_MODE=addBinding node bypass-cloudflare.mjsWhat each step does
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. Without the environment variable, the leak can stay partly open, so set it on the run command.
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.
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.
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.
Gotchas
An interactive managed challenge never resolves on its own.
- Issue: the poll loop waits on a non-interactive interstitial; a Cloudflare managed challenge renders a Turnstile widget inside
#challenge-formthat expects a human click, so the loop runs to its 15-second cap and the title never changes. - Fix: detect the case (the script stops when
isStillChallengedis still true at the deadline) and route those targets to a real interactive flow such as puppeteer-real-browser, which launches a non-headless Chrome window to handle the click.
- Issue: the poll loop waits on a non-interactive interstitial; a Cloudflare managed challenge renders a Turnstile widget inside
The Runtime.Enable patch is off without the environment variable.
- Issue: installing rebrowser-puppeteer alone does not fully close the leak; the fix mode defaults to a value that can leave
Runtime.Enableobservable depending on version, and Cloudflare flags it on the first navigation. - Fix: set
REBROWSER_PATCHES_RUNTIME_FIX_MODE=addBindingbefore launch, then confirm against rebrowser-bot-detector that theRuntime.Enablecheck passes.
- Issue: installing rebrowser-puppeteer alone does not fully close the leak; the fix mode defaults to a value that can leave
A datacenter IP fails the check even with a clean browser.
- Issue: Cloudflare scores network reputation separately from the browser fingerprint, so a patched browser on a known datacenter IP range can still be handed the interstitial on every request.
- Fix: route the browser through a residential proxy and authenticate with
page.authenticatewhen the target's network path allows it.
waitUntil: 'networkidle2' hangs on a challenge page.
- Issue: the interstitial keeps polling its own challenge endpoint, so the network never goes idle and a
gotowithwaitUntil: 'networkidle2'sits until it times out. - Fix: navigate with
waitUntil: 'domcontentloaded'as the script does, then poll the page state yourself rather than waiting on network quiet.
- Issue: the interstitial keeps polling its own challenge endpoint, so the network never goes idle and a
The cf_clearance cookie is bound to the IP and User-Agent that earned it.
- Issue: sending the captured
cf_clearancefrom a different IP address or with a different User-Agent string is rejected, because Cloudflare ties the cookie to the session that passed the challenge. - Fix: reuse the cookie only from the same IP and the same User-Agent header, and re-run the browser pass when either changes or the cookie expires.
- Issue: sending the captured
The User-Agent major version drifts from the launched Chromium.
- Issue: hardcoding
Chrome/126while the fork installs a newer Chromium build sends a UA that contradicts the real engine, which is itself a signal the interstitial can read. - Fix: read the live version with
await browser.version()and build the User-Agent string from it, or pin the rebrowser-puppeteer version and keep the hardcoded major in step with it.
- Issue: hardcoding
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).