PHP cURL proxy with username and password

Two options look alike and only one is for the proxy. And for http:// URLs a rejected login doesn't fail at all, it comes back as a page.

6 min readTested on PHP 8.4 · libcurl 8.14 · Guzzle 8.3 and 7.16
PHP cURL with the login in CURLOPT_USERPWD fails with CONNECT tunnel failed, response 407; the same login in CURLOPT_PROXYUSERPWD returns 200.One option name apartCURLOPT_PROXY => "via…:9000"CURLOPT_USERPWD => "USER:PASS"(56) CONNECT tunnel failed, 407CURLOPT_PROXY => "via…:9000"CURLOPT_PROXYUSERPWD => "USER:PASS"200 { "country": "DE" }For an http:// URL the same 407 is not an error:curl_exec() returns the proxy's page, CURLINFO_RESPONSE_CODE is 407PHP 8.4 · libcurl 8.14 · Guzzle 8.3 and 7.16 · October 20, 2026

In short

CURLOPT_PROXY => "via.proxies.vision:9000" plus CURLOPT_PROXYUSERPWD => "user:pass". Then check two things: curl_exec() returning false, and a response code of 407, because for http:// URLs a refused login arrives as an ordinary response.

CURLOPT_PROXYUSERPWDCURLINFO_HTTP_CONNECTCODE

cURL keeps two logins apart. CURLOPT_PROXYUSERPWD is sent to the proxy, in a Proxy-Authorization header. CURLOPT_USERPWD is sent to the website, in Authorization, and only after the proxy has let you through. Put your proxy login into the second one and the proxy never sees it:

(56) … response 407CURLOPT_USERPWD => "USER:PASS"the website's login
(56) … response 407no login at allsame message
200CURLOPT_PROXYUSERPWD => "USER:PASS"the proxy's login
200CURLOPT_PROXY => "http://USER:PASS@via.proxies.vision:9000"login in the proxy URL

The login can also go into the proxy URL itself, as in the last row. Then special characters matter: a password with an @ in it broke the URL before anything was sent (Unsupported proxy syntax … Bad hostname). Encode both parts with rawurlencode(), or keep the login in CURLOPT_PROXYUSERPWD, which needs no encoding.

For an https:// URL, cURL first asks the proxy for a tunnel. If the proxy refuses, there is no response from the website, and curl_exec() returns false with error 56. For an http:// URL there is no tunnel: the proxy answers the request itself, and cURL hands you that answer like any other page. Same wrong password, two very different results:

falsecurl_exec() for https://ipinfo.ioerrno 56, CONNECT tunnel failed, response 407
a stringcurl_exec() for http://ipinfo.iono error, CURLINFO_RESPONSE_CODE 407

A scraper that saves error pages

Code that only checks for false stores the proxy’s error text as if it were the website, for every http:// URL. Check CURLINFO_RESPONSE_CODE too. For tunnels, CURLINFO_HTTP_CONNECTCODE holds the proxy’s status even when the request failed: 407, 403 or 465 on our gateway.

Every message, reproduced

PHP 8.4.26 with libcurl 8.14.1 against via.proxies.vision, https://ipinfo.io as the target.

What we seterrnocurl_error()
Wrong password, or no login56CONNECT tunnel failed, response 407
Username -country-usa56CONNECT tunnel failed, response 403
A session on the rotating port 900056CONNECT tunnel failed, response 403
Chicago with country de56CONNECT tunnel failed, response 465
Password with @, not encoded, in the URL5Unsupported proxy syntax in '…': Bad hostname
HTTP to the SOCKS port 1100056Proxy CONNECT aborted
socks5h:// to the HTTP port 900028Connection timed out after 40005 milliseconds
https:// as the proxy scheme35TLS connect error: error:0A00010B:SSL routines::wrong version number
socks5h://, wrong password97User was rejected by the SOCKS5 server (1 1).
Port 80807Failed to connect to via.proxies.vision port 8080 … Could not connect to server
A typo in the host5Could not resolve proxy: via.proxy.vision
Fig. 1 — curl_errno() and curl_error() for each case, October 20, 2026. 403 and 465 are our gateway’s statuses.

403: a value in the username

Targeting goes into the proxy username. Our gateway refuses values it can’t read: a three-letter country code, or a session label on port 9000 (sessions belong on 10000). A key it doesn’t know is ignored without an error.

465: no exit

The login is fine, but no residential exit matches every filter right now. Drop the narrowest one, usually the city. The cURL exit codes are the same as on the command line; here is each one.

SOCKS5

Write socks5h://via.proxies.vision:11000 as the proxy, or keep host:port and set CURLOPT_PROXYTYPE to CURLPROXY_SOCKS5_HOSTNAME. Both returned 200, as did plain CURLPROXY_SOCKS5. The _HOSTNAME form sends the website’s name to the proxy instead of looking it up on your machine, so no DNS request leaves from your own connection.

Guzzle runs on the same cURL, so the messages above appear inside its exceptions: cURL error 56: CONNECT tunnel failed, response 407. Its proxy option takes a URL with the login in it. It also takes an array keyed by URL scheme, and that is where requests quietly skip the proxy:

your own IP'proxy' => ['http' => 'http://USER:PASS@via.proxies.vision:9000']https:// URLs go direct
proxied'proxy' => ['https' => 'http://USER:PASS@via.proxies.vision:9000']for https:// URLs
proxied'proxy' => 'http://USER:PASS@via.proxies.vision:9000'every scheme

No error, just your IP

With only an 'http' key, an https:// request returned 200 with the same IP as a request without any proxy. Nothing failed, so nothing tells you. The key is the scheme of the website’s URL, not of the proxy, and almost every site is https:// today.

Guzzle 7 and 8 throw different classes

A refused tunnel threw RequestException in Guzzle 7.16 and NetworkException in 8.3. An http:// URL with a wrong login threw ClientException (a 407 response) in both. Catch the GuzzleException interface and the code works on either version.

A setup that works

Both scripts ran unchanged with php on PHP 8.4, the Guzzle one with Guzzle 8.3 and 7.16.

fetch_proxy.php
<?php
// php fetch_proxy.php   (PHP with the curl extension)
$user = getenv("PROXY_USER") . "-country-de";
$pass = getenv("PROXY_PASS");

$ch = curl_init("https://ipinfo.io/json");
curl_setopt_array($ch, [
    CURLOPT_PROXY => "via.proxies.vision:9000",
    CURLOPT_PROXYUSERPWD => "$user:$pass", // the proxy's login; CURLOPT_USERPWD is the website's
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CONNECTTIMEOUT => 15,
    CURLOPT_TIMEOUT => 30,
]);
$body = curl_exec($ch);

if ($body === false) {
    // https:// URLs: a refused tunnel ends here, e.g. "CONNECT tunnel failed, response 407"
    fwrite(STDERR, "curl error " . curl_errno($ch) . ": " . curl_error($ch) . "\n");
    exit(1);
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status === 407) {
    // http:// URLs: the proxy's 407 arrives as an ordinary response, not an error
    fwrite(STDERR, "The proxy rejected the login\n");
    exit(1);
}
echo $status, " ", $body, "\n";
txt
200 {
  "ip": "79.226.48.89",
  "city": "Köln",
  …
guzzle_proxy.php
<?php
require __DIR__ . "/vendor/autoload.php"; // composer require guzzlehttp/guzzle

use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;

$user = getenv("PROXY_USER") . "-country-de";
$pass = getenv("PROXY_PASS");

// One string, not an array: every URL scheme goes through the proxy.
// rawurlencode keeps characters like @ or : in a password from breaking the URL.
$proxy = sprintf("http://%s:%s@via.proxies.vision:9000", rawurlencode($user), rawurlencode($pass));
$client = new Client(["proxy" => $proxy, "timeout" => 30]);

try {
    $r = $client->get("https://ipinfo.io/json");
    echo $r->getStatusCode(), " ", $r->getBody(), "\n";
} catch (GuzzleException $e) {
    // A refused tunnel reads "cURL error 56: CONNECT tunnel failed, response 407"
    fwrite(STDERR, $e->getMessage() . "\n");
    exit(1);
}

Port 9000 gives a new exit per connection. For the same IP across requests, add a session to the username and use port 10000: USER-country-de-session-job1-lifetime-10.

How do I use a proxy with a username and password in PHP cURL?
Set CURLOPT_PROXY to host:port and CURLOPT_PROXYUSERPWD to "user:pass". CURLOPT_USERPWD is a different option: it is the login for the website, and the proxy never sees it.
Why does curl_exec return a page instead of failing when the proxy login is wrong?
For http:// URLs there is no tunnel. The proxy answers the request itself with 407, and cURL treats that as a normal response: curl_exec returns the proxy's error page and CURLINFO_RESPONSE_CODE is 407. For https:// URLs the same login fails with error 56, CONNECT tunnel failed, response 407. Check both.
How do I use a SOCKS5 proxy in PHP cURL?
Either write the scheme into CURLOPT_PROXY (socks5h://host:port) or set CURLOPT_PROXYTYPE to CURLPROXY_SOCKS5_HOSTNAME. The _HOSTNAME variant, like socks5h, lets the proxy resolve the website's name. Credentials still go into CURLOPT_PROXYUSERPWD.
Why does my Guzzle request not use the proxy?
The proxy option was an array with only an 'http' key. Guzzle picks the proxy by the request's scheme, so https:// URLs went out directly from your own IP, without an error. Pass one string, or set both 'http' and 'https'.
What does 'Unsupported proxy syntax … Bad hostname' mean?
The proxy URL couldn't be parsed, usually because the password contains @ or :. URL-encode the username and password with rawurlencode(), or put them into CURLOPT_PROXYUSERPWD instead of the URL.

Residential proxies, pay per GB

HTTP and SOCKS5. Country, city and sticky sessions in the username. No subscription.