How to patch headless Chrome to avoid detection
If a site loads fine in your own browser but serves your headless scraper a blank page, a CAPTCHA, or an endless challenge screen, you have run into bot detection. Default headless Chrome gives itself away with a handful of tells that standard desktop Chrome does not send, and on plenty of sites that alone is enough to get you quietly turned away.
Closing those tells addresses some browser-fingerprint checks and does not grant authorization or bypass a site's policy. The solution is to launch through a patched build of Chrome and override the few fingerprints that still stand out. We'll build a small script that launches Chrome through a patched fork so the page can't catch Puppeteer driving it, injects an override script before any page JavaScript runs so the spoofs are in place before detection code reads them, rewrites the values that mark a headless browser as automated (the navigator.webdriver flag, the WebGL software-renderer strings, and the default canvas signature), and checks the result against an open bot-detector page. It comes to about 60 lines of Node.js with rebrowser-puppeteer and a small init script.
The complete script
// patch-headless-chrome.mjs
import puppeteer from 'rebrowser-puppeteer'
/* the init script runs in every frame before the page's own scripts.
each override targets one signal a bot detector reads. */
const canvasNoise = Math.floor(Math.random() * 3) + 1
const patchFingerprint = (noise) => {
// navigator.webdriver is `true` under automation. normal browsing reports `false`.
Object.defineProperty(navigator, 'webdriver', { get: () => false })
// headless Chrome reports a software renderer ("SwiftShader" / "Google Inc.").
// spoof WebGL vendor/renderer strings for a desktop Windows GPU path.
const getParameter = WebGLRenderingContext.prototype.getParameter
WebGLRenderingContext.prototype.getParameter = function (parameter) {
if (parameter === 37445) return 'Google Inc. (Intel)' // UNMASKED_VENDOR_WEBGL
if (parameter === 37446) return 'ANGLE (Intel, Intel(R) UHD Graphics 630 Direct3D11 vs_5_0 ps_5_0, D3D11)' // UNMASKED_RENDERER_WEBGL
return getParameter.call(this, parameter)
}
// a pristine canvas fingerprints identically across all headless instances.
// add a tiny per-session offset on a copy so repeated reads stay stable.
const toDataURL = HTMLCanvasElement.prototype.toDataURL
HTMLCanvasElement.prototype.toDataURL = function (...args) {
const context = this.getContext('2d')
const { width, height } = this
if (!context || width === 0 || height === 0) {
return toDataURL.apply(this, args)
}
const image = context.getImageData(0, 0, width, height)
for (let i = 0; i < image.data.length; i += 4) {
image.data[i] = Math.min(255, image.data[i] + noise)
}
const copy = document.createElement('canvas')
copy.width = width
copy.height = height
copy.getContext('2d').putImageData(image, 0, 0)
return toDataURL.apply(copy, args)
}
}
const browser = await puppeteer.launch({
headless: true,
args: [
'--disable-blink-features=AutomationControlled', // drops the automation flag
'--no-sandbox'
]
})
const page = await browser.newPage()
// desktop Chrome User-Agent; match the major version to the Chromium build you launch.
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 the patch before the page loads, so it covers the first navigation.
await page.evaluateOnNewDocument(patchFingerprint, canvasNoise)
await page.goto('https://bot-detector.rebrowser.net/', { waitUntil: 'networkidle2' })
const report = await page.evaluate(() => document.body.innerText)
console.log(report)
await browser.close()npm install rebrowser-puppeteer
REBROWSER_PATCHES_RUNTIME_FIX_MODE=addBinding node patch-headless-chrome.mjsHow it works
Launch through rebrowser-puppeteer. The import is the only code change from stock Puppeteer. The fork ships the same API and closes the leak detectors like Cloudflare and DataDome read on the first navigation, the Runtime.Enable call stock Puppeteer makes to get a per-frame execution context, when REBROWSER_PATCHES_RUNTIME_FIX_MODE=addBinding is set; installing the package without that env var leaves the leak partly open, so confirm with the detector that the Runtime.Enable check passes. The fork tracks upstream Puppeteer but lags it, so install the rebrowser version whose major matches your target Puppeteer release and re-run the detector after any upgrade. A clean fingerprint from a datacenter IP still trips Cloudflare and DataDome, since network reputation is checked separately from the browser, so route through a residential proxy when the target allows it.
Pass the automation-control flag. The Blink automation-control flag stops Chrome from advertising itself as automated at the Blink layer, which is a separate signal from navigator.webdriver. Without it, some detectors flag the browser before any of your JavaScript runs.
Set a desktop Chrome User-Agent. Headless Chrome's default UA contains the literal token HeadlessChrome, which is the simplest possible block. Replace it with a current desktop Chrome string and match the major version to the Chrome you are actually running.
Inject the patch with evaluateOnNewDocument. This registers the override to run before the page's own scripts on every navigation and every new frame. Running the same code with page.evaluate after goto is too late, because the detection script has already read the unpatched values.
Override WebGL and canvas. The WebGL parameter constants 37445 and 37446 are UNMASKED_VENDOR_WEBGL and UNMASKED_RENDERER_WEBGL. Returning Windows ANGLE GPU strings hides the software renderer, but keep the platform consistent: a macOS GPU string under a Windows User-Agent is an internally inconsistent fingerprint that scores worse than the unpatched default, so keep the User-Agent, WebGL strings, navigator.platform, and timezone on one platform. The canvas patch adds a small, consistent per-session offset to pixel values on a copy of the canvas, so the fingerprint differs from the default headless value but stays stable within the session; fresh random noise on every toDataURL call would change the hash between two reads of the same canvas, which is itself a tell detectors test for.
Use this when
You run authorized, permitted data collection: your own sites, an API you are licensed to use, or a target whose terms and robots rules allow it, and a bot-detection layer 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 at all), when the data has an official API or export (use that), when only a single CAPTCHA stands in the way (solve that challenge directly rather than patching the whole browser), and when the block is a hard IP ban rather than a fingerprint check (rotate the network path instead).