Troubleshooting11 min read

Fix proxy error 407 without guessing

Diagnose Proxy Authentication Required in a repeatable order: gateway, authentication method, credential encoding, account scope and client configuration.

On this page

A 407 points to the proxy authentication layer. Start there before changing your destination request or adding retries. This guide gives the order of checks, the exact symptom in curl, Python requests, httpx, aiohttp and urllib3, Playwright and Puppeteer, and one working sample per client. Every error string and sample below was recorded against a small loopback proxy that demands Basic authentication; the lab and the recorded run are linked at the end.

Identify who is asking for credentials

HTTP 407 means the proxy requires authentication. The response includes a Proxy-Authenticate challenge, and the client supplies proxy credentials using Proxy-Authorization, as defined in RFC 9110. Destination authentication is separate: the website's own login uses 401 and Authorization. Putting your proxy password in the destination's Authorization header, or in the website-authentication option of your client, is the wrong fix; each client section below names that option so you can rule it out.

Where the 407 appears depends on the destination. For an http:// destination the proxy forwards the request itself, so a 407 arrives as an ordinary response: status code 407, a Proxy-Authenticate header, sometimes a short body. For an https:// destination the client first sends CONNECT host:443 to open a tunnel. A 407 at that step happens before any exchange with the destination, so most clients raise a proxy or tunnel exception instead of handing you a response object. curl 8.15, 8.16 and 8.17, for example, print curl: (56) CONNECT tunnel failed, response 407; curl 8.18.0 and 8.22.0 printed the same message with exit code 7 in our runs. Same proxy, same credentials, different symptoms:

Clienthttps:// destination (CONNECT)http:// destination
requests 2.34requests.exceptions.ProxyError with Tunnel connection failed: 407 Proxy Authentication RequiredResponse with status_code == 407
httpx 0.28httpx.ProxyError: 407 Proxy Authentication RequiredResponse with status_code == 407
aiohttp 3.14aiohttp.ClientHttpProxyError: 407, message='Proxy Authentication Required'response with status == 407
urllib3 2.8urllib3.exceptions.ProxyError with Tunnel connection failed: 407response with status == 407
Playwright 1.63with the default waitUntil: "load", page.goto rejects with Timeout 30000ms exceeded and requestfailed reports net::ERR_TUNNEL_CONNECTION_FAILED; with waitUntil: "commit" it resolves with status() === 407page.goto resolves with status 407 and the proxy's error body
Puppeteer 25page.goto rejects with net::ERR_INVALID_AUTH_CREDENTIALS (no credentials) or resolves with status 407 plus net::ERR_HTTP_RESPONSE_CODE_FAILURE (wrong credentials)rejects with net::ERR_INVALID_AUTH_CREDENTIALS
curl 8.15 to 8.17exit 56, curl: (56) CONNECT tunnel failed, response 407, %{http_connect} is 407http=407
curl 8.18.0exit 7, curl: (7) CONNECT tunnel failed, response 407, %{http_connect} is 407http=407

If the code you see is 429, 502 or 504 rather than 407, proxy errors explained identifies which layer produced it. When Chromium reports the CONNECT failure without a status, the tunnel error guide covers the rest of its net::ERR_* family. Scrapy is not in the table: Scrapy 2.19 raises TunnelError: Could not open CONNECT tunnel with proxy for an https:// URL and drops the 407 on an http:// URL with one INFO log line; Scrapy proxies in 2.19 covers both. curl_cffi is not in the table either: 0.16.3 raises ConnectionError with curl: (7) CONNECT tunnel failed, response 407 for an https:// URL, not ProxyError, and returns the 407 as an ordinary response for an http:// URL; curl_cffi proxies shows how to catch both.

Check one variable at a time

Use this order to produce a useful reproduction rather than a sequence of unrelated changes. Inherited settings are the usual surprise; proxy environment variables explains how to see which gateway a client really chose.

  • Gateway: copy the exact scheme, hostname and port from the account's current setup instructions.
  • Method: confirm whether this endpoint expects a username/password, an allowed source IP or another documented authentication mechanism. A password sent to a port that authenticates by source IP is answered with 407 from a new machine.
  • Credential scope: confirm that the username belongs to this product or subuser, not merely the dashboard login.
  • Encoding: percent-encode credentials when embedding them in a URL; use separate credential fields when the client supports them.
  • Client: check inherited proxy settings and verify that the intended gateway is actually selected. A stale HTTPS_PROXY, a .curlrc, a container environment or a CI secret can point at a different gateway or an old password. Log the host and port the client resolved, never the password.

Start with one curl request

Before touching application code, prove that the gateway, username and password work at all. Proxy credentials are separate from destination credentials: -U or --proxy-user sends them to the proxy; -u or --user sends them to the destination. With the wrong flag the proxy still sees no Proxy-Authorization and answers 407, even though the password is right.

sh
curl --disable -sS -o /dev/null --noproxy '' \
  -x "$PROXY_URL" --proxy-user "$PROXY_USERNAME:$PROXY_PASSWORD" \
  -w 'http=%{http_code} connect=%{http_connect} exit=%{exitcode}\n' \
  https://example.com/

connect=200 http=200 means the credentials are accepted and the problem is in your application. connect=407 with CONNECT tunnel failed, response 407 means the account itself is not being accepted; check the gateway, method and scope above, then send the report described at the end. The curl proxy guide has the complete flag reference.

Compare curl with the failing application

If the curl request works, concentrate on how the application maps those settings. In Playwright, for example, proxy credentials belong in proxy.username and proxy.password rather than httpCredentials. The client sections below show the exact exception or status each client produces and a working sample for each.

If both clients fail, remove optional provider-specific country or session modifiers and test the simplest documented credential format, then add one modifier at a time. Confirm account access and the endpoint's authentication instructions with the provider. Do not assume that every provider uses 407 for the same billing or product-access condition.

Python requests: ProxyError, Tunnel connection failed: 407

Requests takes proxy credentials only from the proxy URL. There is no separate proxy-auth argument that survives the CONNECT step: requests.auth.HTTPProxyAuth adds a Proxy-Authorization header to the request, which works for an http:// destination and does nothing for an https:// one, because the tunnel is opened by urllib3 before that header is ever sent. The same is true for a Proxy-Authorization entry in headers=. In the recorded run both variants returned 200 for http://example.com/ and ProxyError for https://example.com/.

Three more things break the URL form:

  • Special characters. A password such as p@ss/w0rd#1 pasted straight into http://user:p@ss/w0rd#1@host:port makes the URL parser treat ss as the host. Requests then fails with NameResolutionError for host 'ss', which looks nothing like an authentication problem. Percent-encode the username and the password separately with urllib.parse.quote(value, safe="").
  • Both keys. The proxies dictionary is keyed by the destination scheme. A dictionary with only an "http" entry sends https:// requests directly, with no proxy at all. Set "http" and "https" to the same authenticated URL.
  • Environment variables win over the Session. If HTTPS_PROXY is set in the environment, Session.proxies loses to it, and a credential-free variable turns a correct Session configuration into a 407. A proxies= argument on the request itself still wins. The cleanest fix is session.trust_env = False, which also disables NO_PROXY inheritance; the environment variable guide explains what else that setting controls.

The sample below applies all three. It is the file fix_requests.py in the lab download and was run with a plain password, a special-character password and a wrong password; the first two printed 200, the third printed the ProxyError message.

python
import os
from urllib.parse import quote

import requests

# PROXY_URL is scheme://host:port with no credentials inside, for example http://gate.example.net:8080
proxy_url = os.environ["PROXY_URL"]
username = quote(os.environ["PROXY_USERNAME"], safe="")
password = quote(os.environ["PROXY_PASSWORD"], safe="")
target = os.environ.get("TARGET_URL", "https://example.com/")

scheme, host_port = proxy_url.split("://", 1)
authenticated_proxy = f"{scheme}://{username}:{password}@{host_port}"
proxies = {"http": authenticated_proxy, "https": authenticated_proxy}

session = requests.Session()
session.trust_env = False  # HTTP_PROXY, HTTPS_PROXY and NO_PROXY can no longer override these proxies

try:
    response = session.get(target, proxies=proxies, timeout=(5, 15))
    print(response.status_code)
except requests.exceptions.ProxyError as error:
    print("proxy rejected the request:", error)

The Python Requests proxy guide covers Sessions, SOCKS and rotation on top of this; the sample here is the minimum that makes a 407 go away.

Python httpx: httpx.ProxyError: 407 Proxy Authentication Required

httpx has a dedicated object for this: httpx.Proxy(url, auth=(username, password)). The credentials go into the CONNECT request and never touch the URL, so a special-character password needs no encoding. The auth= argument on httpx.get or on the Client is website authentication; in the recorded run it left the proxy challenge unanswered and raised httpx.ProxyError: 407 Proxy Authentication Required. Credentials inside the proxy URL also work when they are percent-encoded; pasted raw, the same p@ss/w0rd#1 password produced httpx.ConnectError: [Errno -3] Temporary failure in name resolution, again because the parser read the password as a hostname.

python
import os

import httpx

proxy = httpx.Proxy(
    url=os.environ["PROXY_URL"],  # scheme://host:port, no credentials inside
    auth=(os.environ["PROXY_USERNAME"], os.environ["PROXY_PASSWORD"]),
)
target = os.environ.get("TARGET_URL", "https://example.com/")

with httpx.Client(proxy=proxy, trust_env=False, timeout=15.0) as client:
    try:
        response = client.get(target)
        print(response.status_code)
    except httpx.ProxyError as error:
        print("proxy rejected the request:", error)

httpx.AsyncClient takes the same proxy= argument, and so does httpx.HTTPTransport if you route only some URL patterns through the proxy with mounts. Both were exercised in the lab and returned 200. For a fully asynchronous worker, the httpx async proxy guide builds on the same Proxy object.

Python aiohttp: ClientHttpProxyError 407

aiohttp separates the two credentials explicitly: proxy_auth=aiohttp.BasicAuth(username, password) is for the proxy, auth= is for the website. Passing the proxy login through auth= raised aiohttp.client_exceptions.ClientHttpProxyError: 407, message='Proxy Authentication Required' in the recorded run. Credentials embedded in the proxy URL are also accepted. aiohttp 3.14 deprecates proxy_auth and BasicAuth: the call below still works but prints two DeprecationWarnings. aiohttp proxies in 3.14 shows the replacement and the case where it does not authenticate.

One aiohttp-specific trap: ClientSession ignores HTTP_PROXY and HTTPS_PROXY unless you create it with trust_env=True. With the default, a session that has no proxy= argument connects directly, so a script that "worked on the other machine because the variable was set there" silently stops using the proxy rather than failing with 407.

python
import asyncio
import os

import aiohttp


async def main() -> None:
    proxy_url = os.environ["PROXY_URL"]  # scheme://host:port, no credentials inside
    proxy_auth = aiohttp.BasicAuth(os.environ["PROXY_USERNAME"], os.environ["PROXY_PASSWORD"])
    target = os.environ.get("TARGET_URL", "https://example.com/")
    timeout = aiohttp.ClientTimeout(total=15)

    async with aiohttp.ClientSession(timeout=timeout) as session:
        try:
            async with session.get(target, proxy=proxy_url, proxy_auth=proxy_auth) as response:
                print(response.status)
        except aiohttp.ClientHttpProxyError as error:
            print("proxy rejected the request:", error.status, error.message)


asyncio.run(main())

urllib3 and urllib.request

If you use urllib3 directly, ProxyManager does not read credentials from the proxy URL for the tunnel: ProxyManager("http://user:pass@host:port") still produced ProxyError with Tunnel connection failed: 407 for an https:// destination. Pass them as proxy_headers=urllib3.util.make_headers(proxy_basic_auth="user:pass") instead. The headers= argument of request() is not used for CONNECT either. The standard-library urllib.request.ProxyHandler is the opposite case: it does parse http://user:pass@host:port and answered the challenge correctly in the same run.

Playwright: Timeout exceeded, status 407 and net::ERR_TUNNEL_CONNECTION_FAILED

Playwright configures the proxy through the proxy option, either on browserType.launch or on browser.newContext, with three fields: server, username and password. When the credentials are missing or wrong, the symptom depends on what page.goto waits for. With the default waitUntil: "load", and with "domcontentloaded", no document ever loads, so the call rejects with page.goto: Timeout 30000ms exceeded after the full navigation timeout, and the only hint is a requestfailed event carrying net::ERR_TUNNEL_CONNECTION_FAILED. That is why a proxy 407 in Playwright is so often reported as "the proxy is slow". With waitUntil: "commit" the same navigation resolves in milliseconds with a response whose status() is 407, whose statusText() is Proxy Authentication Required and whose headers include the proxy's Proxy-Authenticate. The sample below waits for commit first, checks the status, and only then waits for domcontentloaded; in the recorded run the wrong-password case failed in under a second with the 407 message instead of after thirty seconds.

Two placements look right and are not:

  • Credentials inside server. { server: "http://user:pass@host:port" } is accepted without complaint, and the proxy then receives a CONNECT with no Proxy-Authorization at all. Chromium ignores the userinfo part of a proxy URL.
  • httpCredentials on the context. In the recorded run, Chromium 153 did answer the proxy challenge with httpCredentials, so the navigation succeeded. It is still the wrong place: httpCredentials is the website login, and Playwright will offer the same username and password to any origin that answers 401. Keep the proxy password in proxy.username and proxy.password.
javascript
import { chromium } from 'playwright';

const proxy = {
  server: process.env.PROXY_URL, // scheme://host:port, no credentials inside
  username: process.env.PROXY_USERNAME, // proxy credentials belong here, not in httpCredentials
  password: process.env.PROXY_PASSWORD,
};
const target = process.env.TARGET_URL ?? 'https://example.com/';

const browser = await chromium.launch();
try {
  const context = await browser.newContext({ proxy });
  const page = await context.newPage();
  page.on('requestfailed', (request) => console.error('failed:', request.url(), request.failure()?.errorText));
  // Wait for 'commit' first: a proxy 407 never reaches 'domcontentloaded', it would surface as a TimeoutError instead.
  const response = await page.goto(target, { timeout: 30_000, waitUntil: 'commit' });
  if (!response || response.status() === 407) {
    throw new Error(`proxy rejected the credentials: ${response?.status()} ${response?.statusText()}`);
  }
  await page.waitForLoadState('domcontentloaded');
  console.log(response.status());
} finally {
  await browser.close();
}

A per-context proxy needs chromium.launch({ proxy: { server: 'per-context' } }) first; the lab run confirms that a context configured this way authenticates on its own. The request.newContext API used for API tests takes the identical proxy object and returned 200 with credentials and 407 without. Special characters in the password need no encoding here because Playwright never builds a URL from them. One limit is not Playwright's: Chromium has no support for username and password on SOCKS5 proxies, as its proxy documentation states, so a socks5:// gateway that requires a login cannot be used from Chromium at all, and the HTTP vs SOCKS5 guide helps pick the HTTP gateway instead. The Playwright proxy guide shows how to keep this configuration isolated per scenario.

Puppeteer: net::ERR_INVALID_AUTH_CREDENTIALS

Puppeteer passes the proxy to Chromium as a launch flag, --proxy-server=host:port, and there is no flag for the password. Without a page.authenticate call, the first navigation rejects with net::ERR_INVALID_AUTH_CREDENTIALS; this is Chromium reporting that a proxy challenge arrived and nothing was registered to answer it. With a wrong password, page.goto resolves with a 407 status and Chromium's own error page in the document, and requestfailed reports net::ERR_HTTP_RESPONSE_CODE_FAILURE.

What does not work, from the recorded run:

  • --proxy-server=http://user:pass@host:port fails immediately with net::ERR_NO_SUPPORTED_PROXIES.
  • page.setExtraHTTPHeaders({ 'Proxy-Authorization': ... }) fails with net::ERR_INVALID_ARGUMENT; Chromium refuses to let page code set that header.

What does work: call page.authenticate({ username, password }) on the page before its first navigation. The credentials are then cached for the browser context, so a second page opened with browser.newPage() loaded without its own call, while a page in a new context from browser.createBrowserContext() failed with ERR_INVALID_AUTH_CREDENTIALS until it had one. Calling page.authenticate after a failed navigation and retrying page.goto also succeeded, which is useful when the credentials are only known later.

javascript
import puppeteer from 'puppeteer';

const target = process.env.TARGET_URL ?? 'https://example.com/';
const browser = await puppeteer.launch({
  args: [`--proxy-server=${process.env.PROXY_URL}`], // scheme://host:port, credentials are not accepted here
});
try {
  const page = await browser.newPage();
  // Register the proxy credentials before the first navigation; every new browser context needs its own call.
  await page.authenticate({ username: process.env.PROXY_USERNAME, password: process.env.PROXY_PASSWORD });
  const response = await page.goto(target, { timeout: 30_000, waitUntil: 'domcontentloaded' });
  if (!response || response.status() === 407) {
    throw new Error(`proxy rejected the credentials: ${response?.status()}`);
  }
  console.log(response.status());
} finally {
  await browser.close();
}

Node.js code that only needs HTTP, not a browser, should not launch Chromium for this; the Node.js fetch proxy guide puts the same credentials on an Undici ProxyAgent.

Send a reproducible, sanitized report

Keep the client version, UTC timestamp, gateway host and port, destination hostname, authentication method and status or exception class. State whether the minimal curl reproduction also fails. Those details let support find the attempt without receiving your password.

Remove tokens, credential-bearing URLs, cookies and authorization headers from logs. Stop automatic retries while diagnosing a persistent 407: a retry loop turns one 407 into a burst of failed logins, and one lost response, two created jobs explains why blind retries hurt for other reasons too. Once corrected, rerun one request and then the smallest application scenario before restoring normal concurrency.

For help inside an MCP-compatible coding agent, Proxy Toolkit MCP offers error diagnostics and supported configuration templates. Describe the client and sanitized error without sending proxy credentials or full logs; verify any suggested change with the bounded reproduction above.

  • Identify proxy authentication separately from website authentication.
  • Reproduce with the same settings in a second client.
  • Share sanitized diagnostics instead of credentials or raw traces.

Reproduce the recorded run

The client symptoms and samples above were recorded with the lab in the download folder: a fake proxy on 127.0.0.1 that demands Basic authentication and forwards to https://example.com/, the symptoms.py script that produces every Python error string, the Playwright, Puppeteer and curl cases, and the five fix_* samples run with a normal password, a special-character password and a wrong password. The recorded results.json names the versions used: Python 3.14, requests 2.34, httpx 0.28, aiohttp 3.14, urllib3 2.8, Node.js 24, Playwright 1.63 with Chromium 153, Puppeteer 25 and curl 8.18. The curl 8.15 to 8.17 and 8.22.0 messages come from the loopback lab published with CONNECT tunnel failed, response 403. Newer releases can change an error string; the mechanism, proxy credentials in the proxy option and nothing else, does not.

Sources & further reading

Technical references used for this guide. Check the documentation for your installed version and your provider’s supported configuration.