Skip to main content
Nodriver is specifically designed to be undetectable by anti-bot systems. Unlike other browser automation tools, it operates with built-in stealth features that make it indistinguishable from a regular Chrome browser.

How nodriver avoids detection

Nodriver works differently from traditional automation tools like Selenium:
1
No automation flags
2
Nodriver doesn’t use Chrome’s --enable-automation flag, which is a dead giveaway for bot detection systems.
3
Clean JavaScript environment
4
The browser’s JavaScript environment doesn’t contain telltale automation properties like window.navigator.webdriver.
5
Native CDP communication
6
By using Chrome DevTools Protocol directly instead of WebDriver, nodriver leaves no automation traces in the browser.
7
No modified Chrome binaries
8
Nodriver uses the standard Chrome/Chromium browser without modifications, making it identical to a regular user’s browser.

Automatic stealth features

Nodriver applies stealth techniques automatically when you use headless=True: From tab.py:203-222:
async def _prepare_headless(self):
    if getattr(self, "_prep_headless_done", None):
        return
    resp = await self._send_oneshot(
        cdp.runtime.evaluate(
            expression="navigator.userAgent",
        )
    )
    if not resp:
        return
    response, error = resp
    if response and response.value:
        ua = response.value
        # Remove "Headless" from user agent
        await self._send_oneshot(
            cdp.network.set_user_agent_override(
                user_agent=ua.replace("Headless", ""),
            )
        )
    setattr(self, "_prep_headless_done", True)

User agent cleaning

Nodriver automatically removes the “Headless” string from the user agent when running in headless mode.

Running in headless mode

Headless mode allows you to run without a visible browser window:
import nodriver as uc

# Start in headless mode
browser = await uc.start(headless=True)
tab = await browser.get('https://example.com')

# The site won't detect headless mode
Even in headless mode, nodriver removes detection indicators automatically.

Expert mode for advanced stealth

Use expert mode for additional anti-detection features:
from nodriver import Config

# Create config with expert mode
config = Config(expert=True)
browser = await uc.start(config=config)
From config.py:68-70:
:param expert: when set to True, enabled "expert" mode.
       This conveys, the inclusion of parameters: --disable-site-isolation-trials,
       as well as some scripts and patching useful for debugging
From tab.py:224-241:
async def _prepare_expert(self):
    if getattr(self, "_prep_expert_done", None):
        return
    if self.browser:
        await self._send_oneshot(cdp.page.enable())
        await self._send_oneshot(
            cdp.page.add_script_to_evaluate_on_new_document(
                """
                console.log("hooking attachShadow");
                Element.prototype._attachShadow = Element.prototype.attachShadow;
                Element.prototype.attachShadow = function () {
                    console.log('calling hooked attachShadow')
                    return this._attachShadow( { mode: "open" } );
                };"""
            )
        )
    setattr(self, "_prep_expert_done", True)
Expert mode:
  • Disables site isolation trials
  • Forces shadow DOM to always be in “open” mode
  • Adds debugging capabilities

Custom user agent

Override the user agent if needed:
from nodriver import cdp

browser = await uc.start()
tab = browser.main_tab

# Set custom user agent
await tab.send(
    cdp.network.set_user_agent_override(
        user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
    )
)

# Navigate with new user agent
await tab.get('https://example.com')

Realistic timing and behavior

Make your automation more human-like:
import random

async def human_like_typing(element, text):
    """Type with random delays like a human"""
    for char in text:
        await element.send_keys(char)
        # Random delay between 50-150ms
        await tab.wait(random.uniform(0.05, 0.15))

async def human_like_mouse_move(element):
    """Move mouse naturally before clicking"""
    # Hover first
    await element.mouse_move()
    await tab.wait(random.uniform(0.1, 0.3))
    # Then click
    await element.click()

Using custom Chrome profile

Use a real user profile to maintain cookies and browsing history:
from pathlib import Path
from nodriver import Config

# Use existing Chrome profile
profile_path = Path.home() / '.config' / 'google-chrome' / 'Default'

config = Config(
    user_data_dir=str(profile_path),
    headless=False
)

browser = await uc.start(config=config)
Don’t use your main Chrome profile while Chrome is running. Create a separate profile for automation.

Handling CAPTCHAs

Nodriver can often bypass bot detection that triggers CAPTCHAs:
browser = await uc.start()
tab = await browser.get('https://site-with-protection.com')

# Many sites won't show CAPTCHA at all
# If CAPTCHA appears, you may need manual solving
try:
    content = await tab.select('.protected-content', timeout=5)
    print('No CAPTCHA detected!')
except:
    print('CAPTCHA detected, may need manual intervention')
    await tab.wait(30)  # Wait for manual solving

Dealing with Cloudflare

Nodriver often passes Cloudflare checks automatically:
async def bypass_cloudflare():
    browser = await uc.start()
    tab = await browser.get('https://site-with-cloudflare.com')
    
    # Wait for Cloudflare check
    await tab.wait(5)
    
    # Check if we're past the challenge
    try:
        challenge = await tab.find('checking your browser', timeout=2)
        if challenge:
            print('Waiting for Cloudflare check...')
            await tab.wait(10)
    except:
        print('No Cloudflare challenge detected')
    
    # Continue with normal automation
    content = await tab.get_content()
    return content

Fingerprint resistance

Canvas fingerprinting

Add noise to canvas fingerprinting:
script = """
(() => {
    const originalToDataURL = HTMLCanvasElement.prototype.toDataURL;
    HTMLCanvasElement.prototype.toDataURL = function() {
        // Add slight randomness to canvas output
        const context = this.getContext('2d');
        if (context) {
            const imageData = context.getImageData(0, 0, this.width, this.height);
            for (let i = 0; i < imageData.data.length; i += 4) {
                imageData.data[i] += Math.floor(Math.random() * 3) - 1;
            }
            context.putImageData(imageData, 0, 0);
        }
        return originalToDataURL.apply(this, arguments);
    };
})();
"""

await tab.send(
    cdp.page.add_script_to_evaluate_on_new_document(script)
)

WebGL fingerprinting

Obfuscate WebGL parameters:
script = """
(() => {
    const getParameter = WebGLRenderingContext.prototype.getParameter;
    WebGLRenderingContext.prototype.getParameter = function(parameter) {
        if (parameter === 37445) {  // UNMASKED_VENDOR_WEBGL
            return 'Intel Inc.';
        }
        if (parameter === 37446) {  // UNMASKED_RENDERER_WEBGL
            return 'Intel Iris OpenGL Engine';
        }
        return getParameter.apply(this, arguments);
    };
})();
"""

await tab.send(
    cdp.page.add_script_to_evaluate_on_new_document(script)
)

Proxy usage for IP rotation

Use browser contexts with different proxies:
# Create context with proxy
context1 = await browser.create_context(
    url='https://example.com',
    proxy_server='http://proxy1.example.com:8080',
    new_window=True
)

# Create another context with different proxy
context2 = await browser.create_context(
    url='https://example.com',
    proxy_server='http://proxy2.example.com:8080',
    new_window=True
)

# Each has different IP address
ip1 = await context1.evaluate('fetch("https://api.ipify.org").then(r => r.text())', await_promise=True)
ip2 = await context2.evaluate('fetch("https://api.ipify.org").then(r => r.text())', await_promise=True)

print(f"Context 1 IP: {ip1}")
print(f"Context 2 IP: {ip2}")

Best practices for stealth

1
Let nodriver handle detection
2
Nodriver’s built-in features are usually sufficient. Don’t over-complicate with additional stealth measures unless needed.
3
Use realistic timing
4
Add random delays between actions:
5
import random

# Random wait between actions
await tab.wait(random.uniform(1, 3))
6
Mimic human behavior
7
Scroll, hover, and move the mouse like a human would:
8
# Scroll gradually
for _ in range(5):
    await tab.scroll_down(20)
    await tab.wait(0.5)
9
Maintain sessions
10
Use cookies and localStorage to maintain session state:
11
# Save session after first visit
await browser.cookies.save('session.dat')

# Load in subsequent runs
await browser.cookies.load('session.dat')
12
Rotate user agents
13
Change user agents between sessions if making many requests:
14
user_agents = [
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64)...',
    'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)...',
    'Mozilla/5.0 (X11; Linux x86_64)...'
]

import random
await tab.send(
    cdp.network.set_user_agent_override(
        user_agent=random.choice(user_agents)
    )
)

Real-world example

Here’s a complete stealth automation example:
import nodriver as uc
import random
from pathlib import Path

async def stealth_scraping():
    # Start with headless mode
    browser = await uc.start(
        headless=True,
        sandbox=True
    )
    
    # Load previous session if exists
    session_file = Path('session.dat')
    if session_file.exists():
        await browser.cookies.load(session_file)
    
    tab = await browser.get('https://protected-site.com')
    
    # Wait for potential checks
    await tab.wait(random.uniform(2, 4))
    
    # Scroll like a human
    for _ in range(3):
        await tab.scroll_down(random.randint(20, 40))
        await tab.wait(random.uniform(0.5, 1.5))
    
    # Find and interact with elements
    search_box = await tab.select('input[type=search]')
    
    # Type with human-like delays
    search_term = 'search query'
    for char in search_term:
        await search_box.send_keys(char)
        await tab.wait(random.uniform(0.05, 0.15))
    
    # Random pause before submit
    await tab.wait(random.uniform(0.5, 1.5))
    
    submit = await tab.select('button[type=submit]')
    await submit.click()
    
    # Wait for results
    await tab.wait(random.uniform(2, 4))
    
    # Save session for next time
    await browser.cookies.save(session_file)
    
    # Get results
    results = await tab.select_all('.result')
    print(f'Found {len(results)} results')
    
    browser.stop()

if __name__ == '__main__':
    uc.loop().run_until_complete(stealth_scraping())

Testing detection

Verify that your automation is undetectable:
async def test_detection():
    browser = await uc.start()
    
    # Test pages that detect automation
    test_urls = [
        'https://bot.sannysoft.com',
        'https://arh.antoinevastel.com/bots/areyouheadless',
        'https://nowsecure.nl'
    ]
    
    for url in test_urls:
        tab = await browser.get(url)
        await tab.wait(3)
        
        # Take screenshot for manual inspection
        await tab.save_screenshot(f'{url.split("/")[-1]}.png')
        
        print(f'Tested: {url}')
    
    browser.stop()
Run the test detection script periodically to ensure your automation remains undetectable as anti-bot systems evolve.

Build docs developers (and LLMs) love