Puppeteer with antidetect browser profiles
Connect Puppeteer 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 call puppeteer.connect(). 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 Puppeteer
puppeteer.connect({ browserWSEndpoint: wsEndpoint, defaultViewport: null }). Pages you open withbrowser.newPage()belong to the profile, with its 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, prints what the page sees, closes the tab and disconnects, leaving the profile open. --stop stops the profile afterwards.
Show the full script (90 lines)
// Launch one XDLogin profile, attach Puppeteer, look at a page, then detach.
//
// XDLOGIN_PROFILE_ID=<id> node node/puppeteer-launch.mjs [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 puppeteer from 'puppeteer-core';
import { setTimeout as sleep } from 'node:timers/promises';
const BASE = (process.env.XDLOGIN_BASE_URL || 'http://127.0.0.1:31337').replace(/\/+$/, '');
const KEY = process.env.XDLOGIN_API_KEY;
const PROFILE = (process.env.XDLOGIN_PROFILE_ID || '').trim();
const args = process.argv.slice(2);
const STOP = args.includes('--stop');
const TARGET = args.find((a) => !a.startsWith('--')) || 'https://example.com';
async function api(method, path) {
let res;
try {
res = await fetch(BASE + path, { method, headers: KEY ? { Authorization: `Bearer ${KEY}` } : {} });
} catch (err) {
throw new Error(`Can't reach the XDLogin API at ${BASE} (${err.cause?.code ?? err.message}). ` +
'In XDLogin: Settings > API & Auto > Start API.');
}
if (res.status === 401) {
throw new Error('401 Unauthorized: set XDLOGIN_API_KEY to the key in Settings > API & Auto, or unset it if no key is set.');
}
const json = await res.json().catch(() => ({}));
return { code: res.status, ...json };
}
async function launch(id) {
const r = await api('POST', `/api/profiles/${encodeURIComponent(id)}/launch`);
if (r.code !== 200) throw new Error(`Launch failed: ${r.error ?? `HTTP ${r.code}`}`);
}
// /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, so stop
// waiting after a while and show the reason.
async function connectInfo(id, timeoutMs = 30_000) {
const deadline = Date.now() + timeoutMs;
for (;;) {
const r = await api('GET', `/api/profiles/${encodeURIComponent(id)}/connect`);
if (r.code === 200) return r.data;
if (r.code !== 409 || Date.now() > deadline) {
throw new Error(`No DevTools endpoint: ${r.error ?? `HTTP ${r.code}`}`);
}
await sleep(500);
}
}
async function main() {
if (!PROFILE) {
throw new Error(`Set XDLOGIN_PROFILE_ID to a profile id. List them with: curl ${BASE}/api/profiles`);
}
await launch(PROFILE); // brings the window forward if it is already running
const info = await connectInfo(PROFILE);
console.log(`Attached to ${info.browser} at ${info.debuggerAddress}`);
// defaultViewport: null keeps the profile's own window and screen size.
// Puppeteer's default (800x600) would override them on every page.
const browser = await puppeteer.connect({ browserWSEndpoint: info.wsEndpoint, defaultViewport: null });
try {
const page = await browser.newPage(); // a new tab in the profile, with its cookies
await page.goto(TARGET, { waitUntil: 'domcontentloaded' });
console.log('Title:', await page.title());
console.log('The page sees:', await page.evaluate(() => ({
userAgent: navigator.userAgent,
platform: navigator.platform,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
screen: `${screen.width}x${screen.height}`,
})));
await page.close();
} finally {
// disconnect() leaves the profile running; browser.close() would shut its Chrome down.
await browser.disconnect();
}
if (STOP) {
const r = await api('POST', `/api/profiles/${encodeURIComponent(PROFILE)}/stop`);
console.log(r.code === 200 ? 'Profile stopped.' : `Stop failed: ${r.error ?? `HTTP ${r.code}`}`);
}
}
main().catch((err) => {
console.error(err.message);
process.exitCode = 1;
});Install with npm i puppeteer-core. It needs Node.js 22.12 or later for version 25. The full puppeteer package downloads a browser of its own, which you don’t need here.
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, a quick check that every proxy is in place.
// Uses launch() and connectInfo() from the script above.
async function runOne(id) {
await launch(id);
const info = await connectInfo(id);
const browser = await puppeteer.connect({ browserWSEndpoint: info.wsEndpoint, defaultViewport: null });
try {
const page = await browser.newPage();
await page.goto('https://api.ipify.org/?format=json');
const { ip } = JSON.parse(await page.evaluate(() => document.body.innerText));
await page.close();
return { id, ip };
} finally {
await browser.disconnect(); // the profile keeps running
}
}
// 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 connecting
puppeteer.launch() starts a new Chrome with a temporary profile of its own: none of the profile’s fingerprint, proxy, cookies or logins. Launch through the API and attach with puppeteer.connect().
Leaving the default viewport on
puppeteer.connect() gives every page an 800×600 viewport unless you pass defaultViewport: null. On a profile that reports a laptop screen or a phone, that viewport contradicts the screen the page sees.
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 separate context
browser.createBrowserContext() makes a context that doesn’t share the profile’s cookies. browser.newPage() opens a tab in the profile’s own context, and browser.pages() lists the tabs already open.
Mixing up disconnect() and close()
browser.disconnect() leaves the profile running, so you can attach again later. browser.close() closes the profile’s Chrome, every tab included, and XDLogin marks it idle. POST /api/profiles/{id}/stop stops it through the API.
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 /stop, or finish with browser.close(), which shuts Chrome down cleanly.
Setting your own user agent
Don’t call page.setUserAgent(), page.setViewport(), page.emulate() or page.emulateTimezone(), 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.
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.
- Puppeteer is its own layer. What it adds to a page, such as functions from
page.exposeFunction()or scripts frompage.evaluateOnNewDocument(), 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.