Code flashes on screen. await page.addInitScript(() => { Object.defineProperty(navigator, ‘webdriver’, { get: () => undefined }); }); Boom. One line, and suddenly your headless browser doesn’t rat itself out.
But wait — zoom out. That snippet? It’s from a dusty npm package, playwright-stealth, untouched for over a year. While anti-bot armies from Cloudflare to custom enterprise shields evolved to sniff 40+ fingerprints, this old guard patches just 12. You’re running naked in 2026.
Caught Red-Handed: The Detection Dead Drops
WebGL renderer screams ‘headless.’ GPU mismatch. navigator.permissions spits wrong notification states. window.chrome? Half-baked, missing loadTimes() and csi() — methods real Chrome coughs up without thinking.
HTTP/2 fingerprints leak your Node.js origins via TLS JA3. Iframes nest like Russian dolls, exposing the puppeteer underbelly. Language mismatches with proxies? Amateur hour.
Here’s the kicker — and it’s not in the original rundown. This feels like the early 2000s spam filter wars (remember those?), where blacklists chased regex patterns until Bayesian filters flipped the script. By 2027, expect AI to watch behavior: mouse wiggles, scroll hesitations. JS patches buy time, but the real arms race is puppeteering humans.
Note: undefined not false. Some detectors specifically check for false as a patched value.
Spot on. False is the lazy flag; undefined is native Chrome’s whisper.
Patch #1: Webdriver’s Ghost
await page.addInitScript(() => { Object.defineProperty(navigator, ‘webdriver’, { get: () => undefined, configurable: true }); });
Short. Sweet. Essential. Detectors probe this first — undefined fools ‘em into thinking it’s a real browser soul.
Why Is Playwright Stealth Crumbling Now?
Modern shields query deeper. They don’t just peek at webdriver; they cross-check plugins, mimeTypes, Chrome sub-objects. Original package skips loadTimes(), a timing method that headless skips entirely.
Plug it:
window.chrome.loadTimes = function() { return { requestTime: Date.now() / 1000, // … rest as listed }; };
And csi()? Same deal — mimics Chrome’s telemetry stub. Without these, you’re a ghost in the machine, but a detectable one.
Corporate spin from Playwright maintainers? ‘Use extraHTTPHeaders.’ Cute, but irrelevant here. Fingerprinting laughs at headers.
Patch Arsenal: Plugins and Permissions
Next, plugins array — fake PDF viewers to match real Chrome’s bloat.
const plugins = [ { name: ‘PDF Viewer’, description: “…”, filename: ‘internal-pdf-viewer’ }, // etc. ];
navigator.plugins gets a proxy object with item(), namedItem(). mimeTypes? Barebones length:2, dummies.
Permissions query for notifications? Override to echo real Notification.permission. No more ‘prompt’ leaks from headless voids.
- await page.addInitScript(() => {
- const originalQuery = window.navigator.permissions.query;
- window.navigator.permissions.query = (parameters) => (
- parameters.name === ‘notifications’
- ? Promise.resolve({ state: Notification.permission })
- originalQuery(parameters) ); });
Clean.
WebGL: The GPU Masquerade
Do These Patches Beat 2026 Anti-Bots?
WebGLRenderingContext.getParameter(37445) — that’s UNMASKED_VENDOR_WEBGL. Headless defaults? Intel or Mesa, mismatched to your proxy’s ‘user.’
Patch:
const getParameter = WebGLRenderingContext.prototype.getParameter; WebGLRenderingContext.prototype.getParameter = function(parameter) { if (parameter === 37445) return ‘Intel Inc.’; if (parameter === 37446) return ‘Intel Iris OpenGL Engine’; return getParameter.call(this, parameter); };
Swap for NVIDIA if gaming rig vibes. Match your canvas.
Language patch? Obvious, but deadly mismatch with geo-IP.
Object.defineProperty(navigator, ‘language’, { get: () => ‘en-US’ });
Proxy in Berlin? Set ‘de-DE’ or flag yourself.
Iframe Traps and Chrome Deep Dive
Iframes birth nested contexts — unpatched, they inherit webdriver truth.
Override document.createElement for iframes, injecting stealth into contentWindow.navigator.
const origCreateElement = document.createElement.bind(document); document.createElement = function(…args) { const element = origCreateElement(…args); if (args[0].toLowerCase() === ‘iframe’) { // define contentWindow proxy } return element; };
Chrome completeness: app, runtime, enums. Full stub or bust.
window.chrome.app = { isInstalled: false, / enums / };
HTTP/2 and TLS? That’s proxy turf — fingerprint your stack separately.
Wrapping the applyStealthPatches Function
async function applyStealthPatches(page) { await page.addInitScript(/ all above /); }
Call pre-navigate. Test on fingerprint.com or creepjs. Iterate.
But here’s my bold call: This patches the ‘what,’ not the ‘how you act.’ Train your bots on real user traces — or hire ‘em. JS is the easy layer; behavior’s the moat.
🧬 Related Insights
- Read more: Cloudflare Cracks the Code: ASTs Turn Workflow Scripts into Stunning Visual Maps
- Read more: 3.1 Seconds to Boil: The Precise Mind of George Goble Fades Out
Frequently Asked Questions
What is Playwright stealth mode?
It’s init scripts masking headless browsers as real Chrome to dodge anti-bot fingerprints like webdriver, WebGL, and Chrome APIs.
How do I install these Playwright stealth patches?
npm i playwright; then async applyStealthPatches(page) with the code above before page.goto(). Test rigorously.
Will Playwright stealth patches work forever?
Nah — detectors evolve. By late 2026, behavioral AI might ignore JS entirely. Stay sharp.