Selenium with antidetect browser profiles
Attach Selenium to an XDLogin profile: a real Chrome on your machine with its own fingerprint, proxy, timezone and cookies, still signed in from last time. XDLogin launches it; ChromeDriver attaches through debuggerAddress instead of starting a Chrome of its own. It works on every plan, the free one included.
Four steps from script to signed-in page
-
Turn on the API
In XDLogin, open Settings → API & Auto and press Start API. It listens on
127.0.0.1:31337. If you generate an API key there, send it on every request asAuthorization: Bearer <key>. From then on the API starts with XDLogin, until you press Stop API. -
Launch the profile
POST /api/profiles/{id}/launchstarts Chrome with the profile’s fingerprint and proxy, and answers once both are in place. If the profile is already open, it brings the window forward instead of starting a second copy. -
Read the endpoint
GET /api/profiles/{id}/connectanswers409until Chrome is ready, then returnsdebuggerAddress(such as127.0.0.1:49152) andbrowser, the version of the Chrome running the profile. The port can change with every launch, so read it each time. -
Attach ChromeDriver
Set
debuggerAddressinChromeOptionsand create the driver as usual. ChromeDriver connects to the running Chrome, so the session has the profile’s cookies and logins.
Launch, attach, work, detach
Set XDLOGIN_PROFILE_ID to an id from GET /api/profiles, and XDLOGIN_API_KEY if you set a key. The script opens a tab in the profile, prints what the page sees, closes the tab and ends the ChromeDriver session, leaving the profile open. --stop stops the profile afterwards.
Show the full script (94 lines in Python, 56 in Node.js)
"""Launch one XDLogin profile and attach Selenium to it through ChromeDriver.
XDLOGIN_PROFILE_ID=<id> python python/selenium_attach.py [url] [--stop]
XDLOGIN_BASE_URL API address (default http://127.0.0.1:31337)
XDLOGIN_API_KEY only if you set a key in Settings > API & Auto
CHROMEDRIVER path to a chromedriver matching the profile's Chrome; without it
Selenium Manager picks one for the Chrome installed on this machine
--stop stop the profile afterwards instead of leaving it running
"""
import os
import sys
import time
from urllib.parse import quote
import requests
from selenium import webdriver
from selenium.common.exceptions import SessionNotCreatedException
from selenium.webdriver.chrome.service import Service
BASE = os.environ.get("XDLOGIN_BASE_URL", "http://127.0.0.1:31337").rstrip("/")
KEY = os.environ.get("XDLOGIN_API_KEY")
HEADERS = {"Authorization": f"Bearer {KEY}"} if KEY else {}
def api(method, path, timeout=90):
try:
r = requests.request(method, BASE + path, headers=HEADERS, timeout=timeout)
except requests.ConnectionError:
sys.exit(f"Can't reach the XDLogin API at {BASE}. In XDLogin: Settings > API & Auto > Start API.")
if r.status_code == 401:
sys.exit("401 Unauthorized: set XDLOGIN_API_KEY to the key in Settings > API & Auto, or unset it if no key is set.")
try:
body = r.json()
except ValueError:
body = {}
return r.status_code, body
def connect_info(profile_id, timeout=30):
# 409 until Chrome is ready, and also for a profile that is not running,
# does not exist or uses the built-in engine: give up with the reason.
deadline = time.monotonic() + timeout
while True:
code, body = api("GET", f"/api/profiles/{quote(profile_id)}/connect", timeout=10)
if code == 200:
return body["data"]
if code != 409 or time.monotonic() > deadline:
sys.exit(f"No DevTools endpoint: {body.get('error') or f'HTTP {code}'}")
time.sleep(0.5)
def main():
profile = os.environ.get("XDLOGIN_PROFILE_ID", "").strip()
if not profile:
sys.exit(f"Set XDLOGIN_PROFILE_ID to a profile id. List them with: curl {BASE}/api/profiles")
args = sys.argv[1:]
stop = "--stop" in args
target = next((a for a in args if not a.startswith("--")), "https://example.com")
code, body = api("POST", f"/api/profiles/{quote(profile)}/launch")
if code != 200:
sys.exit(f"Launch failed: {body.get('error') or f'HTTP {code}'}")
info = connect_info(profile)
print(f"Profile runs {info['browser']} at {info['debuggerAddress']}")
options = webdriver.ChromeOptions()
options.debugger_address = info["debuggerAddress"] # attach instead of launching a new Chrome
driver_path = os.environ.get("CHROMEDRIVER")
service = Service(executable_path=driver_path) if driver_path else Service()
try:
driver = webdriver.Chrome(options=options, service=service)
except SessionNotCreatedException as e:
sys.exit(f"ChromeDriver refused to attach. Its major version must match the profile's browser "
f"({info['browser']}); point CHROMEDRIVER at a matching chromedriver.\n{e.msg}")
try:
driver.switch_to.new_window("tab") # a new tab in the profile, with its cookies
driver.get(target)
print("Title:", driver.title)
print("The page sees:", driver.execute_script(
"return {userAgent: navigator.userAgent,"
" timezone: Intl.DateTimeFormat().resolvedOptions().timeZone}"))
driver.close() # closes that tab only
finally:
driver.quit() # ends the ChromeDriver session; the profile's Chrome keeps running
if stop:
code, body = api("POST", f"/api/profiles/{quote(profile)}/stop")
print("Profile stopped." if code == 200 else f"Stop failed: {body.get('error') or f'HTTP {code}'}")
if __name__ == "__main__":
main()// XDLOGIN_PROFILE_ID=<id> node selenium-attach.mjs [url] [--stop]
import { Builder } from 'selenium-webdriver';
import chrome from 'selenium-webdriver/chrome.js';
import { setTimeout as sleep } from 'node:timers/promises';
const BASE = process.env.XDLOGIN_BASE_URL || 'http://127.0.0.1:31337';
const KEY = process.env.XDLOGIN_API_KEY; // only if you set a key in XDLogin
const PROFILE = process.env.XDLOGIN_PROFILE_ID; // from GET /api/profiles
const headers = KEY ? { Authorization: `Bearer ${KEY}` } : {};
const args = process.argv.slice(2);
const target = args.find((a) => !a.startsWith('--')) || 'https://example.com';
async function api(method, path) {
const res = await fetch(BASE + path, { method, headers }).catch(() => {
throw new Error(`No XDLogin API at ${BASE}: Settings > API & Auto > Start API`);
});
if (res.status === 401) throw new Error('401: set XDLOGIN_API_KEY to the key from Settings > API & Auto');
return { code: res.status, ...(await res.json().catch(() => ({}))) };
}
// 409 until Chrome is ready. Also 409 for a profile that isn't running,
// doesn't exist or uses the built-in engine, so don't wait forever.
async function connectInfo(id, timeoutMs = 30_000) {
const deadline = Date.now() + timeoutMs;
for (;;) {
const r = await api('GET', `/api/profiles/${id}/connect`);
if (r.code === 200) return r.data;
if (r.code !== 409 || Date.now() > deadline) throw new Error(r.error ?? `HTTP ${r.code}`);
await sleep(500);
}
}
if (!PROFILE) throw new Error('Set XDLOGIN_PROFILE_ID');
const launched = await api('POST', `/api/profiles/${PROFILE}/launch`);
if (launched.code !== 200) throw new Error(`Launch failed: ${launched.error}`);
const info = await connectInfo(PROFILE);
console.log(`Profile runs ${info.browser} at ${info.debuggerAddress}`);
// Attach to the running Chrome instead of starting a new one. Selenium Manager
// picks a chromedriver for the Chrome installed here; it must match info.browser.
const options = new chrome.Options().debuggerAddress(info.debuggerAddress);
const builder = new Builder().forBrowser('chrome').setChromeOptions(options);
if (process.env.CHROMEDRIVER) builder.setChromeService(new chrome.ServiceBuilder(process.env.CHROMEDRIVER));
const driver = await builder.build();
try {
await driver.switchTo().newWindow('tab'); // a new tab in the profile, with its cookies
await driver.get(target);
console.log('Title:', await driver.getTitle());
console.log('The page sees:', await driver.executeScript(
'return {userAgent: navigator.userAgent, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone}'));
await driver.close(); // closes that tab only
} finally {
await driver.quit(); // ends the ChromeDriver session; the profile's Chrome keeps running
}
if (args.includes('--stop')) await api('POST', `/api/profiles/${PROFILE}/stop`);Install with pip install selenium requests or npm i selenium-webdriver. Selenium 4.6 and later fetches a ChromeDriver for the Chrome installed on your machine by itself; see the first pitfall for when that isn’t the right one.
A profile with a proxy, from code
POST /api/profiles takes the same fields as the profile editor. The docs have the reference: the profile fields with an example profile.json, every device id, and what each error status means. From Python, with that profile.json:
# python create_profile.py (reads profile.json from the current folder)
import json
import os
import requests
BASE = os.environ.get("XDLOGIN_BASE_URL", "http://127.0.0.1:31337")
KEY = os.environ.get("XDLOGIN_API_KEY")
HEADERS = {"Authorization": f"Bearer {KEY}"} if KEY else {}
with open("profile.json", encoding="utf-8") as f:
r = requests.post(f"{BASE}/api/profiles", json=json.load(f), headers=HEADERS, timeout=30)
body = r.json()
if not r.ok: # 400 bad field, 403 plan limit
raise RuntimeError(f"{r.status_code}: {body.get('error')}")
print("Created", body["data"]["id"])Several profiles at once
Each profile is its own Chrome process with its own DevTools port, so runs in different profiles don’t touch each other. Give every profile its own thread and its own driver, and start a few at a time: every profile is a full browser, and memory runs out before anything else. This version prints the exit IP each profile shows to websites, a quick check that every proxy is in place.
# Uses api() and connect_info() from the script above. One ChromeDriver
# session per profile, each in its own thread.
from concurrent.futures import ThreadPoolExecutor
def run_one(profile_id):
code, body = api("POST", f"/api/profiles/{quote(profile_id)}/launch")
if code != 200:
return profile_id, f"launch failed: {body.get('error')}"
options = webdriver.ChromeOptions()
options.debugger_address = connect_info(profile_id)["debuggerAddress"]
driver = webdriver.Chrome(options=options)
try:
driver.switch_to.new_window("tab")
driver.get("https://api.ipify.org/")
ip = driver.find_element("tag name", "body").text
driver.close()
return profile_id, ip
finally:
driver.quit() # the profile keeps running
ids = os.environ["XDLOGIN_PROFILE_IDS"].split(",")
with ThreadPoolExecutor(max_workers=3) as pool: # each profile is a full Chrome process
for profile_id, result in pool.map(run_one, ids):
print(profile_id, result)Give each profile to one script at a time. Two scripts in one profile share its tabs and cookies and get in each other’s way, and two launches of the same profile at the same moment can fail, because two Chromes can’t share one profile folder.
What usually goes wrong
A ChromeDriver for the wrong Chrome
ChromeDriver’s major version must match the Chrome running the profile, which /connect reports in browser (for example Chrome/152.0.7977.76). Selenium picks a driver for the Chrome installed on your machine, normally the same one. If you set a custom browser path in XDLogin, or Chrome has updated since the profile launched, pass a matching driver yourself: Service(executable_path=...) in Python, chrome.ServiceBuilder(path) in Node.
Leaving out debuggerAddress
Without debuggerAddress in ChromeOptions, ChromeDriver starts a new Chrome with a temporary profile of its own: none of the profile’s fingerprint, proxy, cookies or logins. Set it to data.debuggerAddress from /connect before you create the driver.
Waiting on 409 forever
409 from /connect means the endpoint isn’t ready. It also means the profile isn’t running, doesn’t exist, or opens in the built-in engine, which has no DevTools endpoint (Settings → Network → Browser engine must be Chromium). Poll for about 30 seconds, then show the error field.
Forgetting the key
Once you set an API key, every request needs Authorization: Bearer <key>, /api/health included. Anything else gets 401. A new key applies after you stop and start the API.
Expecting quit() to close Chrome
When ChromeDriver attached through debuggerAddress, driver.quit() ends the session and leaves the profile’s Chrome running. Stop it with POST /api/profiles/{id}/stop. driver.close() closes the current tab, so don’t close the profile’s last one.
Launch options on an attached session
Arguments, extensions and a binary path in ChromeOptions change how Chrome starts, and this Chrome has already started, so they do nothing. For the same reason there’s no need for --user-data-dir: the profile already is one.
Setting your own user agent
Don’t send Network.setUserAgentOverride or Emulation.setTimezoneOverride through execute_cdp_cmd, and don’t resize the window to a size the profile’s screen can’t hold. The profile already sets these values, and changing one makes it disagree with the rest of its fingerprint.
Stopping right after a login
Chrome writes cookies to disk about every 30 seconds; anything newer is written only when it closes normally. On Windows, from 3.5.7, /stop asks Chrome to close normally and ends it only if it hasn’t closed within 10 seconds, so a fresh login is kept. The Linux build (3.5.6) and the macOS beta (3.5.5) end Chrome at once. On those, wait half a minute after a fresh login before you stop the profile.
Sharing a driver between threads
A WebDriver session isn’t built to be shared. Give each thread its own profile and its own driver, as the parallel example does.
What a profile doesn’t change
The TLS handshake, WebGL limits, font measurements and some Web Workers still come from this computer, and on Edge or Brave the client hints still say Google Chrome. The docs explain each one, and the test results page has our own probe results.
- ChromeDriver is its own layer. It finds and inspects elements by running its own JavaScript inside the page. XDLogin doesn’t hide what ChromeDriver adds.
- Behavior is yours. The profile sets what a site can read about the device. How your script acts, its timing, clicks and request rate, is up to you.