Puppeteer Proxy: Auth, SOCKS5 and Browser Signals
Diagnose Puppeteer proxy authentication and SOCKS5 failures, then compare two authorized exits against the same profile, timezone, language and canvas output.
BotBrowser Team
Want the structured docs for Getting Started?
This article lives in the editorial library. For step-by-step setup, reference material, and ongoing updates, jump into the docs section.
A Puppeteer proxy can change the observed exit address while the browser keeps its timezone, language and canvas output. First separate a failed connection from an environment mismatch: the repair belongs to a different layer. BotBrowser can configure proxy credentials and explicit timezone, locale and language inputs alongside a profile, but it cannot make an upstream provider accept credentials or guarantee a destination's interpretation of those signals.
TL;DR
- Configure a supported Puppeteer or Chromium proxy input, then verify the request actually took that route.
launch({ proxy: ... })is not Puppeteer's documented proxy configuration. page.authenticate()supplies HTTP credentials. It does not add SOCKS5 username/password negotiation to stock Chrome.- An HTTPS target through an HTTP proxy and a TLS connection to an HTTPS proxy are different arrangements. Keep the proxy scheme separate from the destination URL.
- Compare browser values only after an owned observer confirms two different public exits. A changed IP does not imply changed timezone, language, canvas or cookies.
Contents
- Classify the failure before changing credentials
- Reproduce HTTP and SOCKS5 boundaries locally
- Keep HTTPS, DNS and authentication boundaries separate
- Compare two exits with one immutable profile input
- Read the signal comparison before changing the profile
- Practical conclusion
Classify the failure before changing credentials
Preserve the first failing operation: browser startup, proxy connection, HTTP challenge, destination TLS, HTTP response or page-side comparison. A 407 Proxy Authentication Required response concerns the proxy. A destination's 401 concerns that origin. A navigation error without an HTTP response may have occurred before either application could answer. Increasing a page timeout cannot supply an unsupported authentication method.
| Observation | First check | Evidence needed before moving on |
|---|---|---|
| Browser starts, but the observer reports the direct route | Effective proxy scope and direct-route exclusions | The intended proxy receives this request and the observer reports its exit |
| HTTP proxy challenges repeatedly | Credentials, challenge scheme and page.authenticate() timing | Proxy accepts credentials and forwards the intended request |
| SOCKS5 endpoint works in another client but not Chrome | Negotiated SOCKS authentication methods | A supported client/server handshake, not just an open TCP port |
| CONNECT succeeds, then navigation fails | Destination TLS and tunnel forwarding | A valid destination connection and an actual HTTP response |
| Exit changes, but timezone or language does not | Browser environment settings | The measured value and its expected source, independently of the IP |
| Canvas output is identical after rotation | Profile, renderer and drawing inputs | A controlled repeat of the same drawing; no identity verdict follows |
The Puppeteer context options name proxyServer and proxyBypassList. Chromium can also receive a launch argument such as --proxy-server=http://127.0.0.1:8080. These are configuration surfaces with different scopes; neither is Puppeteer's nonexistent top-level proxy launch option. Confirm the API supported by the installed version instead of copying a Playwright launch object into Puppeteer.
Set HTTP credentials before the protected navigation. Puppeteer's page.authenticate() documentation also states that it enables request interception internally, which can affect performance. Do not investigate that timing cost by disabling authentication halfway through an otherwise uncontrolled comparison. Avoid a blanket Proxy-Authorization extra header: proxy credentials and origin request headers have different recipients.
For the product-specific flag and geographic override contract, use BotBrowser proxy configuration. The distinction here is the failure path in a stock Puppeteer browser: an accepted route, an accepted credential and a page-visible environment value are three observations, not one successful configuration event.
Reproduce HTTP and SOCKS5 boundaries locally
A local fixture should give authentication a visible consequence. The HTTP proxy records whether it received a request, whether the credential matched and whether it forwarded to the owned target. The target returns a case-specific marker. A failed login or handshake must not accidentally produce that marker through a direct connection.
Use separate HTTP cases for correct, absent and wrong credentials. The successful case needs both an accepted proxy request and the target marker. The rejected cases need a proxy challenge and no forwarded target response. A browser error string alone is insufficient: the same navigation failure can result from a closed port, bad TLS or an unsupported proxy method.
For SOCKS5, compare a server that accepts the no-authentication method with one that requires username/password authentication. Repeat the latter with page.authenticate() enabled. The proxy's greeting log distinguishes an unsupported method from an application that simply returned an empty page. Keep the proxy and target on loopback, and account explicitly for Chromium's implicit local-address exclusion when testing whether traffic reached the proxy.
Do not infer a public exit from these tests. Two local HTTP listeners can forward through the same network interface. Two successful SOCKS greetings prove that two listeners answered; they do not prove different public addresses or different providers. The public-route comparison below has a separate observer assertion.
The following HTTP-only reproduction creates its own loopback origin and password-protected proxy. Save it as local-auth.mjs, install [email protected], and run CHROME=/absolute/path/to/chrome node local-auth.mjs. The .test destination is forwarded locally; the proxy rejects every other destination. The three cases run concurrently, and a rejected case must leave the origin untouched.
import assert from 'node:assert/strict';
import http from 'node:http';
import { randomBytes } from 'node:crypto';
import puppeteer from 'puppeteer-core';
const executablePath = process.env.CHROME;
assert.ok(executablePath, 'Set CHROME to the browser executable');
const flags = [
'--password-store=basic',
'--use-mock-keychain',
'--disable-background-networking',
'--disable-component-update',
'--disable-sync',
'--no-first-run',
'--no-default-browser-check',
];
async function listen(server) {
const sockets = new Set();
server.on('connection', socket => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
socket.on('error', () => {});
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
return {
port: server.address().port,
async close() {
for (const socket of sockets) socket.destroy();
await new Promise(resolve => server.close(resolve));
},
};
}
async function runCase(name, password) {
let origin, proxy, browser;
const marker = randomBytes(12).toString('hex');
const counts = { proxyRequests: 0, rejected: 0, accepted: 0, forwarded: 0, originHits: 0 };
try {
origin = await listen(
http.createServer((req, res) => {
counts.originHits++;
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`<p>${marker}</p>`);
})
);
const targetURL = `http://fixture.test:${origin.port}/`;
const authorization = `Basic ${Buffer.from('local-user:local-password').toString('base64')}`;
const server = http.createServer((req, res) => {
if (req.url !== targetURL) {
res.writeHead(403).end();
return;
}
counts.proxyRequests++;
if (req.headers['proxy-authorization'] !== authorization) {
counts.rejected++;
res.writeHead(407, { 'Proxy-Authenticate': 'Basic realm="local-test"' }).end();
return;
}
counts.accepted++;
counts.forwarded++;
const upstream = http.request(
{ host: '127.0.0.1', port: origin.port, path: '/', headers: { Host: 'fixture.test' } },
remote => {
res.writeHead(remote.statusCode, remote.headers);
remote.pipe(res);
}
);
upstream.on('error', () => {
if (!res.headersSent) res.writeHead(502);
res.end();
});
req.pipe(upstream);
});
server.on('connect', (req, socket) => socket.end('HTTP/1.1 403 Forbidden\r\n\r\n'));
proxy = await listen(server);
browser = await puppeteer.launch({
executablePath,
headless: true,
args: [...flags, `--proxy-server=http://127.0.0.1:${proxy.port}`],
});
const browserVersion = await browser.version();
const page = await browser.newPage();
if (password !== null) await page.authenticate({ username: 'local-user', password });
await page.setRequestInterception(true);
page.on('request', request => void (request.url() === targetURL ? request.continue() : request.abort()));
let status = null,
networkError = null;
try {
status = (
await page.goto(targetURL, {
waitUntil: 'domcontentloaded',
timeout: 12000,
})
).status();
} catch (error) {
networkError = String(error.message).match(/net::ERR_[A-Z_]+/)?.[0] || error.name;
}
const markerObserved = (await page.content()).includes(marker);
const success = name === 'correct';
assert.equal(markerObserved, success);
assert.equal(counts.originHits, success ? 1 : 0);
assert.equal(counts.forwarded, success ? 1 : 0);
assert.equal(counts.accepted, success ? 1 : 0);
assert.ok(counts.proxyRequests > 0 && counts.rejected > 0);
if (success) assert.equal(status, 200);
return { case: name, browserVersion, status, networkError, markerObserved, ...counts };
} finally {
if (browser) await browser.close();
if (proxy) await proxy.close();
if (origin) await origin.close();
}
}
try {
const cases = await Promise.allSettled([
runCase('correct', 'local-password'),
runCase('wrong', 'incorrect'),
runCase('absent', null),
]);
assert.ok(
cases.every(result => result.status === 'fulfilled'),
'A local assertion failed'
);
console.log(JSON.stringify({ passed: true, cases: cases.map(result => result.value) }, null, 2));
} catch (error) {
console.error(JSON.stringify({ passed: false, error: error.name }));
process.exitCode = 1;
}
This script isolates HTTP Basic authentication. The SOCKS5 method boundary still requires a SOCKS greeting capture: HTTP success is not a SOCKS password test.
Keep HTTPS, DNS and authentication boundaries separate
Chromium's proxy protocol documentation distinguishes the transport to a proxy from the requested destination scheme. An HTTP proxy can carry an HTTPS destination through CONNECT. An HTTPS proxy additionally protects the browser-to-proxy connection with TLS. Merely changing the destination URL to https:// does not turn an HTTP proxy into an HTTPS proxy.
| Configuration | Browser-to-proxy path | Relevant boundary |
|---|---|---|
| HTTP proxy, HTTPS destination | HTTP proxy connection followed by a CONNECT tunnel | Proxy authentication and destination TLS remain separate |
| HTTPS proxy, HTTPS destination | TLS to the proxy, then the destination tunnel | Both the proxy connection and destination connection need valid TLS |
| Stock Chrome SOCKS5 URL request | SOCKS5 transport for TCP requests | Chrome documents proxy-side hostname resolution and no supported SOCKS5 authentication methods |
The last row is a browser implementation boundary, not a claim that the SOCKS5 protocol lacks authentication. A Python or Node SOCKS client can support username/password methods that stock Chrome does not negotiate. Changing page.authenticate() or putting a password in a Chromium proxy argument does not add that protocol implementation. Conversely, Chrome's SOCKS5 path already delegates destination hostname resolution to the proxy; copying another client's socks5h:// spelling is not a substitute for checking Chrome's documented scheme.
If an authorized SOCKS5 service requires credentials, select a client that supports that method or use an approved local adapter whose upstream client does. Record that adapter in the experiment: Chrome is then connecting to the adapter, while the adapter performs the authenticated SOCKS connection. Keep its listener private, constrain destinations and redact credentials. A successful adapter run is not evidence of native Chrome SOCKS5 password support.
DNS resolution for a proxied TCP URL also says nothing complete about WebRTC. Chromium's documented SOCKS5 URL path does not relay UDP. A page request reaching the expected exit therefore cannot certify where a later STUN or TURN operation would travel. Test those paths under their own approved network policy rather than extrapolating from CONNECT or a page's HTTP status.
Compare two exits with one immutable profile input
Hold the browser binary, launch settings, profile input, owned endpoint and request method constant, and record the same fields in both runs. Change only the authorized proxy route. The Launcher egress-verification workflow covers the corresponding route-evidence discipline in a GUI; the same variables need to remain explicit in a Puppeteer comparison.
An ordinary Chromium user-data directory is writable and process-locked. For concurrent A/B lanes, close the baseline browser first, then make byte-identical copies of that immutable snapshot into two fresh test directories. Verify the prelaunch hashes. This compares one profile input on two routes without opening the same writable directory in two processes. Two unrelated empty directories would not provide the same storage starting point.
Use an HTTPS observation endpoint on a domain the workflow owner controls. Where Cloudflare serves that domain, its documented /cdn-cgi/trace endpoint can provide a bounded network observation; another deployment can expose its own peer-address response. Validate the status, response format and expected host before trusting an address field. Keep raw addresses private and report route labels in public evidence.
Require two valid public address observations and assert that they differ. If either route fails, returns a private address or produces the same exit as the other, the result is not a two-exit comparison. A provider's country label, separate port or session name cannot replace this check. An adapter must also count and restrict its upstream requests so background browser traffic does not silently widen the test.
Collect page-visible values only after the verified response. Use the same drawing dimensions, text, font request and canvas comparison method in both lanes. Record Intl.DateTimeFormat().resolvedOptions().timeZone, the resolved Intl locale, navigator.languages and the user agent separately. Keep the original values for review; calculate equality after both lanes finish rather than assuming what should remain stable.
Save this independent comparison as two-exits.mjs beside the same installed Puppeteer version. Set CHROME, PROXY_A, PROXY_B, OBSERVER_URL and PROFILE_DIR in the environment, then run node two-exits.mjs --pool 20. The observer contract is HTTPS, HTTP 200, text/plain, and one ip= line, as used by an owned Cloudflare trace endpoint. The profile directory must belong to a fully closed browser; the lock-file check cannot substitute for closing it. HTTP proxies may carry credentials; SOCKS5 inputs must have no credentials. An authenticated SOCKS5 upstream needs a separately approved adapter.
import assert from 'node:assert/strict';
import { createHash, createHmac, randomBytes } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs/promises';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import puppeteer from 'puppeteer-core';
const sha = value => createHash('sha256').update(value).digest('hex');
const safeError = error => ({
name: error.name,
networkCode: String(error.message).match(/net::ERR_[A-Z_]+/)?.[0] || null,
});
const flags = [
'--password-store=basic',
'--use-mock-keychain',
'--disable-background-networking',
'--disable-component-update',
'--disable-sync',
'--no-first-run',
'--no-default-browser-check',
'--window-size=1280,900',
];
async function resources() {
const sample = () =>
os.cpus().reduce(
(sum, cpu) => {
sum.idle += cpu.times.idle;
sum.total += Object.values(cpu.times).reduce((a, b) => a + b, 0);
return sum;
},
{ idle: 0, total: 0 }
);
const before = sample();
await new Promise(resolve => setTimeout(resolve, 250));
const after = sample(),
total = after.total - before.total;
const unusedCpuCores = (os.cpus().length * (after.idle - before.idle)) / Math.max(1, total);
let availableMemoryBytes = os.freemem();
if (process.platform === 'darwin') {
const vm = execFileSync('vm_stat', { encoding: 'utf8' });
const pageSize = Number(vm.match(/page size of (\d+) bytes/)[1]);
availableMemoryBytes = [...vm.matchAll(/^(?:Pages free|Pages inactive|Pages speculative):\s+(\d+)\./gm)].reduce(
(sum, match) => sum + Number(match[1]) * pageSize,
0
);
} else if (process.platform === 'linux') {
availableMemoryBytes =
Number((await fs.readFile('/proc/meminfo', 'utf8')).match(/^MemAvailable:\s+(\d+)/m)[1]) * 1024;
}
return {
capturedAt: new Date().toISOString(),
cpuCount: os.cpus().length,
unusedCpuCores: Number(unusedCpuCores.toFixed(2)),
availableMemoryBytes,
};
}
async function treeHash(root) {
const entries = [];
async function walk(relative) {
const children = await fs.readdir(path.join(root, relative), { withFileTypes: true });
for (const entry of children.sort((a, b) => a.name.localeCompare(b.name))) {
const name = path.join(relative, entry.name),
file = path.join(root, name);
if (entry.isDirectory()) await walk(name);
else if (entry.isFile()) entries.push([name, sha(await fs.readFile(file))]);
else if (entry.isSymbolicLink()) entries.push([name, 'symlink', await fs.readlink(file)]);
else throw new Error('Unsupported profile entry');
}
}
await walk('');
return { sha256: sha(JSON.stringify(entries)), entries: entries.length };
}
function publicAddress(ip) {
if (net.isIP(ip) === 4) {
const value = ip.split('.').reduce((n, octet) => n * 256 + Number(octet), 0);
const excluded = [
['0.0.0.0', 8],
['10.0.0.0', 8],
['100.64.0.0', 10],
['127.0.0.0', 8],
['169.254.0.0', 16],
['172.16.0.0', 12],
['192.0.0.0', 24],
['192.0.2.0', 24],
['192.88.99.0', 24],
['192.168.0.0', 16],
['198.18.0.0', 15],
['198.51.100.0', 24],
['203.0.113.0', 24],
['224.0.0.0', 3],
];
return !excluded.some(
([base, bits]) =>
Math.floor(value / 2 ** (32 - bits)) ===
Math.floor(base.split('.').reduce((n, octet) => n * 256 + Number(octet), 0) / 2 ** (32 - bits))
);
}
if (net.isIP(ip) !== 6) return false;
const [first, second] = ip.split(':').map(part => parseInt(part || '0', 16));
return (
first >= 0x2000 &&
first <= 0x3fff &&
first !== 0x2002 &&
!(first === 0x2001 && (second < 0x200 || second === 0xdb8)) &&
!(first === 0x3fff && second < 0x1000)
);
}
async function signals(page) {
const measured = await page.evaluate(async () => {
const canvas = document.createElement('canvas');
canvas.width = 160;
canvas.height = 50;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#e5ebf3';
ctx.fillRect(0, 0, 160, 50);
ctx.fillStyle = '#17243a';
ctx.font = '16px Arial';
ctx.fillText('Proxy fixture 2026', 5, 30);
const webrtc = { candidateTypes: {}, candidateCount: 0, gatheringComplete: false, error: null };
let pc;
try {
pc = new RTCPeerConnection({ iceServers: [] });
pc.createDataChannel('owned-comparison');
await new Promise(resolve => {
const timer = setTimeout(resolve, 1800);
pc.onicecandidate = event => {
if (event.candidate) {
const type = event.candidate.type || 'unknown';
webrtc.candidateTypes[type] = (webrtc.candidateTypes[type] || 0) + 1;
webrtc.candidateCount++;
} else {
webrtc.gatheringComplete = true;
clearTimeout(timer);
resolve();
}
};
pc.createOffer()
.then(offer => pc.setLocalDescription(offer))
.catch(error => {
webrtc.error = error.name;
clearTimeout(timer);
resolve();
});
});
} catch (error) {
webrtc.error = error.name;
} finally {
pc?.close();
}
return {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
intlLocale: Intl.DateTimeFormat().resolvedOptions().locale,
languages: [...navigator.languages],
userAgent: navigator.userAgent,
canvas: canvas.toDataURL(),
webrtc,
};
});
const { canvas, ...rest } = measured;
return { ...rest, canvasSha256: sha(canvas) };
}
async function main() {
const { CHROME, PROFILE_DIR, OBSERVER_URL, PROXY_A, PROXY_B } = process.env;
assert.ok(CHROME && PROFILE_DIR && OBSERVER_URL && PROXY_A && PROXY_B, 'Missing required input');
const observer = new URL(OBSERVER_URL);
assert.ok(observer.protocol === 'https:' && !observer.username && !observer.password && !observer.hash);
const index = process.argv.indexOf('--pool');
const requested = index < 0 ? 20 : Number(process.argv[index + 1]);
assert.ok(Number.isInteger(requested) && requested >= 2, 'Two concurrent lanes need pool >= 2');
const snapshot = await resources();
const effective = Math.min(
requested,
2,
Math.floor(snapshot.unusedCpuCores),
Math.floor(snapshot.availableMemoryBytes / 1024 ** 3)
);
console.log(JSON.stringify({ resources: snapshot, pool: { requested, effective } }));
assert.equal(effective, 2, 'Insufficient resources for two concurrent lanes; no observer request sent');
const names = await fs.readdir(PROFILE_DIR);
assert.ok(!names.some(name => /^Singleton(?:Lock|Socket|Cookie)$/.test(name)), 'Close the baseline browser');
const input = await treeHash(PROFILE_DIR);
const work = await fs.mkdtemp(path.join(os.tmpdir(), 'two-exits-'));
const copies = ['A', 'B'].map(label => path.join(work, label));
await Promise.all(
copies.map(copy =>
fs.cp(PROFILE_DIR, copy, {
recursive: true,
dereference: false,
verbatimSymlinks: true,
errorOnExist: true,
force: false,
})
)
);
const prelaunch = await Promise.all(copies.map(treeHash));
for (const copy of prelaunch) assert.deepEqual(copy, input);
assert.deepEqual(await treeHash(PROFILE_DIR), input);
const ipKey = randomBytes(32);
let ready = 0,
unblock,
active = 0,
maximumActive = 0;
const gate = new Promise(resolve => {
unblock = resolve;
});
async function lane(label, proxyInput, userDataDir) {
let browser;
const startedAt = new Date().toISOString();
maximumActive = Math.max(maximumActive, ++active);
try {
const proxy = new URL(proxyInput);
assert.ok(['http:', 'socks5:'].includes(proxy.protocol));
assert.ok(proxy.hostname && (!proxy.pathname || proxy.pathname === '/') && !proxy.search && !proxy.hash);
assert.ok(proxy.protocol !== 'socks5:' || (!proxy.username && !proxy.password), 'SOCKS5 auth needs an adapter');
browser = await puppeteer.launch({
executablePath: CHROME,
userDataDir,
headless: true,
timeout: 25000,
args: [...flags, `--proxy-server=${proxy.protocol}//${proxy.host}`],
});
const browserVersion = await browser.version(),
page = await browser.newPage();
if (proxy.username || proxy.password)
await page.authenticate({
username: decodeURIComponent(proxy.username),
password: decodeURIComponent(proxy.password),
});
let observerRequests = 0;
await page.setRequestInterception(true);
page.on('request', request => {
if (request.url() === observer.href && observerRequests === 0) {
observerRequests++;
void request.continue();
} else void request.abort();
});
if (++ready === 2) unblock();
await gate;
assert.equal(ready, 2, 'Peer lane failed before navigation');
const navigationStartedAt = new Date().toISOString();
const response = await page.goto(observer.href, { waitUntil: 'domcontentloaded', timeout: 25000 });
assert.ok(response && response.status() === 200 && response.url() === observer.href);
assert.equal(response.request().redirectChain().length, 0);
assert.ok(/^text\/plain(?:;|$)/i.test(response.headers()['content-type'] || ''));
const body = await response.text();
assert.ok(body.length <= 8192);
const addresses = [...body.matchAll(/^ip=(.+)$/gm)];
assert.equal(addresses.length, 1);
const address = addresses[0][1].trim();
assert.ok(publicAddress(address), 'Non-public observer address');
const addressDigest = createHmac('sha256', ipKey).update(address).digest('hex');
const observed = await signals(page);
assert.equal(observerRequests, 1);
return {
label,
startedAt,
navigationStartedAt,
finishedAt: new Date().toISOString(),
browserVersion,
observer: { status: 200, requests: observerRequests, publicAddressValidated: true, addressDigest },
signals: observed,
};
} finally {
unblock();
if (browser) await browser.close();
active--;
}
}
const completed = await Promise.allSettled([lane('A', PROXY_A, copies[0]), lane('B', PROXY_B, copies[1])]);
assert.deepEqual(await treeHash(PROFILE_DIR), input);
if (completed.some(result => result.status === 'rejected')) {
console.log(
JSON.stringify({
passed: false,
lanes: completed.map((result, i) => ({
label: ['A', 'B'][i],
status: result.status,
...(result.status === 'rejected' ? { error: safeError(result.reason) } : { result: result.value }),
})),
})
);
process.exitCode = 1;
return;
}
const routes = completed.map(result => result.value);
assert.equal(maximumActive, 2);
assert.equal(routes[0].browserVersion, routes[1].browserVersion);
assert.notEqual(routes[0].observer.addressDigest, routes[1].observer.addressDigest, 'Both routes used one exit');
const equality = Object.fromEntries(
['timezone', 'intlLocale', 'languages', 'canvasSha256', 'userAgent'].map(key => [
key,
JSON.stringify(routes[0].signals[key]) === JSON.stringify(routes[1].signals[key]),
])
);
console.log(
JSON.stringify(
{
passed: true,
differentPublicExits: true,
maximumActive,
input,
prelaunch,
baselineUnchanged: true,
equality,
routes,
temporaryProfilesRetained: true,
},
null,
2
)
);
}
main().catch(error => {
console.error(JSON.stringify({ passed: false, error: safeError(error) }));
process.exitCode = 1;
});
The resource rule reserves one estimated idle CPU core and 1 GiB of available memory per browser. It refuses to turn the comparison into a serial run when two slots are unavailable. Profile copies remain in the operating system's temporary directory for inspection. The address check conservatively rejects special-use ranges; it is not a geolocation lookup. The per-run HMAC key stays in memory, so the printed digests support equality only within that execution.
Measured two-route comparison
On November 11, 2026, the comparison ran with Node 26.9.0, Puppeteer Core 25.10.0 and Chrome 157.0.8103.79 on macOS arm64, Darwin 24.6.0. The fresh sample recorded 2.21 estimated idle CPU cores and 12,294,160,384 available memory bytes, reducing the requested pool of 20 to two simultaneous lanes. Their navigations started one millisecond apart; script execution took 4.702 seconds. The closed profile input and both prelaunch copies contained 147 matching entries with SHA-256 4faf879627b7cff3c24aa9cded30e2f892d7772323939232061e72a036d33829, and the input remained unchanged afterward.
| Observation | Route A | Route B | Verified comparison |
|---|---|---|---|
| Owned observer address | Redacted exit A | Redacted exit B | Both public, different; one HTTP 200 response per route |
| Timezone | America/Toronto | America/Toronto | Equal |
| Intl locale | en-US | en-US | Equal |
| Languages | ["en-US", "en"] | ["en-US", "en"] | Equal |
| Canvas SHA-256 prefix | 7d6ee35f6cebcbf3 | 7d6ee35f6cebcbf3 | Full hashes equal |
| User agent browser token | HeadlessChrome/157.0.0.0 | HeadlessChrome/157.0.0.0 | Full user agents equal |
WebRTC with iceServers: [] | One host candidate | One host candidate | Gathering completed; no STUN/UDP exit test |
The final stdout included "differentPublicExits": true and "maximumActive": 2. Both authorized upstreams required SOCKS5 credentials, so the execution wrapper supplied private HTTP CONNECT adapters restricted to botbrowser.io:443; each admitted exactly one upstream connection. The adapters refused background destinations and one additional same-target CONNECT per lane. Chrome did not negotiate the upstream SOCKS5 credentials. No raw address, credential or candidate address was retained.
The recorded external run included one additional Chromium loopback-routing argument. The displayed version omits that unnecessary argument for the non-loopback observer. Its final execution stopped at the resource check: 1.28 estimated idle CPU cores yielded one slot, so neither route made an observer request. The final formatted local HTTP script passed all three cases: correct credentials returned 200 and the marker, while wrong and absent credentials produced zero forwarded requests and zero origin hits. The table records the earlier successful external measurement; the final launch-argument deletion has no completed external measurement.
WebRTC observation has its own scope
A local RTCPeerConnection with iceServers: [] can record the host candidates exposed in that run without contacting a third-party STUN service. Record candidate types and counts, not private addresses or complete SDP. This does not exercise a public STUN or TURN path, prove that UDP follows the HTTP proxy, or establish that a public WebRTC address cannot appear under another configuration. An empty or mDNS-obscured host-candidate result is not a universal “no leak” verdict.
Read the signal comparison before changing the profile
The useful result is a per-signal comparison beside the independently checked egress result. Timezone and Intl locale normally have browser or operating-system inputs; a new proxy does not by itself rewrite those inputs. Canvas output additionally depends on the requested drawing, fonts and rendering environment. Cookies and other storage belong to the profile snapshot and subsequent application activity. None should be treated as an automatic derivative of a proxy password or country label.
If the exit changed but navigator.languages did not, ask whether the workflow actually requires another language. Multilingual users and travelers exist; geographic disagreement alone is not an error or a remote detection result. For a controlled regional test, declare the expected locale and timezone explicitly, change that setting as a separate variable and repeat. Otherwise an IP rotation plus several environment changes leaves no evidence about which input caused a different application result.
Identical canvas hashes are narrower evidence than “the site knows this is the same machine.” Two profiles can produce identical pixels, and one environment can change output after a font or renderer update. A changed canvas hash is not proof of a new identity either. Preserve the browser build and drawing inputs so the observation can be reproduced without turning one hash into an unsupported attribution claim.
BotBrowser's documented proxy configuration accepts credentials through --proxy-server; explicit timezone, locale and language overrides remain independent inputs. Those controls can support a declared environment when that is the test requirement. They cannot correct a provider's password, certify its country assignment or promise how a destination associates sessions. The stock Chrome fixture does not test BotBrowser, a destination's risk model or any account-authorization outcome.
Choose the repair from the first failing layer. Correct an invalid proxy option before testing authentication; correct an HTTP challenge before comparing egress; require two actual exits before interpreting A/B signals. When the route works and only an environment expectation differs, change the declared environment rather than repeatedly rotating credentials. Keep unrelated browser and provider changes outside that reproduction.
Frequently asked questions
Does Puppeteer accept launch({ proxy })?
That is not the documented Puppeteer launch surface. Check the installed version's BrowserContextOptions.proxyServer, or pass Chromium's documented proxy launch argument. A browser starting successfully does not prove that an unknown JavaScript option configured its network route.
Why does page.authenticate() not fix my SOCKS5 password?
It supplies HTTP credentials. Chromium documents no supported authentication methods for its native SOCKS5 path. Confirm the proxy's required method, then use a supported client or an approved adapter rather than adding HTTP headers to a SOCKS handshake.
Does a Puppeteer HTTPS proxy need a different target URL?
The proxy scheme describes the connection to the proxy; the target URL describes the destination. An HTTP proxy can tunnel an HTTPS target. An HTTPS proxy adds TLS on the browser-to-proxy leg, which needs its own valid certificate and support.
Does changing the proxy change my browser fingerprint?
It can change the observed network exit. Whether timezone, language, canvas, storage or other signals change requires separate measurement. Neither a matching hash nor a different IP establishes a destination's identity decision.
Sources
- Puppeteer: BrowserContextOptions
- Puppeteer: Page.authenticate
- Chromium: proxy schemes, authentication, DNS and direct-route exclusions
- Cloudflare: managed /cdn-cgi/ endpoints
- W3C: WebRTC peer connection and ICE configuration
- BotBrowser: proxy configuration
- BotBrowser: proxy geolocation alignment
Practical conclusion
Treat Puppeteer proxy authentication, the observer's exit address and browser-side settings as separate tests. A supported handshake and an actual target response establish more than a browser startup; two validated public exits establish more than two configured proxy names. Compare unchanged profile inputs and measured signals only after those checks pass. Apply an explicit environment change when the workflow requires it, then verify its effect without inferring anonymity or a site's verdict.
This article is part of the guide: Browser Automation Getting Started: A First Run Map
Related Articles
Playwright Proxy: Routing, Auth and Unchanged Signals
NetworkBrowser Proxy Configuration: SOCKS5, HTTP, and HTTPS Guide
NetworkBrowser Proxy Authentication and Credential Hygiene
NetworkProxy, DNS, and WebRTC Consistency for a Coherent Browser Identity
NetworkVerify Proxy Egress IP in BotBrowser Launcher
IdentityTimezone, Locale, and Language in Browser APIs
Run the same check in BotBrowser
Load a profile, keep the signals you just read about consistent inside the browser, and confirm the result on your own machine before a rollout.