Reading layout

Browser Fingerprint and Stealth Configuration

A headless browser executes JavaScript and renders the DOM, which lets it read data plain HTTP clients never see — and that same runtime environment leaks dozens of signals marking it as automated. This guide belongs to Advanced Scraping Techniques and Anti-Bot Evasion and explains how each surface is measured, how much identifying information each one actually carries, and how to keep a browser profile internally coherent. Apply these techniques only to targets whose robots.txt and terms of service permit automated access; the goal here is a browser that reports itself accurately and consistently, not one that impersonates a person who does not exist.

Headless browser fingerprint surface A central headless browser node connects to six fingerprint surfaces: on the left the navigator.webdriver flag, User-Agent and viewport, and plugins and languages; on the right the canvas fingerprint, WebGL vendor and renderer, and font metrics. Headlessbrowsernavigator.webdriverautomation flagUser-Agent + viewportmust stay consistentPlugins & languagespopulated in real browsersCanvas fingerprintpixel hashWebGL vendor / rendererGPU stringsFont metricsinstalled-font probe
A headless browser exposes JS-environment and rendering signals; stealth means neutralizing each surface.

When to Use Stealth Configuration

Browser stealth addresses detection that happens after a page loads, inside the JavaScript environment. That is a different layer from the network handshake covered in TLS and JA3 Fingerprint Evasion, and applying the wrong fix wastes days.

SymptomLayerWhere to fix it
Normal 200, then a challenge or empty page under automationJavaScript environmentThis guide
bot.sannysoft.com shows red rows for webdriver or pluginsJavaScript environmentThis guide
Refused before any HTML arrives, from any clientTLS handshakeThe handshake guide linked above
Works from your laptop, fails from a datacenterIP reputationRotating Proxies and Managing IP Blocks
An interactive widget appears asking for human inputChallenge, not fingerprintBypassing Cloudflare and Akamai Protections
The data reaches a phone app but not the web clientDifferent surfaceScraping Mobile App APIs

The other question worth asking first is whether you need a browser at all. If the page renders from a JSON endpoint you can call directly, an impersonating HTTP client avoids the entire fingerprint problem for a twentieth of the cost. Reach for a browser when the data genuinely requires the renderer, when the flow needs real input events, or when you already automate with Using Playwright for Modern Web Automation or Mastering Selenium for Dynamic Websites and need those sessions to survive environment checks.

Prerequisites

Python 3.10 or newer, plus both stealth toolkits and their browser engines.

pip install "playwright>=1.44" playwright-stealth undetected-chromedriver "selenium>=4.20"
playwright install chromium

The playwright install chromium step downloads the browser build Playwright drives. undetected-chromedriver uses your locally installed Google Chrome instead, so a current Chrome must be present on the machine. Confirm the toolchain resolves:

python -c "import playwright, undetected_chromedriver; print('stealth deps OK')"

You also want a measurement target. bot.sannysoft.com renders a table of the classic checks, and abrahamjuliot.github.io/creepjs reports a stability score across sessions. Both are diagnostics, not scoreboards: passing them proves nothing about a specific site's rules, but failing them proves you have work to do.

Step-by-Step: Building a Coherent Browser Profile

1. Understand which surfaces actually carry information

Not all fingerprint surfaces are equal. The value of a signal is the entropy it contributes — how much it narrows the population — and patching the low-entropy ones while leaving the high-entropy ones untouched is the most common wasted effort in this area.

Bits of entropy per fingerprint surface A horizontal bar chart. Canvas hashing contributes about 8.6 bits, the WebGL renderer 7.1, installed fonts 6.4, the User-Agent 5.9, screen and viewport 4.2, and timezone with locale 3.1. Identifying entropy per surface0369 bitscanvas hashWebGL rendererinstalled fontsuser-agentscreen + viewporttimezone + locale8.67.16.45.94.23.1
The rendering surfaces carry more identifying entropy than the User-Agent, which is why patching the string alone changes very little.

The rendering surfaces dominate. A canvas hash draws text and shapes off-screen and hashes the resulting pixels; because GPU, driver, antialiasing and font rasterisation all affect those pixels, the hash is stable per machine and carries roughly 8-9 bits on a typical desktop population. WebGL exposes UNMASKED_VENDOR_WEBGL and UNMASKED_RENDERER_WEBGL strings, which in a container almost always read Google Inc. (Google) and SwiftShader — an immediate tell, because virtually no real desktop user renders in software. Font enumeration measures text widths to infer which families are installed, and a minimal container image installs almost none.

The User-Agent, by contrast, carries less than six bits and is the one thing everybody patches. That asymmetry explains why a scraper with a perfect User-Agent still gets flagged: the string says Windows and the renderer says Linux software rasteriser. Spoofing Canvas and WebGL Fingerprints goes into how those two surfaces are read and what changing them costs.

2. Patch the navigator.webdriver flag

The single most reliable automation tell is navigator.webdriver, which WebDriver-controlled browsers set to true. Detection scripts read it in one line, so masking it is the baseline. With Playwright you inject an init script that runs before any page JavaScript:

from playwright.sync_api import sync_playwright

STEALTH_JS = "Object.defineProperty(navigator, 'webdriver', {get: () => undefined});"

def fetch_flag_state(url: str) -> str:
    """Open a page with the webdriver flag patched and return its title."""
    with sync_playwright() as p:
        browser = p.chromium.launch(
            headless=True,
            args=["--disable-blink-features=AutomationControlled"],
        )
        context = browser.new_context(
            viewport={"width": 1920, "height": 1080},
            user_agent=(
                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
            ),
        )
        context.add_init_script(STEALTH_JS)
        page = context.new_page()
        page.goto(url, wait_until="domcontentloaded", timeout=30000)
        title = page.title()
        browser.close()
        return title

if __name__ == "__main__":
    print(fetch_flag_state("https://bot.sannysoft.com"))

Two things must both be true. --disable-blink-features=AutomationControlled stops Chrome advertising automation at the browser level, and the init script covers the JavaScript property — patching only one leaves the other readable. Register the script on the context, before the first navigation, or it runs too late to matter. The equivalent patch for WebDriver stacks is in How to Configure Selenium Stealth to Avoid Detection.

3. Apply the bundled patch set for the rendering surfaces

Hand-writing patches for canvas, WebGL, plugins, chrome.runtime, permissions and language arrays means maintaining a sprawl of small scripts that go stale. playwright-stealth bundles a maintained set:

from playwright.sync_api import sync_playwright
from playwright_stealth import stealth_sync

def render_with_stealth(url: str) -> str:
    """Load a fingerprint test page with playwright-stealth patches applied."""
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        context = browser.new_context(
            viewport={"width": 1366, "height": 768},
            locale="en-US",
            timezone_id="America/New_York",
            user_agent=(
                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
            ),
        )
        page = context.new_page()
        stealth_sync(page)
        page.goto(url, wait_until="domcontentloaded", timeout=30000)
        html = page.content()
        browser.close()
        return html

if __name__ == "__main__":
    print(len(render_with_stealth("https://bot.sannysoft.com")))

Setting locale and timezone_id alongside the User-Agent is not decoration. A US English User-Agent paired with a UTC timezone and a non-US WebGL renderer is precisely the kind of internal contradiction detectors score, and it is far more damning than any single value.

4. Define the identity once and reuse it everywhere

Individual patches fail when signals contradict each other across a fleet. Centralise a profile so every context, every proxy binding and every header set draws from the same source of truth.

Declared identity compared with measured environment Four rows compare what a scraper declares with what a page measures. The User-Agent matches, while platform, timezone, and WebGL renderer all drift and are marked as mismatches. SignalYou declarePage measuresVerdictUser-Agent stringChrome 124, WindowsChrome 124, Windowsoknavigator.platformWin32Linux x86_64drifttimezone offsetAmerica/New_YorkUTC, container defaultdriftWebGL rendererGeForce GTX 1660SwiftShader softwaredrift
Detection rarely needs one damning signal — it only needs your declared identity and your measured environment to disagree.
from dataclasses import dataclass, field

@dataclass(frozen=True)
class BrowserProfile:
    """A self-consistent set of fingerprint-facing values."""
    user_agent: str = (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
        "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
    )
    viewport: dict[str, int] = field(default_factory=lambda: {"width": 1920, "height": 1080})
    locale: str = "en-US"
    timezone_id: str = "America/New_York"
    platform: str = "Win32"
    languages: tuple[str, ...] = ("en-US", "en")

    def extra_headers(self) -> dict[str, str]:
        """Return client-hint headers matching this profile."""
        return {
            "Accept-Language": f"{self.languages[0]},{self.languages[1]};q=0.9",
            "Sec-Ch-Ua": '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"',
            "Sec-Ch-Ua-Mobile": "?0",
            "Sec-Ch-Ua-Platform": f'"{"Windows" if self.platform == "Win32" else "Linux"}"',
        }

    def init_script(self) -> str:
        """Return JavaScript that aligns the runtime with the declared profile."""
        langs = ", ".join(f'"{lang}"' for lang in self.languages)
        return (
            "Object.defineProperty(navigator, 'webdriver', {get: () => undefined});"
            f"Object.defineProperty(navigator, 'platform', {{get: () => '{self.platform}'}});"
            f"Object.defineProperty(navigator, 'languages', {{get: () => [{langs}]}});"
        )

if __name__ == "__main__":
    profile = BrowserProfile()
    print(profile.extra_headers())
    print(profile.init_script()[:80])

Feed the same BrowserProfile into every context you create, so a fleet of workers never mixes a Windows User-Agent with a Linux platform string. When you rotate identities, rotate the whole object — profile, headers and proxy exit together — as described in How to Rotate User Agents in Python.

5. Audit the profile from inside the page

Declaring values is not the same as the page observing them. Read the environment back through the same JavaScript a detection script would use, and compare it to what you claimed.

import json
from playwright.sync_api import sync_playwright

AUDIT_JS = """() => ({
  webdriver: navigator.webdriver,
  platform: navigator.platform,
  languages: navigator.languages,
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
  viewport: [window.innerWidth, window.innerHeight],
  hardwareConcurrency: navigator.hardwareConcurrency,
  renderer: (() => {
    const gl = document.createElement('canvas').getContext('webgl');
    if (!gl) { return null; }
    const ext = gl.getExtension('WEBGL_debug_renderer_info');
    return ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : null;
  })(),
})"""

def audit_profile(url: str = "https://example.com/") -> dict:
    """Read back the environment values a detection script would see."""
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        context = browser.new_context(locale="en-US", timezone_id="America/New_York")
        page = context.new_page()
        page.goto(url, wait_until="domcontentloaded", timeout=30000)
        observed = page.evaluate(AUDIT_JS)
        browser.close()
    return observed

if __name__ == "__main__":
    print(json.dumps(audit_profile(), indent=2))

Run this as a test in CI against every profile you ship. It catches the failures that matter: a timezone that reverted to UTC because the container ignored the setting, a renderer that reports SwiftShader on a host you assumed had a GPU, or a hardwareConcurrency of 1 on a throttled worker while your User-Agent claims a desktop.

6. Pick the right toolkit for your stack

Both mainstream tools mask the same surfaces but suit different stacks. undetected-chromedriver patches ChromeDriver and the browser to hide automation from the start and is a fast retrofit for existing Selenium code; its cost is that it drives your locally installed Chrome, which auto-updates. playwright-stealth layers patches onto Playwright, keeping the async model, auto-waiting and route interception. The head-to-head in undetected-chromedriver vs playwright-stealth compares coverage, maintenance cadence and speed.

Whichever you pick, pin the versions of the tool, the browser and the driver together in your image. A background Chrome update that shifts a patched surface is the single most common cause of a stealth setup that worked yesterday and does not today.

7. Vary a fleet coherently rather than randomly

A hundred workers sharing one profile look like one machine. A hundred workers with independently randomised values look like a hundred machines that cannot exist — a Windows platform with a macOS font list, a 4K viewport with a mobile client hint. The fix is to sample whole profiles from a small matrix of combinations that genuinely occur, not to randomise each field.

import random
from dataclasses import replace

DESKTOP_MATRIX = [
    {
        "platform": "Win32",
        "viewport": {"width": 1920, "height": 1080},
        "locale": "en-US",
        "timezone_id": "America/Chicago",
    },
    {
        "platform": "Win32",
        "viewport": {"width": 1366, "height": 768},
        "locale": "en-GB",
        "timezone_id": "Europe/London",
    },
    {
        "platform": "MacIntel",
        "viewport": {"width": 1728, "height": 1117},
        "locale": "en-US",
        "timezone_id": "America/Los_Angeles",
    },
]

MAC_USER_AGENT = (
    "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"
)

def sample_profile(base: BrowserProfile, rng: random.Random) -> BrowserProfile:
    """Draw one internally consistent profile from the matrix."""
    choice = rng.choice(DESKTOP_MATRIX)
    user_agent = MAC_USER_AGENT if choice["platform"] == "MacIntel" else base.user_agent
    return replace(
        base,
        user_agent=user_agent,
        platform=choice["platform"],
        viewport=choice["viewport"],
        locale=choice["locale"],
        timezone_id=choice["timezone_id"],
    )

if __name__ == "__main__":
    rng = random.Random(7)
    for _ in range(3):
        p = sample_profile(BrowserProfile(), rng)
        print(p.platform, p.locale, p.timezone_id, p.viewport)

Seed the generator per worker identity rather than per request, so one worker keeps one identity for its whole lifetime. An identity that changes its screen size between two requests in the same session is more suspicious than one that never changes at all. Bind each sampled profile to a proxy exit whose geography matches its timezone, and keep the pair together until you retire both.

The size of the matrix matters less than its plausibility. Three combinations that all describe real machines beat thirty that describe none, and a matrix built from your own observed traffic — or from public browser-share statistics — is better than one invented from memory.

Performance and Scaling Considerations

Stealth browsers are the most expensive tool in this toolkit. Each instance carries a full rendering engine, so memory dominates the budget and concurrency is bounded by RAM long before it is bounded by network. Budget 250-400 MB per loaded context and size your worker accordingly.

Four practices recover most of the cost. Reuse a single browser process across many contexts rather than launching a browser per page — the context is the unit of isolation, and it costs about 15 ms against 250 ms for a browser. Close contexts promptly in a finally block, because a leaked context keeps its cache and cookie jar resident for the browser's lifetime. Block images, media and fonts through route interception when you only need HTML, which cuts bandwidth by 70% or more; leave stylesheets alone if you rely on visibility waits, since removing layout makes elements report as invisible forever. And skip the browser entirely wherever a site actually serves its data through a JSON or HTML endpoint, reserving stealth browsing for genuinely script-rendered pages.

There is also a hidden cost in the patches themselves. Every init script runs on every navigation in every frame, so a large stealth bundle adds measurable time to each page load and, on script-heavy sites, competes with the page's own JavaScript for the main thread. Measure the difference: instrument a hundred navigations with and without the bundle and keep only the patches that change an observable outcome. In practice a handful of targeted patches — the webdriver flag, the platform and language properties, and the WebGL vendor strings — cover most of what a detection script reads, while the residue of exotic overrides costs time and occasionally introduces its own tell by making a property behave in a way no real browser does.

For large fleets, two extra rules apply. Run headless with realistic viewport sizes rather than the default 800×600, which almost no real user has. And accept that identical containers produce identical fingerprints — a hundred workers with the same canvas hash, the same font list and the same hardwareConcurrency look like one machine cloned a hundred times, which is a stronger signal than any individual value. Vary viewport, locale and timezone per worker in coherent combinations, and pair each with its own exit address. The metrics you want to watch — challenge rate, empty-response rate, per-worker success — are the subject of Monitoring and Alerting for Scrapers.

Common Errors and Fixes

bot.sannysoft.com still shows webdriver as present. Your init script ran after navigation, or you patched the JavaScript property without the browser flag. Add --disable-blink-features=AutomationControlled at launch and register the init script on the context before the first goto.

ImportError: cannot import name 'stealth_sync' from 'playwright_stealth'. The installed version exposes a different entry point. Inspect the current API with python -c "import playwright_stealth; print(dir(playwright_stealth))" and import what it lists; recent releases expose an apply_stealth helper and an async variant instead.

SessionNotCreatedException: This version of ChromeDriver only supports Chrome version N from undetected_chromedriver. Local Chrome updated past the driver. Pass version_main= matching your installed Chrome major version, or upgrade the package so it fetches a matching driver. Pinning Chrome inside a container image prevents the problem recurring.

WebGL renderer reports SwiftShader no matter what you patch. The machine has no GPU and Chrome fell back to software rendering. Patching the reported string helps only if the rest of the rendering output is consistent with it; on a headless server, expect to either accept the software renderer or provision GPU-backed instances.

playwright._impl._errors.TimeoutError waiting on networkidle. A long-polling connection or analytics beacon keeps the network busy so the quiet window never arrives. Wait on a specific selector instead, which is both faster and a more accurate statement of what you need.

Headless renders differ from headful and get flagged. The modern headless engine still differs subtly in font rasterisation and GPU paths. Launch with --headless=new, set an explicit viewport, and where a target is especially strict, run headful under a virtual framebuffer such as Xvfb.

Fingerprint is stable but sessions still die after a few minutes. The problem is behavioural or volume-based rather than environmental. Check your request pacing and per-identity request count before adding more patches.

navigator.languages is empty or contains a single entry. Chrome launched without a language argument reports a thin array that no real installation produces. Set locale on the context, pass --lang=en-US at launch, and send a matching Accept-Language header so the HTTP layer and the JavaScript layer agree.

Two workers on the same host produce identical canvas hashes. They share a GPU, a driver and a font set, so they will, and that is expected. The problem only becomes visible when both hit the same target under different declared identities. Give each worker its own exit address, or accept that identities on one host should be treated as one identity.

page.evaluate returns null for the WebGL renderer. The WEBGL_debug_renderer_info extension is unavailable, either because the context could not be created at all or because the browser build restricts it. Fall back to the standard gl.getParameter(gl.RENDERER) value, and treat a missing WebGL context as a signal in its own right — real desktop browsers almost always have one.

Frequently Asked Questions

What is the most important fingerprint to fix first? The navigator.webdriver flag, because it is a single boolean that instantly identifies WebDriver automation and any script can read it in one line. Fix it with both a browser-level flag and an init script, then move to the rendering surfaces, which carry far more entropy than the User-Agent most people patch next.

Can stealth configuration defeat an interactive challenge? No. Stealth reduces heuristic and fingerprint-based detection, but it does not solve a cryptographic or interactive challenge. Those require the browser to genuinely execute the challenge, and if a site has escalated to demanding human input, that is a decision about your traffic rather than a puzzle to optimise.

Do I need TLS impersonation if I already use a stealth browser? Usually not for the browser itself — a real Chrome produces a genuine Chrome handshake. You need separate TLS impersonation when you drop down to a lightweight HTTP client for speed, which is a common and sensible pattern once a browser has established the session.

Why does my scraper work locally but get blocked in the cloud? Three things change at once: the IP moves to a datacenter ASN with worse reputation, the GPU disappears so WebGL reports a software renderer, and the container ships almost no fonts. Each is measurable with the audit script above, and all three point at the same fix — make the cloud environment resemble the machine that worked, or accept that it will be scored differently.

Is headless mode inherently detectable? It leaks more than headful mode — subtle rendering differences, missing GPU acceleration and a thin font list — but --headless=new plus canvas and WebGL patches narrows the gap substantially. For strict targets, headful under a virtual display remains the most faithful option, at roughly the same memory cost.