Headless Chrome bots powered by Playwright have become a go-to tool for bot developers due to their flexibility and efficiency. Playwright’s cross-browser capabilities, coupled with an API similar to Puppeteer and the lightweight nature of Headless Chrome, make it a powerful choice for tasks like web scraping, credential stuffing, and automated account creation.
In this article, we’ll explore effective techniques for detecting Headless Chrome bots instrumented with Playwright using HTTP headers and JavaScript fingerprinting signals. While Playwright also supports automation for other browsers like Firefox and Safari, this article focuses specifically on Chrome-based detection. For insights into identifying bots on other browsers, check out our dedicated articles on those topics.
TL;DR detection techniques:
If you are just interested in the code of the detection techniques, you can have a look at the code snippets below. The remainder of this article goes into the details of twhese techniques and explains how some of them can be bypassed by attackers. The 5 detection techniques work as follows:
- Using the user agent HTTP headers or
navigator.userAgent
in JS to detect user agents linked to Headless Chrome:Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/131.0.0.0 Safari/537.36
- Using the
sec-ch-ua
client hints HTTP header to detect theHeadlessChrome
substring:"sec-ch-ua": "\\"Not(A:Brand\\";v=\\"99\\", \\"HeadlessChrome\\";v=\\"133\\", \\"Chromium\\";v=\\"133\\""
- Using the absence of the
Accept-language
HTTP header on a Chromium-based browser; - By detecting if
navigator.webdriver = true
in JavaScript - By detecting the side effects of CDP (Chrome DevTools Protocol), the protocol used by Playwright to instrument Headless Chrome
On the server side, Playwright with headless Chrome can be detected using the following detection techniques (note that this is pseudocode):
// Server-side pseudocode to detect Playwright
// Test if Chromium-based browser without the Accept-Language HTTP header
if (req.headers.get('user-agent').contains('Chrome') && !req.headers.get('accept-language')) {
console.log('Headless Chrome detected!);
}
// Test if the user agent HTTP header is linked to Headless Chrome
if (req.headers.get('user-agent').contains('HeadlessChrome')) {
console.log('Headless Chrome detected!);
}
// Test if the sec-ch-ua HTTP header is linked to Headless Chrome
if (req.headers.get('sec-ch-ua').contains('HeadlessChrome')) {
console.log('Headless Chrome detected!);
}
On the client side, using JS in the browser, we can use the following fingerprinting challenges to detect Playwright:
// 3 Efficient techniques to detect Headless Chrome bots instrumented with Playwright:
// Detection technique 1 based on the user agent
if (navigator.userAgent.includes("HeadlessChrome")) {
console.log('Headless Chrome detected!);
}
// Detection technique 2 based on navigator.webdriver
if (navigator.webdriver) {
console.log('Headless Chrome detected!);
}
// Detection technique 3 based on a serialization side effect of the Chrome Devtools Protocol
var e = new Error();
Object.defineProperty(e, 'stack', {
get() {
console.log('Headless Chrome detected!);
}
});
// This is part of the detection, the console.log shouldn't be removed!
console.log(e);
Technique 1: Detecting unmodified Headless Chrome using the user agent and other HTTP headers
To illustrate our detection techniques, we create a local server that listens to port 4006. We use it to collect information about HTTP headers as well as to deliver pages to collect JS browser fingerprinting challenges.
We create a simple Puppeteer bot that uses Headless Chrome, visits localhost:4006/http_headers, and prints its own HTTP headers returned by the server.
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('http://localhost:4006/http_headers');
await new Promise(resolve => setTimeout(resolve, 2000));
const httpHeaders = await page.evaluate(() => {
return document.body.textContent;
});
console.log(JSON.stringify(JSON.parse(httpHeaders), null, 2));
await browser.close();
})();
We obtain the following HTTP headers:
{
"host": "localhost:4006",
"connection": "keep-alive",
"sec-ch-ua": "\"Not(A:Brand\";v=\"99\", \"HeadlessChrome\";v=\"133\", \"Chromium\";v=\"133\"",
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": "\"macOS\"",
"upgrade-insecure-requests": "1",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/133.0.6943.16 Safari/537.36",
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"sec-fetch-site": "none",
"sec-fetch-mode": "navigate",
"sec-fetch-user": "?1",
"sec-fetch-dest": "document",
"accept-encoding": "gzip, deflate, br, zstd"
}
We notice that by default, headless Chrome bots instrumented with Playwright have a user agent that contains the HeadlessChrome
substring:
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/133.0.6943.16 Safari/537.36",
Similarly, we also detect the presence of HeadlessChrome
in the sec-ch-ua
client hints HTTP header:
"sec-ch-ua": "\"Not(A:Brand\";v=\"99\", \"HeadlessChrome\";v=\"133\", \"Chromium\";v=\"133\"",
Besides the HeadlessChrome
substring, we observe the absence of the Accept-Language
HTTP header by default. This header is present on normal Chrome browsers and indicates the user's preferred languages.
Thus, the first detection technique is to verify if the user agent contains the HeadlessChrome
substring. Note that the user agent can be collected both on the server side (it is an HTTP header) and in the browser using JavaScript.
if (navigator.userAgent.includes("HeadlessChrome")) {
console.log('Headless Chrome detected!);
}
On the server side, you can also verify the presence of the Accept-Language HTTP header as well as a sec-ch-ua
HTTP header with the HeadlessChrome
substring:
# pseudocode on the server
if (req.headers.get('user-agent').contains('Chrome') && !req.headers.get('accept-language')) {
console.log('Headless Chrome detected!);
}
if (req.headers.get('sec-ch-ua').contains('HeadlessChrome')) {
console.log('Headless Chrome detected!);
}
Technique 2: Detecting modified Headless Chrome using navigator.webdriver
When it comes to bot detection, HTTP headers and the user agent more specifically are often the first fingerprinting attributes modified by attackers to hide their presence. In the case of Playwright, it can be modified as follows:
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36',
extraHTTPHeaders: {
'Accept-Language': 'en-US,en;q=0.9',
'sec-ch-ua': '"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"'
}
});
Once changed:
- The browser is sending all expected HTTP headers, including the
Accept-Language
HTTP header; - The user agent and the client hints don’t contain the
HeadlessChrome
substring anymore:
navigator.userAgent
// -> returns 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36'
However, we can still detect the presence of a bot using the JavaScript navigator.webdriver
attribute. It is a JavaScript property that indicates whether a browser is being controlled by automation software (such as Playwright, Selenium, Puppeteer, or other web automation tools).
if (navigator.webdriver) {
console.log('Headless Chrome detected!);
}
Technique 3: Detecting modified Headless Chrome bots using CDP
As you can imagine, most attackers also try to get rid of the navigator.webdriver = true
property to avoid being detected as a bot.
The simplest way to erase this discriminating signal is to use the --disable-blink-features=AutomationControlled
Chrome command line argument.
In Playwright, the argument can be passed when creating the Headless Chrome browser instance:
const browser = await chromium.launch({ args: ['--disable-blink-features=AutomationControlled'] });
With this argument, the navigator.webdriver
property doesn’t return true
anymore:
navigator.webdriver
// -> returns false
Thus, to detect bots that modify their fingerprint, we need another approach. One of the most popular approaches in 2025 is called CDP detection. It leverages the fact that under the hood, Playwright uses CDP (Chrome DevTools Protocol) to communicate with Headless Chrome. During the communication process, Playwright needs to serialize data to send it with WebSocket, which may have unintended side effects. Thus, the purpose of the JavaScript challenge below is to trigger an observable side effect when the CDP serialization occurs.
var e = new Error();
Object.defineProperty(e, 'stack', {
get() {
console.log('Headless Chrome detected!);
}
});
// This is part of the detection, the console.log shouldn't be removed!
console.log(e);
If the console.log
contained in the get
function is called, it means that the error object was serialized, which happens only when CDP is used. Thus, it can be used to detect bot automation frameworks like Playwright that use CDP under the hood. Note that one of the side effects of this detection technique is that it will flag human users with dev tools open. The console.log(e)
statement is part of the challenge since it is what triggers the serialization in CDP, it shouldn’t be removed.
Bot detection: A never-ending cat and mouse game?
When it comes to credential stuffing, carding, and creating fake accounts, bot developers don’t stop too easily. They try to remain undetected by lying about all attributes commonly used for fraud detection and bot detection. In particular, they don’t stop at the user agent navigator.webdriver = true
or CDP detection. They lie about their canvas fingerprint, generate human-like mouse movements to appear more human, and leverage residential proxies to have a better IP reputation.
With the advent of bot development, they don’t need to be bot experts to develop sophisticated bots. They have access to open-source frameworks, such as Nodriver and Selenium driverless to have near-perfect human fingerprints. They can also benefit from residential proxy services that give them access to millions of residential IP addresses and AI-based CAPTCHA farms that can automatically solve CAPTCHA at scale for a few cents!
Thus, detecting sophisticated bots in 2025 is a full-time job and goes beyond fingerprinting! It’s key to continuously adapt to the latest techniques used by attackers and to leverage all available signals, such as the user’s behavior, IP reputation, proxy detection, and contextual signals using real-time machine learning.