Playwright with antidetect browser profiles
Attach Playwright to an XDLogin profile: a real Chrome on your machine with its own fingerprint, proxy, timezone and cookies, still signed in from last time. You launch it through the local API, read its DevTools endpoint and connect over CDP. 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 returnswsEndpoint,httpEndpoint,debuggerAddress,portandbrowser. The port can change with every launch, so read it each time. -
Connect Playwright
chromium., then work inconnectOverCDP(wsEndpoint) browser.contexts()[0]. That context is the profile, with its cookies and logins.
Launch, attach, work, detach
Both versions run as they are. Set XDLOGIN_PROFILE_ID to an id from GET /api/profiles, and XDLOGIN_API_KEY if you set a key. By default the script detaches and leaves the profile open; --stop stops it afterwards.
Show the full script (51 lines in Node.js, 82 in Python)
// XDLOGIN_PROFILE_ID=<id> node playwright-launch.mjs [url] [--stop]
import { chromium } from 'playwright';
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(`Attached to ${info.browser} at ${info.debuggerAddress}`);
// noDefaults (Playwright 1.60+): leave the profile's own settings alone.
const browser = await chromium.connectOverCDP(info.wsEndpoint, { noDefaults: true });
const context = browser.contexts()[0]; // the profile itself: its cookies and logins
const page = await context.newPage();
await page.goto(target, { waitUntil: 'domcontentloaded' });
console.log('Title:', await page.title());
console.log('The page sees:', await page.evaluate(() => ({
userAgent: navigator.userAgent,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
})));
await page.close();
await browser.close(); // over CDP this only disconnects: the profile keeps running
if (args.includes('--stop')) await api('POST', `/api/profiles/${PROFILE}/stop`);"""Launch one XDLogin profile, attach Playwright over CDP, look at a page, then detach.
XDLOGIN_PROFILE_ID=<id> python python/playwright_launch.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
--stop stop the profile afterwards instead of leaving it running
"""
import os
import sys
import time
from urllib.parse import quote
import requests
from playwright.sync_api import sync_playwright
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):
# /connect answers 409 until Chrome is ready. It also answers 409 when the
# profile is not running, does not exist or uses the built-in engine.
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"Attached to {info['browser']} at {info['debuggerAddress']}")
with sync_playwright() as p:
# no_defaults (Playwright 1.60+) leaves the profile's own settings alone.
browser = p.chromium.connect_over_cdp(info["wsEndpoint"], no_defaults=True)
context = browser.contexts[0] # the profile itself: its cookies and logins
page = context.new_page()
page.goto(target, wait_until="domcontentloaded")
print("Title:", page.title())
print("The page sees:", page.evaluate(
"() => ({ userAgent: navigator.userAgent,"
" timezone: Intl.DateTimeFormat().resolvedOptions().timeZone })"))
page.close()
browser.close() # over CDP this only disconnects; the profile 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()Install with npm i playwright or pip install playwright requests. Skip playwright install: nothing needs downloading, because Playwright attaches to the Chrome that XDLogin launched. noDefaults needs Playwright 1.60 or later; on older versions, drop it.
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.
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. 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, which is a quick way to check that every proxy is in place.
// Uses api() and connectInfo() from the script above.
async function runOne(id) {
const launched = await api('POST', `/api/profiles/${id}/launch`);
if (launched.code !== 200) throw new Error(`launch: ${launched.error}`);
const info = await connectInfo(id);
const browser = await chromium.connectOverCDP(info.wsEndpoint, { noDefaults: true });
try {
const page = await browser.contexts()[0].newPage();
await page.goto('https://api.ipify.org/?format=json');
const { ip } = JSON.parse(await page.innerText('body'));
await page.close();
return { id, ip };
} finally {
await browser.close(); // disconnect only
}
}
// At most 3 profiles starting at once: each one is a full Chrome process.
const ids = process.env.XDLOGIN_PROFILE_IDS.split(',');
const queue = [...ids];
const results = [];
await Promise.all([1, 2, 3].map(async () => {
for (let id = queue.shift(); id; id = queue.shift()) {
results.push(await runOne(id).catch((err) => ({ id, error: err.message })));
}
}));
console.table(results);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
Launching instead of attaching
chromium.launch() starts a new browser that Playwright runs with a temporary profile of its own: none of the profile’s fingerprint, proxy, cookies or logins. Launch through the API and attach with connectOverCDP.
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.
Opening a new context
The profile’s cookies and logins live in browser.contexts()[0]. browser.newContext() starts an empty context, without the profile’s cookies, and applies Playwright’s own 1280×720 viewport.
Expecting close() to stop the profile
For a browser from connectOverCDP, browser.close() only disconnects; the profile keeps running. To stop it, call POST /api/profiles/{id}/stop.
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 stopping, or let Chrome shut down cleanly by sending the CDP command Browser.close from a browser.newBrowserCDPSession(). XDLogin sees Chrome exit and marks the profile idle.
Setting your own user agent
Don’t pass userAgent, viewport, deviceScaleFactor, locale or timezoneId, and don’t add an Accept-Language header. The profile already sets these, and changing one makes it disagree with the rest of its fingerprint.
Letting Playwright change settings
On attach, Playwright applies its own download, focus and media settings (such as the color scheme) to the default context. Since Playwright 1.60, connectOverCDP(endpoint, { noDefaults: true }) leaves them as the profile has them.
Hard-coding the port
If 31337 is taken when you press Start API, XDLogin takes the next free port up to 31340 and shows it in Settings. Read the base URL from an environment variable.
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.
- Playwright is its own layer. What it adds to a page, such as functions from
exposeFunctionor scripts fromaddInitScript, is visible to that page. XDLogin doesn’t hide it. - 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.