Back to Knowledge Hub
Getting Started

Playwright Proxy: Routing, Auth and Unchanged Signals

Diagnose Playwright proxy routing, context exclusions and SOCKS5 authentication, then separate two real exit addresses from unchanged browser profile signals.

BotBrowser Team

Documentation

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 Playwright proxy can send a request through the intended exit while timezone, language and canvas stay unchanged. Check routing and browser environment separately: a working proxy does not define a new profile. BotBrowser supports declared profile, proxy, timezone, locale and language inputs, but it cannot make an upstream route accept credentials or guarantee how a destination interprets those inputs.

TL;DR

  • Prove the request's route with an owned origin or observer before changing browser settings. A successful page load alone does not establish which proxy handled it.
  • Keep launch-level defaults separate from a context's explicit proxy. Routing exclusions select a direct connection; they are not a fallback to another proxy.
  • Playwright's documented HTTP credentials do not supply native SOCKS5 password support to Chromium. Test the protocol boundary before troubleshooting a login page.
  • Compare byte-identical clones of one closed profile across two validated public exits. Stable browser values are observations, not proof of a destination's identity decision.

Contents

Start with the operation that failed

Preserve the first failed operation and its response. A failure creating a context is different from a proxy rejecting a connection, and both precede a website rejecting an account action. For playwright proxy troubleshooting, the most useful initial record is the proxy scheme, configuration scope, destination scheme and first error. Replace credentials with a label before sharing logs; an error message can contain a server address or a URL with embedded secrets.

ObservationEvidence to collectFirst controlled change
Context creation rejects SOCKS5 credentialsAPI error before navigation; zero listener hitsCheck the documented protocol support
HTTP proxy challenges with 407Proxy challenge and accepted-forward countersCheck that proxy's HTTP credentials
An origin returns 401Origin response, separate from the proxy challengeExamine that origin's authentication
The page loads through an unexpected routeLaunch/context proxy counters and origin markerCorrect configuration scope or exclusion
Both routes show the same public addressObserver responses, timestamps and provider route labelsValidate upstream routing before comparing signals
Exits differ but browser values matchProfile snapshot, binary and individual page readingsInspect the browser input relevant to the requirement

A navigation timeout is not a diagnosis. Increasing page.goto()'s timeout may help a slow authorized endpoint, but cannot add a transport authentication method or change a context's route. Likewise, a destination's 403 does not identify the failed component without more evidence. Preserve the response and stop at the authorized troubleshooting boundary instead of repeatedly changing unrelated settings against a third party.

The proxy URL and destination URL answer different questions. http://proxy.test:8080 names a connection to an HTTP proxy. An https:// destination can travel through its CONNECT tunnel without making the proxy itself an HTTPS proxy. Record both schemes. An accepted tunnel also does not certify which exit the upstream provider selected. The HTTP, HTTPS and SOCKS5 failure distinctions in Puppeteer cover that protocol vocabulary; Playwright's separate launch and context settings add the scope problem below.

Use an origin that returns an unmistakable marker, with a request budget and a counter. The origin proves that the intended response arrived; the proxy counter proves which local route forwarded it. An IP observer answers a different question, the public address visible on that request. Keeping these three records apart avoids treating a page screenshot as network evidence. Direct and forwarded requests can both render the origin's unchanged HTML.

Separate launch routes, context routes and direct exclusions

The documented API accepts a proxy at browser launch and when creating a browser context. A persistent launch also accepts proxy settings together with its userDataDir. These settings have different lifetimes. A normal browser.newContext() is isolated and does not write its browsing state to that directory. A persistent context uses disk-backed state; closing it closes its associated browser. Choosing one because a sample happens to use it can change the experiment's storage conditions.

An explicit context proxy is a new route choice, not a request to try the launch proxy first and use the context proxy if that fails. Treat the whole context configuration as a unit: server, supported credentials and exclusion list. Check each field where that context is created. A route assembled in launch() does not establish what a later newContext({ proxy }) supplied. The Playwright browser setup decisions explain browser ownership and context lifetime before the network comparison begins.

The exact member proxy.bypass names the documented comma-separated routing exclusion list. Here, exclusion means a matching request connects directly instead of through that configured proxy. It does not mean a destination grants access, and it does not define a browser identity. A request excluded from context proxy C is not evidence that launch proxy L handled it. The controlled matrix needs separate L and C counters to distinguish a direct origin hit from either forwarded path.

Route counters establish the chosen network path; immutable-profile readings establish only the page values measured.

The browser ultimately interprets its routing rules, so test a rule with the exact URL form used by the workload. Chromium distinguishes a simple hostname, a domain pattern and an IP-literal URL. Its <local> rule concerns simple hostnames without a dot; it is not a synonym for every address that eventually resolves to a local machine. A CIDR rule applies to an IP literal in the URL, not a hostname merely because DNS returns an address in that subnet. Keep host spelling and port in the failure record.

Loopback behavior deserves an explicit local case. Chromium has implicit local-address exclusions, while Playwright also constructs loopback routing rules. The 1.63.0 implementation checks explicit loopback entries before deciding whether to add its modifier. An older result cannot be transferred just because the option spelling is unchanged. Neither a general statement that localhost always connects directly nor an unmeasured assumption about scope is sufficient evidence. Check forwarding counters on 127.0.0.1, with the installed version recorded; undocumented environment variables or extra resolver flags are unnecessary.

The BotBrowser proxy configuration reference owns the product's network inputs. Those inputs do not replace Playwright's own object scope or merge rules. When changing automation libraries, translate the configuration model deliberately rather than copying a superficially similar object and assuming it retains the same routing semantics.

Reproduce the route before changing the browser

A local failure fixture removes the destination's behavior from the diagnosis. Start an owned HTTP origin on a dynamically assigned loopback port and a proxy that forwards only to that origin. Use a unique response marker and record challenges, accepted forwards and direct origin requests. The proxy should refuse every other destination. An open general-purpose forwarder is unnecessary for reproducing an HTTP credential error.

For HTTP authentication, run correct, missing and incorrect credentials as separate cases. The successful case must prove both forwarding and the origin marker. Rejected cases must prove that the origin was not reached, rather than only matching a convenient error string. Browser retries may produce more than one challenge, so assert the meaningful property, such as zero accepted forwards, instead of assuming a universal number of socket attempts. Do not treat these local HTTP results as a test of an upstream provider's authentication policy.

SOCKS5 needs its own boundary. The pinned Playwright 1.63.0 implementation rejects proxy username/password options for Chromium's SOCKS5 path. In that case, failure can occur before the SOCKS listener receives a connection. It is not a website login failure, and moving credentials into origin HTTP headers does not add the missing SOCKS negotiation. A no-auth SOCKS listener and an attempted credential-bearing configuration test distinct claims. A local adapter that authenticates to an upstream SOCKS service is another architecture and must be labeled accordingly.

Route scope also needs independent cases. Give launch proxy L and context proxy C different counters. Establish the launch default first, then create a context with an explicit C route, then test a direct exclusion on that C context. Keep the origin marker unchanged. Otherwise a changed page can conceal a changed route and leave the reader unsure which modification caused the apparent repair. The origin count alone cannot distinguish L from C, because either proxy can forward the same content.

Browser jobs should use a bounded pool, not serially drift across different machine conditions. The fixture accepts --pool and --workers, requests 20 by default and measures available CPU and memory before selecting its actual parallelism. More requested slots do not create more independent cases. If an external comparison cannot sustain two simultaneous browser lanes, stop before making its observer requests; running A now and B much later weakens the controlled comparison.

Observed local results and a runnable fixture

The controlled run used Playwright 1.63.0, stock Chrome 154.0.8037.57, Node 20.20.2 and Ubuntu 24.04.5 LTS on November 11, 2026, Toronto time. The public HTTP fixture ran six jobs concurrently: 31.88 estimated idle CPU cores and 121726328832 available memory bytes supported all six. The requested pool was 20, capped by the number of cases rather than an arbitrary serial limit. The three separate SOCKS cases also ran concurrently. This root-run, isolated owned-origin environment used Playwright's default chromiumSandbox: false; that condition is not a production deployment recommendation.

Controlled caseObserved resultCounter establishing the result
Launch HTTP, correct credentialsOrigin marker through LL forwarded 1; C forwarded 0
Launch HTTP, wrong credentialsNo origin markerL challenges 2; forwarded 0
Launch HTTP, absent credentialsNo origin markerL challenges 1; forwarded 0
Launch explicitly excludes 127.0.0.1Direct origin markerDirect origin 1; L/C forwarded 0
Context C overrides excluded launch LOrigin marker through CC forwarded 1; L forwarded 0
Context C explicitly excludes 127.0.0.1Direct origin markerDirect origin 1; L/C forwarded 0
SOCKS5 without credentialsOrigin markerRouted 1; origin hits 1
SOCKS5 credentials at launchAPI rejectionListener connections 0; origin hits 0
SOCKS5 credentials at context scopeAPI rejectionListener connections 0; origin hits 0

These are measured counts, not universal retry counts. Wrong HTTP credentials produced net::ERR_HTTP_RESPONSE_CODE_FAILURE; absent credentials produced net::ERR_INVALID_AUTH_CREDENTIALS. Both SOCKS credential cases returned Browser does not support socks5 proxy authentication. The local proxies also refused unrelated background connection attempts. Only the uniquely marked application request counts as a successful forward. The 1.63.0 loopback result agrees with its pinned implementation; an earlier 1.49.0 exploratory result differed and is not substituted for this run.

Save the following complete local-only example as playwright-local-proxy.mjs beside an installed [email protected] package. Set CHROME to a stock Chrome executable. The tested Linux host provided DISPLAY=:88; its browser ran headless. No upstream credentials or external target are needed. The script refuses unrelated proxy destinations, handles cleanup, checks CPU and memory, and exits unsuccessfully if the assertions fail or fewer than two slots are available.

CHROME=/opt/google/chrome/chrome node playwright-local-proxy.mjs --pool 20
import assert from 'node:assert/strict';
import { randomBytes } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs/promises';
import http from 'node:http';
import os from 'node:os';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';

const require = createRequire(process.env.PLAYWRIGHT_RESOLVE_FROM || import.meta.url);
const { chromium } = require('playwright');
const playwrightVersion = require('playwright/package.json').version;
const expectedVersion = process.env.EXPECTED_PLAYWRIGHT_VERSION || '1.63.0';
assert.equal(playwrightVersion, expectedVersion, `Install playwright@${expectedVersion} beside this script`);
const CHROME = process.env.CHROME;
const flags = [
  '--password-store=basic',
  '--use-mock-keychain',
  '--disable-background-networking',
  '--disable-component-update',
  '--disable-sync',
  '--no-first-run',
  '--no-default-browser-check',
];
const safeError = error => ({
  name: error.name,
  networkCode: String(error.message).match(/net::ERR_[A-Z_]+/)?.[0] || null,
});
export async function resources() {
  const sample = () =>
    os.cpus().reduce(
      (sum, cpu) => ({
        idle: sum.idle + cpu.times.idle,
        total: sum.total + Object.values(cpu.times).reduce((a, b) => a + b, 0),
      }),
      { idle: 0, total: 0 }
    );
  const before = sample();
  await new Promise(resolve => setTimeout(resolve, 250));
  const after = sample();
  const unusedCpuCores = (os.cpus().length * (after.idle - before.idle)) / Math.max(1, after.total - before.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,
  };
}
export function requestedPool(argv = process.argv.slice(2)) {
  const index = argv.findIndex(value => /^--?(pool|workers)$/i.test(value));
  const requested = index < 0 ? 20 : Number(argv[index + 1]);
  assert.ok(Number.isInteger(requested) && requested > 0, 'Pool must be a positive integer');
  return requested;
}
export function capacity(snapshot, requested, jobs) {
  const cpuSlots = Math.floor(snapshot.unusedCpuCores);
  const memorySlots = Math.floor(snapshot.availableMemoryBytes / 1024 ** 3);
  return {
    requested,
    jobs,
    effective: Math.min(requested, jobs, Math.max(0, cpuSlots), Math.max(0, memorySlots)),
    cpuSlots,
    memorySlots,
    reason: 'One estimated idle CPU core and 1 GiB per browser; at least two concurrent slots required',
  };
}
export async function pool(jobs, size) {
  let next = 0;
  const results = [];
  await Promise.all(
    Array.from({ length: size }, async () => {
      while (next < jobs.length) {
        const index = next++;
        results[index] = await jobs[index]();
      }
    })
  );
  return results;
}
export 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));
    },
  };
}
export async function httpCase(spec) {
  let origin, left, right, browser;
  const marker = randomBytes(12).toString('hex');
  const counts = { origin: { direct: 0, L: 0, C: 0 }, originOtherRequests: 0 };
  const result = { case: spec.name, startedAt: new Date().toISOString(), expectedRoute: spec.expected };
  try {
    origin = await listen(
      http.createServer((req, res) => {
        if (req.url !== '/') {
          counts.originOtherRequests++;
          res.writeHead(404).end();
          return;
        }
        const route = req.headers['x-local-route'] || 'direct';
        counts.origin[route]++;
        res.writeHead(200, { 'Content-Type': 'text/html' });
        res.end(`<p id="marker">${marker}</p><p id="route">${route}</p>`);
      })
    );
    const target = `http://127.0.0.1:${origin.port}/`;
    async function proxyServer(label) {
      const counter = (counts[label] = { requests: 0, challenges: 0, accepted: 0, forwarded: 0, denied: 0 });
      const expected = `Basic ${Buffer.from('local-user:local-password').toString('base64')}`;
      const server = http.createServer((req, res) => {
        if (req.url !== target) {
          counter.denied++;
          res.writeHead(403).end();
          return;
        }
        counter.requests++;
        if (req.headers['proxy-authorization'] !== expected) {
          counter.challenges++;
          res.writeHead(407, { 'Proxy-Authenticate': 'Basic realm="owned-local-test"' }).end();
          return;
        }
        counter.accepted++;
        counter.forwarded++;
        const upstream = http.request(
          {
            host: '127.0.0.1',
            port: origin.port,
            path: '/',
            headers: { Host: `127.0.0.1:${origin.port}`, 'X-Local-Route': label },
          },
          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) => {
        counter.denied++;
        socket.end('HTTP/1.1 403 Forbidden\r\n\r\n');
      });
      return listen(server);
    }
    left = await proxyServer('L');
    right = await proxyServer('C');
    function settings(port, excluded, credential = 'correct') {
      const proxy = { server: `http://127.0.0.1:${port}` };
      if (credential !== 'absent') {
        proxy.username = 'local-user';
        proxy.password = credential === 'wrong' ? 'incorrect' : 'local-password';
      }
      if (excluded) proxy.bypass = '127.0.0.1';
      return proxy;
    }
    browser = await chromium.launch({
      executablePath: CHROME,
      headless: true,
      proxy: settings(left.port, spec.launchExcluded, spec.credential),
      args: flags,
      timeout: 25000,
    });
    result.browserVersion = browser.version();
    const context = await browser.newContext(
      spec.contextProxy ? { proxy: settings(right.port, spec.contextExcluded) } : {}
    );
    await context.route('**/*', route => (route.request().url() === target ? route.continue() : route.abort()));
    const page = await context.newPage();
    let response = null,
      navigationError = null;
    try {
      response = await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 12000 });
    } catch (error) {
      navigationError = safeError(error);
    }
    let content = '',
      contentReadError = null;
    try {
      content = await page.content();
    } catch (error) {
      contentReadError = {
        ...safeError(error),
        pageChangingAfterFailure: /Unable to retrieve content/.test(String(error.message)),
      };
    }
    const markerObserved = content.includes(marker);
    const observedRoute = markerObserved ? await page.locator('#route').textContent() : null;
    Object.assign(result, {
      navigationStatus: response?.status() ?? null,
      navigationError,
      contentReadError,
      markerObserved,
      observedRoute,
    });
    assert.equal(observedRoute, spec.expected);
    for (const label of ['direct', 'L', 'C']) assert.equal(counts.origin[label], label === spec.expected ? 1 : 0);
    for (const label of ['L', 'C']) assert.equal(counts[label].forwarded, label === spec.expected ? 1 : 0);
    if (spec.expected) assert.equal(response.status(), 200);
    else {
      assert.ok(counts.L.challenges > 0);
      assert.equal(counts.L.accepted, 0);
    }
    return { ...result, status: 'passed', counts };
  } catch (error) {
    return { ...result, status: 'failed', error: safeError(error), counts };
  } finally {
    if (browser) await browser.close();
    if (left) await left.close();
    if (right) await right.close();
    if (origin) await origin.close();
  }
}
export const cases = [
  { name: 'launch-http-correct', expected: 'L' },
  { name: 'launch-http-wrong', credential: 'wrong', expected: null },
  { name: 'launch-http-absent', credential: 'absent', expected: null },
  { name: 'launch-loopback-exclusion', launchExcluded: true, expected: 'direct' },
  { name: 'context-overrides-launch-exclusion', launchExcluded: true, contextProxy: true, expected: 'C' },
  {
    name: 'context-loopback-exclusion-no-launch-fallback',
    contextProxy: true,
    contextExcluded: true,
    expected: 'direct',
  },
];
export async function main() {
  assert.ok(CHROME, 'Set CHROME to the browser executable');
  assert.ok(
    !process.env.PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK,
    'Use Playwright default loopback handling'
  );
  const snapshot = await resources(),
    execution = capacity(snapshot, requestedPool(), cases.length);
  if (execution.effective < 2) {
    const output = {
      passed: false,
      status: 'resource-preflight-stopped',
      playwrightVersion,
      resources: snapshot,
      execution,
      cases: [],
      externalRequests: 0,
    };
    console.log(JSON.stringify(output, null, 2));
    process.exitCode = 1;
    return output;
  }
  let active = 0,
    maximumActive = 0;
  const results = await pool(
    cases.map(spec => async () => {
      maximumActive = Math.max(maximumActive, ++active);
      try {
        return { ...(await httpCase(spec)), finishedAt: new Date().toISOString() };
      } finally {
        active--;
      }
    }),
    execution.effective
  );
  assert.ok(maximumActive >= 2, 'The local matrix must run concurrently');
  const output = {
    passed: results.every(result => result.status === 'passed'),
    playwrightVersion,
    expectedVersion,
    display: process.env.DISPLAY || null,
    sandbox: 'Playwright default chromiumSandbox:false; isolated owned-origin test',
    forcedLoopbackHandlingDisabled: false,
    resources: snapshot,
    execution,
    maximumActive,
    cases: results,
    scope:
      'Loopback HTTP authentication and observed launch/context routing in this pinned runtime; no external request',
  };
  console.log(JSON.stringify(output, null, 2));
  if (!output.passed) process.exitCode = 1;
  return output;
}
if (process.argv[1] && import.meta.url === pathToFileURL(await fs.realpath(process.argv[1])).href) {
  main().catch(error => {
    console.error(JSON.stringify({ passed: false, error: safeError(error) }));
    process.exitCode = 1;
  });
}

Compare two real exits from one closed profile

A pair of proxy ports is not a two-exit experiment. Different local adapters can still reach the same upstream public address. Before comparing browser values, require each authorized route to return a valid public address from an owned observer and require those addresses to differ. If either response is an authentication page, malformed trace, unexpected redirect or network error, the comparison is incomplete. Do not substitute a local port number or a provider's country label for that evidence.

Use one ordinary profile that has been created, cleanly closed and then held immutable. Make two independent writable clones from that source and compare their tree digests before either browser launches. The clones must include the same selected files and use the same exclusion policy for transient locks. After the comparison, hash the original source again. Matching only a profile directory name proves nothing about its content; sharing one live writable directory between two browsers also introduces locking and state races.

Hold the browser executable, operating system, launch mode, viewport and any explicit locale settings constant. Change only the authorized route between persistent contexts A and B, created with launchPersistentContext(). Launch the lanes together, navigate each once to the owned observer and read browser values in that same page. This establishes the scope of the observation: those two requests, those two cloned inputs and that environment. It does not establish every route the browser can use over its lifetime.

Cloudflare documents the per-domain /cdn-cgi/trace endpoint as a troubleshooting resource. The observer used here is on botbrowser.io, with explicit request limits; no unrelated detector, search engine or target website is needed. The full trace can contain a raw address, so compare and validate it in memory. Retain an ephemeral, run-specific keyed digest in evidence rather than publishing addresses or a reusable address lookup. A route label describes the authorized configuration, not a guarantee of physical location.

Authenticated SOCKS upstreams require careful naming. A restricted local HTTP CONNECT adapter can authenticate upstream while the browser speaks ordinary HTTP proxy protocol to that adapter. A successful request through it proves the composed path worked for the allowed destination. It does not overturn Chromium's native SOCKS5 password limitation, and should never be reported as Playwright accepting a SOCKS5 username/password pair. Record adapter tunnel counts separately from page values and observer responses.

Measured two-exit comparison

The same pinned Ubuntu environment ran both external browser lanes concurrently. Their observer navigations began at 2026-11-12T00:01:38.546Z, still November 11 in Toronto. Each made one accepted request to the owned trace endpoint, returned HTTP 200 and passed public-address validation. The two accepted addresses differed. The target-restricted adapters permitted one upstream tunnel each; extra connection attempts were rejected locally. There was no external retry. Before those requests, four local adapter-client checks covered successful authentication, rejected credentials, malformed negotiation and a disallowed target; all passed, with zero forwards for the three negative cases.

Recorded quantityRoute ARoute B
Observer HTTP status / accepted requests200 / 1200 / 1
Public-address HMAC, first 12 charactersbf448a3168c12ef6c2495efc
Closed profile entries / starting digest prefix134 / cb216f5343b0134 / cb216f5343b0
TimezoneAmerica/TorontoAmerica/Toronto
Resolved Intl localeen-USen-US
navigator.languagesen-US, enen-US, en
Canvas SHA-256, first 12 charactersa37a4010499aa37a4010499a
User agent comparisonEqualEqual
Host ICE candidates with iceServers: []1; gathering complete1; gathering complete

The full user agent in both lanes was Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36. The canvas comparison drew a 160 × 50 canvas, background #e5ebf3, text color #17243a, font 16px Arial and Proxy fixture 2026 at (5, 30), then applied SHA-256 to canvas.toDataURL(). Those exact inputs define the digest; it is not a general fingerprint score.

Both 134-entry clones matched the closed input before launch, and the original input's digest remained unchanged afterward. Equality after the validated exit change shows that these recorded page values did not automatically follow the routes in this run. It does not show that all browser state was inspected. Cookies, permissions, service workers, storage and later network activity can differ without appearing in these readings. Host-only ICE counts do not measure candidate-address equality or STUN/TURN traffic. Neither equal signals nor different exits establish a destination's account-linking decision.

Interpret each browser value on its own terms

Timezone comes from the browser environment exposed by Intl.DateTimeFormat().resolvedOptions(), not directly from the address in the trace response. A route labeled for one country can coexist with another timezone because the browser input and upstream route are separate. A legitimate test requirement might be a fixed timezone across every route, or it might require a declared regional environment. Write that requirement first; matching an IP database is not a universal correctness condition.

navigator.languages, the resolved Intl locale and an HTTP language header are related but distinct observations. Record what the page actually returns and, if headers matter, record them separately on an owned server. Do not infer all three from one locale option. A context-level setting is an experimental input: changing it alongside the proxy prevents attributing a difference solely to the route. Such an input can be useful in a later test, after the single-variable comparison is complete.

Canvas output belongs to a specified drawing, browser build and rendering environment. Hashing a fixed drawing makes that one output easy to compare; it does not define a universal browser identifier. Preserve the drawing instructions and comparison method. Two routes yielding the same digest are unsurprising when the underlying renderer and profile inputs are held fixed. Different digests need investigation of rendering inputs before anyone assigns a geographic or account-related interpretation to them.

WebRTC requires an especially narrow statement. A peer connection with iceServers: [] observes local host-candidate gathering under that configuration. Candidate types and counts can be retained without exposing addresses. This does not exercise STUN or TURN, prove that UDP follows an HTTP proxy, or establish the absence of a leak. Failure to gather a candidate is also not a general network verdict. Test any stronger requirement through a separately authorized endpoint and protocol-specific observation plan.

The user agent is one more input to record alongside the exact binary. A matching UA does not prove that ClientHello, HTTP/2 or every page API also matches. Conversely, a UA difference should not be attributed to proxy rotation without checking browser version and launch settings. Keep comparisons at the level supported by the measurement. A destination can combine signals the local page never measured; no result here certifies its account linking or risk decision.

BotBrowser's proxy and geolocation alignment reference documents supported environment inputs. It can help make a declared test configuration reproducible, but does not choose the correct regional policy for an application or validate upstream credentials. Fix an HTTP authentication failure at the proxy layer; fix a wrong context route where that context is configured; change browser values only when an explicit test requirement calls for those values. This preserves the causal value of the next comparison.

Sources

Practical conclusion

Start with one owned-origin marker and one route counter. Once that local path is understood, validate two authorized public exits from byte-matched closed-profile inputs and compare the particular browser values the requirement names. A working Playwright proxy does not automatically alter those values. The next useful change is the smallest one at the failed layer, followed by the same controlled check, rather than a new bundle of proxy, profile and rendering settings.

FAQ

Can a context inherit browser state simply because it uses the launch proxy?

Route inheritance and state persistence are separate properties. A regular newContext() creates an isolated, nonpersistent context even when it uses the launch default. A persistent launch binds one context to a disk directory. For a same-input experiment, use a closed snapshot and separate writable clones; a shared proxy address does not establish that cookies or storage were shared.

Does a direct exclusion send a failed request to the launch proxy instead?

An exclusion selects direct routing for a matching destination, rather than a fallback chain. Confirm it using independent launch-proxy, context-proxy and origin counters. If the direct route itself is unavailable, that failure does not prove either proxy was consulted. Keep destination spelling and scheme constant while checking the rule.

Does hiding addresses in the report make the measurement less useful?

Public-address validity and the A/B inequality check can be asserted before the raw values are discarded. A temporary per-run keyed digest lets the report show that the accepted responses differed without storing an address. The secret key is not published or reused, and the record cannot independently establish the physical country or later routing of either provider exit.

#Playwright#Proxy Authentication#SOCKS5#Browser Identity

This article is part of the guide: Browser Automation Getting Started: A First Run Map

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.