Documentation Python quickstart Blog Free tools hello@quanticdata.ioLog in

Golang HTTP Client Proxy: Auth, SOCKS5

What a plain Go http.Client gets through a proxy: of twelve public listing pages fetched on 14 September 2026 from US residential exits with no JavaScript, six returned the complete HTML (Wikipedia, Etsy, Booking.com, GitHub Trending, Hacker News, Product Hunt), three returned a partial page or an empty shell (Walmart, Zillow, Target) and three answered 403 (eBay, Indeed, Yelp)
What a plain Go http.Client gets through a proxy: of twelve public listing pages fetched on 14 September 2026 from US residential exits with no JavaScript, six returned the complete HTML (Wikipedia, Etsy, Booking.com, GitHub Trending, Hacker News, Product Hunt), three returned a partial page or an empty shell (Walmart, Zillow, Target) and three answered 403 (eBay, Indeed, Yelp)

Go has no client.SetProxy. The proxy is a property of the Transport, not of the request, and that single fact explains the rest: why the environment variable sometimes does nothing, why credentials go in the URL, why a 407 on an HTTPS target arrives as a bare error string, and why rotating exits per request is four lines rather than a library.

Three ways to set a proxy, and when each is right

The standard library gives you exactly one hook, Transport.Proxy, a function called once per connection with the request. Everything else is a wrapper around it.

The first way is the environment. Go's DefaultTransport uses ProxyFromEnvironment, so an exported variable is picked up with no code change at all:

export HTTPS_PROXY="http://USER:PASSWORD@proxy.example.net:8080"
export HTTP_PROXY="http://USER:PASSWORD@proxy.example.net:8080"
export NO_PROXY="127.0.0.1,localhost,.internal.example.com"
go run ./cmd/crawler

The second way is an explicit client, which is what you want in anything long-lived: it does not depend on how the process was started, and it lets you size the connection pool for scraping instead of for a CLI tool.

proxyURL, err := url.Parse("http://USER:PASSWORD@proxy.example.net:8080")
if err != nil {
    log.Fatal(err)
}

tr := &http.Transport{
    Proxy:               http.ProxyURL(proxyURL),
    MaxIdleConns:        100,
    MaxIdleConnsPerHost: 10,
    TLSHandshakeTimeout: 10 * time.Second,
}
client := &http.Client{Transport: tr, Timeout: 30 * time.Second}

resp, err := client.Get("https://example.com/")

The third way is to reassign http.DefaultTransport. It works, it is one line, and it changes the behaviour of every package in your binary that quietly uses http.Get or http.DefaultClient — including your telemetry and your cloud SDKs. Use it in a throwaway script, never in a service.

MethodScopeGood forTrap
HTTP_PROXY / HTTPS_PROXYWhole processCLI tools, corporate egress, containersRead once per process; ignored for localhost
Transport.Proxy with ProxyURLOne clientServices, scrapers, anything with a poolA second client with no Proxy silently bypasses it
Reassigning DefaultTransportWhole binaryScriptsEvery dependency inherits it, including SDKs
Your own func(*http.Request)Per requestRotation, per-target routingEach distinct URL opens its own connection pool

Proxy authentication: Basic, and the 407 you never get to read

If the proxy URL carries a userinfo component, the Transport turns it into a Proxy-Authorization: Basic header for you. It does this in both directions: on plain http:// targets the header is added to every request sent down the proxied connection, and on https:// targets it is added to the CONNECT request that opens the tunnel. You do not write that header yourself, and in fact you cannot: a Proxy-Authorization header you set on req.Header travels inside the tunnel to the origin server, where it is meaningless.

Credentials with symbols in them must be percent-encoded, which is the single most common cause of a mystifying 407. Build the URL with url.UserPassword and Go encodes it correctly:

proxy := &url.URL{
    Scheme: "http",
    Host:   "proxy.example.net:8080",
    User:   url.UserPassword(os.Getenv("PROXY_USER"), os.Getenv("PROXY_PASS")),
}

tr := &http.Transport{
    Proxy: http.ProxyURL(proxy),
    OnProxyConnectResponse: func(ctx context.Context, p *url.URL, req *http.Request, res *http.Response) error {
        if res.StatusCode != 200 {
            log.Printf("CONNECT %s -> %s", req.URL.Host, res.Status)
        }
        return nil
    },
}

That hook matters more than it looks. When a CONNECT gets a non-200 answer, the Transport closes the connection and returns an error built from the status text alone — you get Proxy Authentication Required as an error string, with no status code, no headers and no body, because there is no *http.Response to hand back. OnProxyConnectResponse, added in Go 1.20, is the only place where you can see what the proxy actually said. On a plain http:// target there is no tunnel, so the 407 comes back as an ordinary response you can read normally. Our field guide to that status code is in ../how-to-fix-407-proxy-authentication-required/, and the CONNECT-level failures have their own walkthrough in ../err-tunnel-connection-failed/.

SOCKS5 with the standard library, no extra package

You do not need golang.org/x/net/proxy to send HTTP traffic over SOCKS5. Since Go 1.9 the Transport accepts a socks5:// proxy URL directly, and since Go 1.23 it also accepts socks5h:// — with the two treated identically, meaning the hostname is resolved by the proxy and not by your machine. Username and password are read from the same userinfo component as HTTP proxies and negotiated with the username/password auth method.

proxy := &url.URL{
    Scheme: "socks5h",
    Host:   "proxy.example.net:1080",
    User:   url.UserPassword(os.Getenv("PROXY_USER"), os.Getenv("PROXY_PASS")),
}
client := &http.Client{
    Transport: &http.Transport{Proxy: http.ProxyURL(proxy)},
    Timeout:   30 * time.Second,
}

The limit is that net/http only ever asks the SOCKS proxy for a TCP connection. If you need UDP, or you are proxying something that is not HTTP, dial through x/net/proxy yourself. Our SOCKS5 endpoints answer on the same credentials as the HTTP ones, so switching scheme is a one-character change in the code above.

Rotating the proxy per request without leaking connections

Because Proxy is a function of the request, rotation needs no third-party middleware. Return a different URL per call and keep one client for the whole program:

var gateways = []string{
    "gw-1.example.net:8080",
    "gw-2.example.net:8080",
    "gw-3.example.net:8080",
}
var counter uint64

func rotate(req *http.Request) (*url.URL, error) {
    i := atomic.AddUint64(&counter, 1)
    return &url.URL{
        Scheme: "http",
        Host:   gateways[i%uint64(len(gateways))],
        User:   url.UserPassword(os.Getenv("PROXY_USER"), os.Getenv("PROXY_PASS")),
    }, nil
}

client := &http.Client{
    Transport: &http.Transport{
        Proxy:               rotate,
        MaxIdleConns:        200,
        MaxIdleConnsPerHost: 4,
    },
    Timeout: 30 * time.Second,
}

Two things follow from how the Transport caches connections, and both bite people who build this in a hurry. The idle-connection pool is keyed by the triple of proxy URL, target scheme and target address, so every distinct proxy string you return gets its own pool — and MaxIdleConnsPerHost defaults to 2. With sticky-session usernames, where each session is a different username on the same gateway, you are minting a new pool entry per session and then abandoning it; call client.CloseIdleConnections() when you retire a batch of sessions, or keep the number of live sessions bounded on purpose.

The second is the mistake underneath most "my Go scraper eats file descriptors" reports: building a fresh http.Transport inside the request loop. A Transport is a connection pool, it is designed to be created once and shared across goroutines, and one per request means zero reuse plus a slow leak. One client, one Transport, a rotating Proxy func. If you want the rotation handled on the far side instead, our rotating endpoint gives a new exit per request behind a single host and a single credential, so the Go side stays a two-line ProxyURL.

HTTP_PROXY, NO_PROXY and the environment that is read exactly once

Four behaviours of ProxyFromEnvironment account for nearly every "the environment variable does nothing" thread, and all four are in the standard library source rather than in folklore.

  • The environment is read once per process. The parsed configuration is cached behind a sync.Once on first use, so calling os.Setenv("HTTP_PROXY", ...) after your program's first HTTP request has no effect whatsoever. Set it before you start, or set Transport.Proxy explicitly.
  • Lowercase wins. When both http_proxy and HTTP_PROXY are present, the lowercase one takes precedence — the reverse of what most people assume.
  • localhost and loopback are never proxied. If the request host is localhost or a loopback address, with or without a port, Go returns no proxy as a documented special case. Testing your proxy setup against a local server will always look like the proxy is being ignored, because it is.
  • CGI processes ignore HTTP_PROXY. If REQUEST_METHOD is set in the environment, Go treats the process as a CGI handler and refuses to apply HTTP_PROXY, because a remote client could otherwise set it through the Proxy header. That is the httpoxy vulnerability, closed at the library level.

NO_PROXY is a comma-separated list, and its grammar is more capable than the one-host-per-entry version most people use. Each entry can be a domain, an IP prefix, a CIDR block, or a single asterisk.

NO_PROXY entryMatchesDoes not match
foo.comfoo.com and bar.foo.comnotfoo.com
.y.comx.y.comy.com
10.0.0.0/8Any address in that blockHostnames that resolve into it
1.2.3.4:80That address on that port only1.2.3.4:443
*Everything: proxying is disabled

The errors Go returns, and what each one means

Go's proxy errors are terse, and two of them are terse in a way that hides the status code. This table is the translation.

What you seeWhat happenedWhere to look
proxyconnect tcp: dial tcp …: i/o timeoutThe TCP connection to the proxy itself never openedPort, firewall, egress rules. The target is not involved yet
proxyconnect tcp: lookup …: no such hostThe proxy hostname does not resolveCopy the host from your dashboard; check DNS before blaming auth
Proxy Authentication RequiredA 407 on the CONNECT, returned as a bare status textPercent-encoding of the password, IP allowlist, exhausted plan
Forbidden / Bad Gateway (bare, no code)Non-200 CONNECT: the proxy refused or could not reach the targetOnProxyConnectResponse to see the real status and headers
EOF immediately after CONNECTThe proxy accepted the TCP connection then closed itWrong port for the protocol, or a plan with no bandwidth left
tls: failed to verify certificateThe TLS handshake is with something that is not your targetAn intercepting proxy, or an https:// scheme on an HTTP proxy port
context deadline exceeded (Client.Timeout exceeded while awaiting headers)The client deadline covered CONNECT, TLS and the wait for headersRaise Client.Timeout, or use per-attempt contexts

One nuance worth internalising: http.Client.Timeout is a deadline on the whole exchange, including the proxy handshake and the reading of the body. A 30-second timeout on a residential exit is not generous; it is the budget for a CONNECT, a TLS handshake, a request and a full HTML download over a home line.

What a plain Go client actually gets: 12 pages measured

The interesting question is not how to attach a proxy. It is how far a proxied HTTP client gets before you need a browser. On 14 September 2026, between 05:33 and 05:40 UTC, we fetched twelve public listing pages twice each through US residential exits — once as a pure HTTP client with no JavaScript, which is what net/http is, and once fully rendered — and compared the word counts. Each pair was one call to our own SEO audit endpoint, which exists precisely to answer this question.

PageWords, no JSWords, renderedWhat a Go client gets
en.wikipedia.org (article)6,3066,383Everything
etsy.com (category)5,0465,188Everything
booking.com (city page)4,4374,409Everything
github.com/trending2,2482,248Everything, byte for byte
news.ycombinator.com603603Everything
producthunt.com562676Most of it; 83% of the words
walmart.com (category)8232,838Copy and FAQ, not the product grid
zillow.com (city listings)137403 on renderThin page, but with an ItemList in the structured data
target.com (category)2764An empty shell: no title, no canonical
ebay.com (category)403403Nothing
indeed.com (job search)403403Nothing
yelp.com (search)403403Nothing

Six of twelve returned the complete page to a client that cannot run a line of JavaScript. Three returned something partial — Walmart's category page gives you 29% of its rendered words, and the missing 71% is the product grid, which is the only part anyone wants. Three refused outright with a 403 before any HTML existed to parse.

Two honest caveats. First, those fetches used a browser-grade TLS fingerprint; Go's own ClientHello from crypto/tls is distinctive enough to be classified on sight, so a stock net/http client will do the same or worse on the hostile half, not better. Second, the Zillow row is a useful inversion: the plain HTTP fetch succeeded with a usable ItemList in the structured data while the rendered fetch drew a captcha. More machinery is not automatically more access, and the structured data block is often the shortest path to the fields you were going to parse out of the DOM anyway.

Bandwidth, and when to stop writing client code

Residential traffic is metered by the gigabyte, and HTML is heavier than it looks once images, fonts and analytics beacons are counted — which is why a Go scraper that only ever reads resp.Body is cheaper per page than a headless browser doing the same job. We put real numbers on that in ../how-much-proxy-data-do-i-need/; the short version is that page weight, not request count, is what your bill tracks. Our residential pool starts at $0.80/GB on the Basic tier with no subscription, and rotating exits start at $0.50/GB.

Write the client yourself for the six-out-of-twelve half: those pages are a Transport, a rotating Proxy func and golang.org/x/net/html, and nothing more. For the other half — the 403s, the shells, the grids that only exist after JavaScript — the work is no longer Go code, it is fingerprinting and rendering maintenance. That is what our scraping API is for: one HTTP call from the same http.Client you already built, clean Markdown or structured JSON back, from $0.0002 per page and $0.001 with rendering, billed only on success. Every account gets $2 of free API usage a month, which is several thousand pages before you decide anything.

Sources & further reading

FAQ

Quick answers on golang http client proxy.

Something else? Ask us →

How do I set a proxy for a single request in Go?

There is no per-request field. Transport.Proxy is a func(*http.Request) (*url.URL, error), so you route inside that function: inspect req.URL.Host, or read a value you put on the request context, and return the proxy URL you want for that call. The function runs once per new connection, and the resulting connection is pooled per proxy URL.

Why is my HTTP_PROXY variable being ignored?

Four documented reasons, in order of likelihood: the target is localhost or a loopback address, which Go never proxies; the variable was set with os.Setenv after the first request, and the environment is parsed once and cached; a lowercase http_proxy is also set and takes precedence; or the process has REQUEST_METHOD in its environment, which makes Go treat it as CGI and ignore HTTP_PROXY entirely.

Does Go support SOCKS5 proxies without an extra package?

Yes. Transport.Proxy has accepted socks5:// URLs since Go 1.9 and socks5h:// since Go 1.23, and treats the two the same, so hostnames are resolved by the proxy. Username and password come from the URL userinfo. You only need golang.org/x/net/proxy for non-HTTP traffic or UDP, which net/http never asks for.

How do I pass proxy credentials in Go?

Put them in the proxy URL, ideally via url.UserPassword so that symbols are percent-encoded correctly. Go then emits Proxy-Authorization: Basic itself, on the CONNECT for HTTPS targets and on each request for plain HTTP targets. Setting that header on your own request object does not work for HTTPS: it goes through the tunnel to the origin server.

Why do I get "Proxy Authentication Required" with no status code?

Because the 407 was answered to a CONNECT request, and a failed CONNECT never produces an *http.Response for the caller — the Transport closes the connection and returns an error made from the status text alone. Register OnProxyConnectResponse (Go 1.20 and later) to inspect the actual response, headers included, before the error is built.

Do I need one http.Client per goroutine when rotating proxies?

No, and doing it is the usual cause of descriptor exhaustion. An http.Client and its Transport are safe for concurrent use and exist to pool connections. Share one, put the rotation in the Proxy function, and raise MaxIdleConnsPerHost from its default of 2 — the idle pool is keyed by proxy URL, so every exit you rotate through keeps its own small set of connections.

Proxies that answer a Go client on the first request

HTTP and SOCKS5 on the same credentials, rotating or sticky exits, country targeting per request, and a scraping API for the pages that answer 403. Every account gets $2 of free usage each month.

Related reading