Run Selenium and ChromeDriver through an authenticated proxy, without an extension. Chrome will not send proxy credentials itself and, since Chrome 142, will not load an extension that sends them either — so this puts a tiny proxy on localhost that attaches your BotProxy credentials on the way out. Same program in Go, Node and Python: pick whichever matches your stack, they behave identically, and none of them has a dependency beyond its standard library.
Selenium / Playwright / curl ──▶ 127.0.0.1:8888 ──▶ x.botproxy.net:8080 ──▶ target
(no credentials) (adds them)
BotProxy is a rotating proxy service for collecting
public data at scale. One endpoint, x.botproxy.net:8080, in front of a pool of
datacenter exits across the US, EU, Asia and Australia — you ask for a country
or a city in the proxy username, and requests leave from a different IP each
time unless you pin one with a session id. Residential exits are on the same
account and the same bill for the targets that need them, and Anti-Detect mode
gives requests a real browser's TLS and HTTP/2 fingerprint. All locations are
included on every plan; you pay for traffic, not per request.
An active subscription is what makes the credentials in this README work — botproxy.com/pricing. If you drive Scrapy rather than a browser, scrapy-botproxy does this job as a downloader middleware, and if you would rather not run a browser at all, the Browser API rents you cloud Chrome workers that are already behind the proxy.
Chrome will not take proxy credentials on the command line — --proxy-server=http://user:pass@host:port
silently drops the user:pass part. The workaround everyone used was to
generate a small extension that answered the auth challenge from
chrome.webRequest.onAuthRequired. Chrome 142 removed the --load-extension
switch, along with the --disable-features=DisableLoadExtensionCommandLineSwitch
escape hatch that had kept it alive, so that workaround is finished on
Chrome-branded builds.
A local hop sidesteps the whole problem. Chrome talks to 127.0.0.1 with no
authentication, which it is perfectly happy to do, and the hop attaches the
Proxy-Authorization header upstream. Nothing in this arrangement depends on a
browser feature, so the next Chrome release cannot take it away either.
If the machine running the browser has a static IP, add it to your proxy user's IP whitelist in the dashboard and skip the hop entirely:
options.add_argument("--proxy-server=http://x.botproxy.net:8080")No credentials means no auth challenge, nothing to intercept, and one less moving part. The trade-off is that IP-whitelisted requests carry no username, and the username is where BotProxy reads per-request instructions from — so you lose the country, location, residential and session selectors described below. Use the hop when you need those, or when the client IP moves (laptops, CI runners, containers with dynamic egress).
Pick one. All three read the same config and take the same flags.
Go — a single static binary, nothing to install:
cd go && go build -o proxy-hop .
./proxy-hop --user pxu1000-0 --pass your-passwordNode — 16+, no npm install needed:
node node/proxy-hop.js --user pxu1000-0 --pass your-passwordPython — 3.8+, standard library only:
python3 python/proxy_hop.py --user pxu1000-0 --pass your-passwordEach prints what it is doing and then waits:
listening on 127.0.0.1:8888
upstream x.botproxy.net:8080 as pxu1000-0
Point anything at http://127.0.0.1:8888 and it goes out through BotProxy:
curl -x http://127.0.0.1:8888 https://httpbin.org/ipCredentials are the only thing you have to supply; the endpoint is already set
to x.botproxy.net:8080. Copy proxyhop.example.json to proxyhop.json and
fill in the two fields:
{
"username": "pxu1000-0",
"password": "your-proxy-user-password"
}The file is read from the working directory (or --config path.json).
Environment variables override it, and flags override those, so one config file
can serve several runs that differ in one field.
| Config key | Flag | Environment | Default |
|---|---|---|---|
username |
--user |
BOTPROXY_USERNAME |
— (required) |
password |
--pass |
BOTPROXY_PASSWORD |
— (required) |
listen |
--listen |
127.0.0.1:8888 |
|
upstream |
--upstream |
x.botproxy.net:8080 |
|
country |
--country |
none | |
location |
--location |
none | |
residential |
--residential |
off | |
session |
--session |
none | |
verbose |
-v |
off |
proxyhop.json is in .gitignore — it holds a password, and this is a public
repo you may well have forked.
BotProxy takes its instructions from the proxy username rather than from headers. The hop assembles that username for you, so you set intent rather than syntax:
| Setting | Effect |
|---|---|
--country US |
any exit in that country |
--location us-ny |
a specific location |
--residential true |
residential exit, any region |
--residential US |
residential exit in a country |
--session batch-17 |
keep the same exit IP across requests |
If several are given, residential wins over location, and location wins over
country. --country RS means residential, not Serbia — for a Serbian
datacenter exit ask for --location rs-bg.
The same session id keeps the same exit IP. Sessions expire after your proxy user's max session age (60 seconds by default, configurable in the dashboard) and after 5 minutes of inactivity regardless.
Each hop is one set of selectors. If you need two exit countries at once, run two hops on two ports — they are a few hundred KB of RAM each.
Selenium (Python) — see examples/selenium_python.py:
options = webdriver.ChromeOptions()
options.add_argument("--proxy-server=http://127.0.0.1:8888")
driver = webdriver.Chrome(options=options)Selenium (Node) — see examples/selenium_node.js:
const options = new chrome.Options().addArguments('--proxy-server=http://127.0.0.1:8888');Playwright:
browser = playwright.chromium.launch(proxy={"server": "http://127.0.0.1:8888"})Puppeteer:
const browser = await puppeteer.launch({ args: ['--proxy-server=http://127.0.0.1:8888'] });Anything else — HTTP_PROXY=http://127.0.0.1:8888 HTTPS_PROXY=http://127.0.0.1:8888.
A refusal is passed through with its own status code rather than flattened into a generic 502, and the hop logs what the code means:
| Code | Meaning |
|---|---|
| 401 | bad proxy credentials |
| 402 | subscription suspended, or residential balance exhausted |
| 403 | proxy user auto-disabled by the success-rate guard |
| 407 | credentials missing or malformed |
| 555 | blacklisted site or port, or a free-trial restriction |
| 556 | blocked by one of your own request filters |
- Listens on localhost, without authentication. Anyone who can open a
socket on your machine can spend your traffic. Keep the default bind address
unless you know why you are changing it; if you do move it off
127.0.0.1, put it behind something that authenticates. - HTTPS is a splice. The credentials go out once, in the
CONNECT; everything after that is opaque bytes copied in both directions. The hop never sees inside your TLS and never terminates it, so certificate pinning, HTTP/2 and websockets pass through untouched. - Plain
http://requests are rewritten, which is what an HTTP proxy does. The Go and Node hops pool upstream connections for these; the Python hop opens one per request, which is simpler and costs nothing in practice because browsers send everything overCONNECT. - No retries, no caching, no rewriting. BotProxy already retries across its own nodes. This is a credential-attaching relay and deliberately nothing else.
python3 test/smoke.py # all three implementations
python3 test/smoke.py python go # a subsetThe suite runs each hop against a fake upstream proxy that demands Basic auth,
drives it the way a browser does — an ordinary http:// request and a CONNECT
tunnel — checks that a rejected credential arrives as the upstream's own status
code, and asserts that the upstream saw the exact username the selectors should
have produced.
MIT. You will need an active BotProxy subscription for the proxy itself — botproxy.com.