• English
  • Example: HTTP Client Use a Proxy

    An HTTP proxy sits between your Go program and the server it wants to reach.

    The basic path is:

    Without a proxy:
    
    Go client ────────────────> Origin server
    
    
    With a proxy:
    
    Go client ─────> Proxy ─────> Origin server

    In Go, proxy configuration belongs to the http.Transport, not the http.Client.

    A useful mental model is:

    • http.Client controls request behavior: timeouts, redirects, and cookies.
    • http.Transport controls connections: dialing, connection pooling, TLS, and proxies.

    Once you understand that distinction, proxy configuration becomes much easier to reason about.

    Quick Example

    For most applications, let the deployment environment decide whether a proxy is used:

    package main
    
    import (
    	"fmt"
    	"io"
    	"net/http"
    	"time"
    )
    
    func main() {
    	base, ok := http.DefaultTransport.(*http.Transport)
    	if !ok {
    		panic("http.DefaultTransport is not *http.Transport")
    	}
    
    	transport := base.Clone()
    	transport.Proxy = http.ProxyFromEnvironment
    
    	client := &http.Client{
    		Transport: transport,
    		Timeout:   15 * time.Second,
    	}
    
    	resp, err := client.Get("https://example.com")
    	if err != nil {
    		panic(err)
    	}
    	defer resp.Body.Close()
    
    	body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
    	if err != nil {
    		panic(err)
    	}
    
    	fmt.Println(resp.Status)
    	fmt.Println(len(body))
    }

    In a real application, return errors instead of calling panic. The example uses panic only to keep the setup short.

    Go's ProxyFromEnvironment reads HTTP_PROXY, HTTPS_PROXY, and NO_PROXY, including their lowercase forms. HTTPS_PROXY is used for HTTPS requests, while NO_PROXY can bypass the proxy for selected destinations.

    This is usually the simplest production setup because the same binary can run:

    Developer machine  ──> direct
    CI environment     ──> proxy
    Production         ──> corporate proxy

    without changing application code.


    1. Environment Proxies

    The standard environment variables are:

    HTTP_PROXY
    HTTPS_PROXY
    NO_PROXY

    Lowercase forms are also recognized:

    http_proxy
    https_proxy
    no_proxy

    If both uppercase and lowercase versions are present, the lowercase values take precedence in the current proxy configuration implementation.

    For example:

    HTTPS_PROXY=http://proxy.example.com:8080
    NO_PROXY=localhost,127.0.0.1,.internal.example.com

    Then:

    transport.Proxy = http.ProxyFromEnvironment

    is enough.

    When this is a good choice

    Environment configuration works well when proxy selection belongs to the deployment environment:

    • corporate networks
    • Kubernetes deployments
    • CI runners
    • development environments
    • services with the same network policy across many destinations

    The application does not need to know the actual proxy address.

    NO_PROXY

    NO_PROXY tells Go which destinations should bypass the proxy.

    For example:

    NO_PROXY=localhost,127.0.0.1,example.com,.internal.example.com

    A domain without a leading dot can match the domain and its subdomains. A leading dot is used when you specifically want subdomains.

    Ports can also be included:

    NO_PROXY=example.com:8443

    That applies to port 8443; it does not mean that every port on example.com bypasses the proxy.

    A single * disables proxying for all destinations.

    Do not treat NO_PROXY as a security mechanism. It controls routing, not authorization.


    2. Fixed Proxies

    Sometimes the application itself must choose the proxy.

    For example:

    • a crawler rotates between known proxies
    • different customers use different network paths
    • a security tool has an explicit egress proxy
    • a service has a fixed corporate proxy

    Parse the proxy URL during initialization:

    proxyURL, err := url.Parse("http://proxy.example.com:8080")
    if err != nil {
    	return nil, fmt.Errorf("parse proxy URL: %w", err)
    }
    
    transport := &http.Transport{
    	Proxy: http.ProxyURL(proxyURL),
    }
    
    client := &http.Client{
    	Transport: transport,
    	Timeout: 15 * time.Second,
    }

    http.ProxyURL returns a proxy function that always uses that URL.

    Do not create a new Transport for every request. The transport owns connection pools and other connection state.

    Create it once and reuse it.


    3. What Actually Happens When You Use a Proxy?

    The behavior depends on the protocol.

    HTTP

    For a plain HTTP request, the client sends the request through the proxy:

    Client
    
       │ HTTP request
    
    Proxy
    
       │ HTTP request
    
    Origin server

    The proxy can see the HTTP request because the request is not encrypted by TLS.

    HTTPS

    HTTPS is different.

    The client first asks the proxy to create a TCP tunnel:

    Client ── CONNECT example.com:443 ──> Proxy

    The proxy connects to:

    example.com:443

    and establishes a tunnel.

    Then the TLS handshake happens through that tunnel:

    Client ── CONNECT ──> Proxy ── TCP tunnel ──> Origin
      │                                             │
      └────────────── TLS handshake ────────────────┘

    The important point is that an ordinary HTTP proxy does not automatically terminate HTTPS.

    This distinction is extremely useful when debugging.

    If the proxy refuses the CONNECT, the origin server may never see the request.


    4. SOCKS5

    Go's http.Transport also supports SOCKS proxies.

    For example:

    proxyURL, err := url.Parse("socks5://127.0.0.1:1080")
    if err != nil {
    	return nil, err
    }
    
    transport := &http.Transport{
    	Proxy: http.ProxyURL(proxyURL),
    }

    The current standard library accepts:

    socks5://
    socks5h://

    and treats them the same.

    This is worth knowing because other SOCKS clients may distinguish:

    socks5
        DNS resolution happens on the client
    
    socks5h
        DNS resolution happens through the proxy

    That distinction is common in other tools and libraries, but it should not be assumed when reading Go's standard net/http behavior.

    If you use a third-party SOCKS library, check that library's documentation separately.


    5. Proxy Authentication

    A proxy may require authentication.

    The simplest form is putting credentials in the proxy URL:

    proxyURL, err := url.Parse(
    	"http://user:password@proxy.example.com:8080",
    )
    if err != nil {
    	return nil, err
    }
    
    transport := &http.Transport{
    	Proxy: http.ProxyURL(proxyURL),
    }

    Go uses the credentials to construct the proxy authentication information.

    Do not log the proxy URL

    This is dangerous:

    log.Printf("using proxy %s", proxyURL)

    A proxy URL containing credentials can expose the password in:

    • logs
    • traces
    • metrics
    • error messages
    • debugging output

    Treat proxy credentials like any other secret.

    Also remember that:

    Proxy-Authorization

    authenticates the client to the proxy.

    It is different from:

    Authorization

    which normally authenticates the client to the origin server.


    6. Dynamic Authentication for HTTPS Proxies

    Some proxies require short-lived credentials for HTTPS CONNECT requests.

    Use GetProxyConnectHeader when the authentication value must be generated dynamically:

    transport.GetProxyConnectHeader =
    	func(ctx context.Context, proxyURL *url.URL, target string) (http.Header, error) {
    
    		token, err := loadProxyToken(ctx)
    		if err != nil {
    			return nil, err
    		}
    
    		header := make(http.Header)
    		header.Set("Proxy-Authorization", "Bearer "+token)
    
    		return header, nil
    	}

    The callback runs as part of establishing the proxy connection.

    That means this is a bad place for slow work such as:

    database query
    remote API call
    slow filesystem operation

    If many requests need new connections, slow authentication work can delay connection establishment and consume connection resources.

    Prefer to obtain short-lived credentials ahead of time and keep the callback cheap.

    For example:

    Token refresh
    
    
    Cached token
    
    
    GetProxyConnectHeader
    
    
    CONNECT

    The callback also receives a context, so any unavoidable work should respect cancellation and deadlines.


    7. Different Proxies for Different Destinations

    Sometimes one client needs more than one proxy.

    For example:

    api.example.com       → Proxy A
    partner.example.com   → Proxy B
    everything else       → direct

    Transport.Proxy is a function:

    func(*http.Request) (*url.URL, error)

    So routing can be based on the request:

    proxyA, err := url.Parse("http://proxy-a.example.com:8080")
    if err != nil {
    	return nil, err
    }
    
    proxyB, err := url.Parse("http://proxy-b.example.com:8080")
    if err != nil {
    	return nil, err
    }
    
    transport := &http.Transport{}
    
    transport.Proxy = func(req *http.Request) (*url.URL, error) {
    	switch req.URL.Hostname() {
    	case "api.example.com":
    		return proxyA, nil
    	case "partner.example.com":
    		return proxyB, nil
    	default:
    		return nil, nil
    	}
    }

    The important part is that the callback stays cheap.

    Do not do this:

    transport.Proxy = func(req *http.Request) (*url.URL, error) {
    	proxyURL, err := url.Parse(loadProxyFromDatabase(req.URL.Hostname()))
    	if err != nil {
    		return nil, err
    	}
    
    	return proxyURL, nil
    }

    The proxy function participates in connection setup. Parse configuration and load routing information during initialization whenever possible.


    8. A Proxy Changes Your Network Boundary

    A proxy is not just a different URL.

    It changes where the network connection originates.

    Without a proxy:

    Your application
    
    
    Internet

    With a proxy:

    Your application
    
    
    Proxy network
    
    
    Internet

    That matters for security.

    SSRF

    Suppose your application accepts a user-controlled URL:

    https://your-service.example/fetch?url=...

    An attacker might try to make the service request an internal address such as:

    http://169.254.169.254/

    or another private or loopback address.

    With a proxy, the request may originate from the proxy's network rather than directly from the application host.

    Therefore, if your application fetches user-controlled URLs:

    1. validate the URL scheme
    2. validate the destination hostname
    3. resolve and validate destination IPs
    4. reject private, loopback, link-local, and other forbidden ranges as appropriate
    5. enforce egress policy at the proxy or network layer
    6. account for redirects and subsequent DNS resolution

    A single DNS lookup before the request is not a complete defense against DNS rebinding. The address used for validation and the address eventually used for connection establishment can differ.

    The proxy itself must also be part of the threat model.


    9. Redirects Can Cross Network Boundaries

    Proxy security does not stop at the first URL.

    Consider:

    https://public.example
    
            │ 302
    
    http://internal.example

    Your application may start with a harmless public URL but eventually follow a redirect to another destination.

    If the application accepts untrusted URLs, apply your SSRF policy to the redirect destination too.

    This is one reason URL validation should be treated as a request policy rather than a one-time string check.


    10. Do Not Create a Transport Per Request

    Avoid this pattern:

    func fetch(ctx context.Context, url string) error {
    	transport := &http.Transport{
    		Proxy: http.ProxyFromEnvironment,
    	}
    
    	client := &http.Client{
    		Transport: transport,
    	}
    
    	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    	if err != nil {
    		return err
    	}
    
    	resp, err := client.Do(req)
    	if err != nil {
    		return err
    	}
    	defer resp.Body.Close()
    
    	return nil
    }

    The code works, but the lifecycle is wrong for a normal long-running application.

    A Transport maintains connection state and connection pools.

    Create it once:

    type HTTPClient struct {
    	client *http.Client
    }
    
    func NewHTTPClient() *HTTPClient {
    	transport := &http.Transport{
    		Proxy: http.ProxyFromEnvironment,
    	}
    
    	return &HTTPClient{
    		client: &http.Client{
    			Transport: transport,
    			Timeout:   15 * time.Second,
    		},
    	}
    }

    Then reuse the client:

    client := NewHTTPClient()
    
    // Reuse client for many requests.

    This allows the transport to reuse connections instead of rebuilding the network path for every request.


    11. Troubleshooting Proxy Errors

    When a proxied request fails, first identify which network hop failed.

    Think in this order:

    Application
    
    
    Proxy connection
    
    
    CONNECT / proxy authentication
    
    
    Origin connection
    
    
    TLS
    
    
    HTTP response

    proxyconnect tcp: dial tcp ... connection refused

    The application could not establish a TCP connection to the proxy.

    Check:

    • proxy hostname
    • proxy port
    • DNS
    • firewall rules
    • whether the proxy is running

    The proxyconnect tcp prefix is a useful clue: the failure happened while connecting to the proxy, before reaching the origin.

    407 Proxy Authentication Required

    The proxy rejected authentication.

    Check:

    • username/password
    • Proxy-Authorization
    • dynamic CONNECT headers
    • URL encoding of credentials

    For example, passwords containing characters such as:

    @
    :
    ?
    #

    must be encoded correctly when placed in a URL.

    403 during CONNECT

    The proxy rejected the tunnel request.

    For example:

    Client ── CONNECT example.com:443 ──> Proxy
    
                                          └── 403

    The origin server may never have received the request.

    Common causes include proxy ACLs that restrict:

    • destination domains
    • destination ports
    • HTTPS tunneling

    x509: certificate signed by unknown authority

    If the request works directly but fails through the proxy, the proxy may be performing TLS interception.

    The path can become:

    Client
    
       │ TLS
    
    Corporate proxy
    
       │ TLS
    
    Origin

    The proxy terminates the first TLS connection and creates another one to the origin.

    Your Go client may therefore receive a certificate signed by the organization's internal CA.

    The correct fix is to trust the organization's CA when that TLS interception is intentional.

    Do not solve this with:

    TLSClientConfig: &tls.Config{
    	InsecureSkipVerify: true,
    }

    That disables certificate verification.

    NO_PROXY is not working

    Check the exact destination host and port.

    For example:

    NO_PROXY=example.com:8443

    does not mean:

    example.com:443

    also bypasses the proxy.

    Also remember that:

    example.com

    and:

    .example.com

    have different matching semantics.

    When debugging, log the destination host and port—not credentials or proxy URLs containing secrets.


    Common Mistakes

    Mistake 1: Configuring the proxy on http.Client

    Proxy configuration belongs to:

    http.Transport

    not directly to:

    http.Client

    The client owns request-level behavior; the transport owns connection behavior.

    Mistake 2: Creating a new transport for every request

    This prevents effective connection reuse.

    Reuse the transport and client.

    Mistake 3: Logging proxy credentials

    Avoid logging:

    proxyURL.String()

    when the URL contains user information.

    Credentials can also leak through traces, metrics, and error messages.

    Mistake 4: Using InsecureSkipVerify to fix proxy TLS errors

    A corporate TLS interception problem should be solved by configuring the correct trust chain, not by disabling certificate verification.

    Mistake 5: Doing slow work inside Transport.Proxy

    The proxy callback participates in connection selection.

    Keep it deterministic and cheap.

    Mistake 6: Assuming the proxy is only a performance feature

    A proxy changes the network boundary.

    It can affect:

    • DNS behavior
    • source network identity
    • TLS interception
    • access control
    • SSRF exposure
    • routing policy

    Code Review

    Before shipping proxy-enabled HTTP code, check:

    • Transport and Client are reused
    • Client.Timeout or request context provides a deadline
    • proxy configuration is explicit
    • NO_PROXY behavior is understood
    • proxy credentials are not logged
    • dynamic proxy authentication respects context
    • Transport.Proxy does not perform slow I/O
    • HTTPS CONNECT behavior is understood
    • TLS interception is handled through the correct CA
    • InsecureSkipVerify is not used as a shortcut
    • user-controlled URLs have SSRF protection
    • redirect destinations are covered by the same security policy
    • DNS rebinding is considered when validating destinations
    • proxy-level egress restrictions are part of the security design

    Engineering Rule

    Start with the simplest model:

    http.Client
    
    
    http.Transport
    
    
    Proxy
    
    
    Origin

    Use ProxyFromEnvironment when proxy routing belongs to deployment configuration.

    Use ProxyURL or a custom Transport.Proxy function when the application owns the routing decision.

    For HTTPS, remember that an ordinary HTTP proxy usually creates a CONNECT tunnel rather than seeing the decrypted request.

    And when a proxy is involved in security-sensitive traffic, treat it as part of your application's network boundary—not as a transparent pipe.