Proxy authentication in Go’s net/http, HTTP and SOCKS5
The standard library does all of it, SOCKS5 included. What trips people up: rotation that doesn’t rotate, passwords that break the URL, and requests that end in EOF.
In short
Build the proxy URL with url.UserPassword and set it on an http.Transport with http.ProxyURL. For a new IP on every request through a rotating gateway, also set DisableKeepAlives: true. Otherwise Go quietly reuses the same exit.
An http.Transport asks its Proxy function which proxy to use for each request. http.ProxyURL returns the same proxy every time, and the user info in that URL (username and password) is what Go sends to the proxy.
For an http:// target Go sends the request to the proxy with a Proxy-Authorization header. For an https:// target it first asks the proxy to open a tunnel with CONNECT, authenticated the same way, and speaks TLS to the website through it. You don’t write any of that; the proxy scheme stays http:// in both cases.
SOCKS5 works the same way: give the URL the socks5 scheme and net/http handles the handshake and the password itself. We ran both against our gateway with Go 1.25, without importing anything outside the standard library.
proxyURL := &url.URL{
Scheme: "http", // "socks5" works the same way
User: url.UserPassword(os.Getenv("PROXY_USER"), os.Getenv("PROXY_PASS")),
Host: "via.proxies.vision:9000", // SOCKS5: 11000
}
client := &http.Client{
Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
Timeout: 30 * time.Second,
}
resp, err := client.Get("https://ipinfo.io/json")Build the URL, don’t format it
A password with @, : or / breaks a URL put together with fmt.Sprintf. url.UserPassword escapes those characters, so the URL always parses.
Always set a timeout
The zero-value http.Client waits forever. A residential exit can stall, so give every client a Timeout.
A rotating gateway picks a new exit IP when a connection opens, not for every request on it. Go’s Transport keeps connections alive and sends the next request through the tunnel that is already open. So with a default client, every request after the first leaves through the same IP, even though the gateway rotates.
We ran three requests through one client, then three with keep-alive disabled:
| Client | Request 1 | Request 2 | Request 3 |
|---|---|---|---|
| Default Transport | 152.55.177.69 | 152.55.177.69 | 152.55.177.69 |
| DisableKeepAlives: true | 209.214.13.74 | 172.59.191.18 | 72.168.144.6 |
For one IP per request, open a new connection per request. For the same IP on purpose, for a login or a checkout, keep keep-alive on and add a session label to the username instead: USER-session-checkout1-lifetime-10 on the sticky port 10000 holds one IP for ten minutes, across connections and even across processes.
Transport: &http.Transport{
Proxy: http.ProxyURL(proxyURL),
DisableKeepAlives: true, // one connection per request = one IP per request
},Residential exits are real home connections, and now and then one drops in the middle of a request. Go reports it as Get "https://…": EOF. It isn’t your code and it isn’t the proxy rejecting you: the next attempt gets a healthy exit. Retry network errors a couple of times with a short pause.
for i := 0; i < attempts; i++ {
resp, err := client.Get(target)
if err != nil {
lastErr = err // EOF, reset, timeout: try a fresh exit
time.Sleep(time.Duration(i+1) * 500 * time.Millisecond)
continue
}
// …read the body, return on success
}Reading proxy errors
When the proxy refuses a request, Go returns an error before any response. Go shows the proxy’s reason phrase, not the status number.
| Go’s error ends with | Cause | Fix |
|---|---|---|
Proxy Authentication Required | Wrong username or password (407) | Copy both again from the dashboard |
Invalid Request | A value in the username the gateway can’t read (403) | Two-letter country codes, cities without spaces, sessions only on the sticky port |
Can Not Fulfill Targeting | No exit matches all your filters (465) | Remove the narrowest filter (city, network) |
| EOF | A residential exit dropped | Retry |
Filters fail closed
An impossible combination, such as a city that doesn’t exist in the chosen country, fails instead of quietly giving you another location. City names go in lower case, without spaces or accents: São Paulo is saopaulo. The generator below rewrites them for you. A key the gateway doesn’t know, such as -cc-US, is ignored without an error, so check where your exits land.
Seeing ERR_TUNNEL_CONNECTION_FAILED in a headless browser instead? That’s the same refusal in Chromium’s words: every cause, tested.
A setup that works
Pick protocol, rotation and location. The program compiles as shown, retries dropped connections, and needs nothing outside the standard library.
Generate your Go client
compiles as shown · Go 1.23
Username sent to the proxy: PROXY_USER-country-de
package main
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"
)
func main() {
user := os.Getenv("PROXY_USER") + "-country-de"
proxyURL := &url.URL{
Scheme: "http",
// url.UserPassword escapes special characters in the password.
User: url.UserPassword(user, os.Getenv("PROXY_PASS")),
// Rotating ports pick a new exit per connection: HTTP 9000, SOCKS5 11000.
Host: "via.proxies.vision:9000",
}
client := &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(proxyURL),
// Without this, Go reuses the open tunnel and every request leaves
// through the same IP. One connection per request = one IP per request.
DisableKeepAlives: true,
},
Timeout: 30 * time.Second,
}
for i := 1; i <= 3; i++ {
body, err := fetch(client, "https://ipinfo.io/json", 3)
if err != nil {
fmt.Println("request failed:", err)
continue
}
fmt.Printf("request %d: %s\n", i, body)
}
}
// fetch retries on network errors: residential exits occasionally drop a
// connection mid-request (Go reports EOF), and the next attempt gets a
// healthy one. Proxy refusals (403, 407, 465) come back as errors too.
func fetch(client *http.Client, target string, attempts int) ([]byte, error) {
var lastErr error
for i := 0; i < attempts; i++ {
resp, err := client.Get(target)
if err != nil {
lastErr = err
time.Sleep(time.Duration(i+1) * 500 * time.Millisecond)
continue
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = err
continue
}
if resp.StatusCode >= 500 {
lastErr = fmt.Errorf("target answered %s", resp.Status)
continue
}
return body, nil
}
return nil, lastErr
}
Run it with PROXY_USER=… PROXY_PASS=… go run main.go. No third-party packages needed, SOCKS5 included.
Do I need golang.org/x/net/proxy for SOCKS5?
Why does my rotating proxy return the same IP for every request in Go?
How do I pass a proxy password with special characters?
Does HTTPS work through an HTTP proxy in Go?
Keep reading
Node.js fetch With a Proxy: undici ProxyAgent, NODE_USE_ENV_PROXY and Errors
Node's fetch ignores HTTPS_PROXY. Three ways to proxy it, the undici 8 'invalid onRequestStart method' trap, and where the proxy's 407 hides.
6 min read · #node.js#errors
How to Rotate Proxies in Python (and Why Yours May Not Rotate)
requests.Session, httpx.Client and Scrapy reuse one IP through a rotating proxy. What rotates, measured, and code that gets a new IP per request.
7 min read · #python#scrapy
aiohttp Proxy Errors: ClientHttpProxyError 407, SOCKS5 and trust_env
aiohttp's ClientHttpProxyError explained, why its message leaks your proxy password, socks5:// without aiohttp-socks, and rotation.
6 min read · #python#errors
Residential proxies, pay per GB
HTTP and SOCKS5. Country, city and sticky sessions in the username. No subscription.