How Bot Detection Works in 2026
Modern anti-bot systems don't just check your User-Agent. They build a fingerprint from dozens of signals and cross-reference them. If any combination doesn't match a "real browser profile," you get a 403, a CAPTCHA, or a silent redirect to a honeypot page.
- navigator.webdriver — set to
truein headless browsers by default - Chrome runtime checks —
window.chromeis absent in headless mode - Permission API — real browsers report "granted" for notifications; headless returns "denied"
- Plugin list — real Chrome has 3 plugins (PDF Viewer, Chrome PDF Viewer, Native Client)
- WebGL vendor/renderer — headless exposes software rendering strings
- Canvas fingerprint — headless renders canvas elements differently than GPU-accelerated Chrome
- TLS fingerprint (JA3/JA4) — Chromium's TLS handshake has distinct characteristics
- Mouse/keyboard patterns — no human-like movement, instant events
- iframe contentWindow — inconsistencies between frames and parent
- Timezone/locale mismatches — IP geolocation vs browser timezone don't match
puppeteer-extra-plugin-stealth
The most widely used stealth solution for Puppeteer. It patches 11+ evasions in a single plugin, covering most of the detection vectors above.
Install
npm install puppeteer-extra puppeteer-extra-plugin-stealth puppeteer
Basic usage
import puppeteer from 'puppeteer-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
puppeteer.use(StealthPlugin());
const browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const page = await browser.newPage();
await page.goto('https://bot.sannysoft.com'); // fingerprint test page
await page.screenshot({ path: 'stealth-test.png', fullPage: true });
await browser.close();
Individual evasion control
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
// Enable only the evasions you need
const stealth = StealthPlugin();
stealth.enabledEvasions.clear();
stealth.enabledEvasions.add('chrome.app');
stealth.enabledEvasions.add('chrome.csi');
stealth.enabledEvasions.add('chrome.loadTimes');
stealth.enabledEvasions.add('chrome.runtime');
stealth.enabledEvasions.add('navigator.languages');
stealth.enabledEvasions.add('navigator.permissions');
stealth.enabledEvasions.add('navigator.plugins');
stealth.enabledEvasions.add('navigator.webdriver'); // most critical
stealth.enabledEvasions.add('sourceurl');
stealth.enabledEvasions.add('user-agent-override');
stealth.enabledEvasions.add('webgl.vendor');
puppeteer.use(stealth);
Manual Fingerprint Patches
For sites that have gotten wise to the stealth plugin, you can patch at the CDP (Chrome DevTools Protocol) level directly:
const page = await browser.newPage();
// Remove webdriver flag
await page.evaluateOnNewDocument(() => {
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
});
// Inject realistic plugin list
await page.evaluateOnNewDocument(() => {
Object.defineProperty(navigator, 'plugins', {
get: () => [
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' },
{ name: 'Native Client', filename: 'internal-nacl-plugin', description: '' },
],
});
});
// Realistic language list
await page.evaluateOnNewDocument(() => {
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
});
// Spoof WebGL
await page.evaluateOnNewDocument(() => {
const getParameter = WebGLRenderingContext.prototype.getParameter;
WebGLRenderingContext.prototype.getParameter = function(parameter) {
if (parameter === 37445) return 'Intel Inc.'; // UNMASKED_VENDOR_WEBGL
if (parameter === 37446) return 'Intel Iris OpenGL Engine'; // UNMASKED_RENDERER_WEBGL
return getParameter.call(this, parameter);
};
});
User-Agent & Headers
Your UA must match your browser version exactly. A mismatch between the reported UA and actual Chromium version is an instant red flag.
// Get actual Chromium version from the browser
const version = await browser.version();
// e.g. "HeadlessChrome/124.0.6367.207"
await page.setUserAgent(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
);
// Set realistic Accept-Language and other headers
await page.setExtraHTTPHeaders({
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Upgrade-Insecure-Requests': '1',
});
Proxy Rotation
IP-based rate limiting and geoblocking are the most common blocks after fingerprinting. Rotating residential proxies are the most effective solution — datacenter IPs are flagged by most systems.
const proxies = [
'http://user:pass@proxy1.provider.com:10000',
'http://user:pass@proxy2.provider.com:10001',
'http://user:pass@proxy3.provider.com:10002',
];
function pickProxy() {
return proxies[Math.floor(Math.random() * proxies.length)];
}
async function stealthFetch(url) {
const proxy = pickProxy();
const browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
`--proxy-server=${proxy}`,
],
});
const page = await browser.newPage();
// Set timezone to match proxy location
await page.emulateTimezone('America/New_York');
try {
await page.goto(url, { waitUntil: 'networkidle2', timeout: 20000 });
return await page.content();
} finally {
await browser.close();
}
}
page.authenticate() as a fallback: await page.authenticate({ username: 'user', password: 'pass' })
Stealth with Playwright
Playwright doesn't have an official stealth plugin, but you can apply the same patches via addInitScript. There's also playwright-extra with stealth plugin support:
import { chromium } from 'playwright-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
chromium.use(StealthPlugin());
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto('https://bot.sannysoft.com');
await page.screenshot({ path: 'playwright-stealth.png' });
await browser.close();
Or manually with addInitScript:
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
locale: 'en-US',
timezoneId: 'America/New_York',
viewport: { width: 1280, height: 800 },
deviceScaleFactor: 2,
});
await context.addInitScript(() => {
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3] }); // truthy length
});
const page = await context.newPage();
await page.goto('https://example.com');
Handling Cloudflare Challenges
Cloudflare's Bot Management (formerly "Under Attack Mode") uses JavaScript challenges that execute in the browser. Plain waitUntil: 'networkidle' won't work — you need to wait for the challenge to resolve.
async function bypassCloudflare(page, url) {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
// Wait for CF challenge to complete (it reloads the page)
try {
await page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 20000 });
} catch {
// No redirect — page loaded directly (no CF challenge was triggered)
}
// Verify we're past the challenge
const title = await page.title();
if (title.includes('Just a moment')) {
throw new Error('Cloudflare challenge not resolved — rotate proxy or add delay');
}
return page;
}
Human Behavior Simulation
For the most aggressive bot detectors, you need to simulate human interaction patterns — random delays, mouse movement, realistic scroll behavior.
// Random delay between min and max ms
const delay = (min, max) =>
new Promise(r => setTimeout(r, min + Math.random() * (max - min)));
// Simulate human-like mouse movement to an element
async function humanClick(page, selector) {
const el = await page.$(selector);
const box = await el.boundingBox();
// Move to a random point within the element
const x = box.x + box.width * (0.2 + Math.random() * 0.6);
const y = box.y + box.height * (0.2 + Math.random() * 0.6);
await page.mouse.move(x - 50, y - 30); // approach from offset
await delay(80, 200);
await page.mouse.move(x, y, { steps: 8 }); // smooth movement
await delay(50, 150);
await page.mouse.click(x, y);
}
// Simulate human scroll
async function humanScroll(page) {
const totalHeight = await page.evaluate(() => document.body.scrollHeight);
let scrolled = 0;
while (scrolled < totalHeight) {
const step = 200 + Math.random() * 300;
await page.evaluate(s => window.scrollBy(0, s), step);
scrolled += step;
await delay(100, 400);
}
}
// Usage
await page.goto(url, { waitUntil: 'domcontentloaded' });
await delay(800, 2000); // human read delay
await humanScroll(page);
await delay(500, 1500);
await page.screenshot({ path: 'stealth.png' });
SnapAPI: Managed Stealth — Zero Config
Maintaining stealth infrastructure is a full-time job — browser fingerprints evolve, anti-bot vendors update their detection weekly, and proxies get burned. SnapAPI handles all of it. Pass stealth: true and it handles Cloudflare, rotating proxies, and fingerprint management automatically.
import { SnapAPI } from 'snapapi-js';
const snap = new SnapAPI({ apiKey: process.env.SNAPAPI_KEY });
// Stealth screenshot — handles Cloudflare challenges automatically
const { data } = await snap.screenshot({
url: 'https://heavily-protected-site.com',
stealth: true,
blockAds: true,
blockCookieBanners: true,
waitUntil: 'networkidle',
fullPage: true,
});
// Returns PNG buffer — no proxy management, no fingerprint tuning needed
// Stealth scrape — full HTML after JS execution + bot bypass
const { data } = await snap.scrape({
url: 'https://protected-site.com/listings',
stealth: true,
waitForSelector: '.product-list',
});
console.log(data.html); // full rendered HTML, past any challenges
Stealth Approach Comparison
| Approach | Cloudflare | DataDome | Setup effort | Maintenance | Cost |
|---|---|---|---|---|---|
| puppeteer-extra-plugin-stealth | Partial | Partial | Low | Medium (updates) | Free |
| Manual CDP patches + proxy | Good | Good | High | High | Proxy cost |
| playwright-extra + stealth | Partial | Partial | Low | Medium | Free |
| SnapAPI (stealth: true) | ✅ Full | ✅ Full | Minimal | None | $79/mo (50K) |
Quick Stealth Checklist
- ✅ Remove
navigator.webdriverflag (most critical) - ✅ Inject realistic plugin list (length > 0)
- ✅ Set UA matching exact Chromium version
- ✅ Match timezone to proxy IP geolocation
- ✅ Set realistic Accept-Language headers
- ✅ Spoof WebGL vendor/renderer strings
- ✅ Add human-like delays between actions
- ✅ Use residential proxies (not datacenter)
- ✅ Run
headless: 'new'notheadless: true(Chrome 112+) - ✅ Wait for Cloudflare challenge redirect to complete