DLogin
Guide · Playwright

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.

How it works

Four steps from script to signed-in page

  1. 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 as Authorization: Bearer <key>. From then on the API starts with XDLogin, until you press Stop API.

  2. Launch the profile

    POST /api/profiles/{id}/launch starts 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.

  3. Read the endpoint

    GET /api/profiles/{id}/connect answers 409 until Chrome is ready, then returns wsEndpoint, httpEndpoint, debuggerAddress, port and browser. The port can change with every launch, so read it each time.

  4. Connect Playwright

    chromium.connectOverCDP(wsEndpoint), then work in browser.contexts()[0]. That context is the profile, with its cookies and logins.

Full script

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.

Create profiles

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.

Parallel runs

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.

Pitfalls

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.

Limits

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 exposeFunction or scripts from addInitScript, 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.