• English
  • Example: HTTP Client Following Redirects Safely

    HTTP redirects are convenient, but they can change the destination, method, request body, and credential boundary of a request.

    For simple GET requests, the default behavior is often enough. For authenticated requests, file uploads, or requests to untrusted URLs, redirect behavior becomes part of your security and reliability policy.

    This example shows how to handle redirects deliberately with http.Client.

    Quick Example

    A custom CheckRedirect lets you reject redirects that cross your trust boundary:

    client := &http.Client{
    	CheckRedirect: func(req *http.Request, via []*http.Request) error {
    		if len(via) > 0 && req.URL.Host != via[0].URL.Host {
    			return fmt.Errorf("refusing redirect to %s", req.URL.Host)
    		}
    		return nil
    	},
    }

    Use this when the destination must remain within a known host, service, or trust boundary.

    If you do not need a custom policy, the default client already follows redirects and stops after 10 consecutive requests.

    Redirect Behavior

    Go's http.Client handles the standard redirect status codes:

    StatusDefault method behavior
    301GET for most original methods
    302GET for most original methods
    303GET for most original methods
    307Preserve method and body
    308Preserve method and body

    For 307 and 308, Go can resend the request body only when Request.GetBody is available. http.NewRequest automatically provides GetBody for common in-memory body types such as bytes.Buffer, bytes.Reader, and strings.Reader.

    For 301, 302, and 303, a request such as POST normally becomes GET, with no request body.

    This behavior is part of the http.Client API, not something your CheckRedirect callback implements.


    1. Make Request Bodies Replayable

    A redirect can require the request body to be sent again.

    For example, a file upload using 307 or 308 needs a fresh reader for every resend:

    file, err := os.Open(path)
    if err != nil {
    	return err
    }
    
    req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, file)
    if err != nil {
    	file.Close()
    	return err
    }
    
    req.GetBody = func() (io.ReadCloser, error) {
    	return os.Open(path)
    }
    
    resp, err := client.Do(req)
    if err != nil {
    	return err
    }
    defer resp.Body.Close()

    GetBody must return a new, independent stream.

    The original file handle is used for the first request and is closed by the underlying transport when that request is finished, including when a redirect occurs. Subsequent redirect requests do not reuse that handle; they call GetBody to obtain fresh readers.

    If you create a body but never pass the request to Client.Do, you remain responsible for closing that body yourself.

    Seekable Streams Are Not the Same as Replayable Request Bodies

    A file is seekable, but that does not automatically make it replayable from the HTTP client's perspective.

    GetBody explicitly tells net/http how to create a fresh body:

    req.GetBody = func() (io.ReadCloser, error) {
    	return os.Open(path)
    }

    Do not assume that Seek alone is enough for redirect handling.

    Common Cases

    For these body types, http.NewRequest automatically sets GetBody:

    bytes.NewBuffer(data)
    bytes.NewReader(data)
    strings.NewReader(data)

    For a file or another custom stream, you may need to provide it yourself.

    For large uploads, also consider whether replaying the entire body after a redirect is acceptable. A redirect can turn one large upload into multiple uploads.


    2. Treat CheckRedirect as a Policy Hook

    CheckRedirect receives two arguments:

    CheckRedirect func(req *http.Request, via []*http.Request) error

    req is the upcoming request.

    via contains the requests already made, oldest first.

    For example:

    via[0]  → original request
    via[1]  → first redirected request
    via[2]  → second redirected request
    
    req     → next request about to be sent

    If there is no redirect yet, via is empty.

    The default policy stops after 10 consecutive requests. Providing your own CheckRedirect replaces that default redirect-count policy, so a custom implementation should enforce its own limit if necessary.

    A simple limit:

    client := &http.Client{
    	CheckRedirect: func(req *http.Request, via []*http.Request) error {
    		if len(via) >= 5 {
    			return fmt.Errorf("too many redirects")
    		}
    		return nil
    	},
    }

    Stop Without Treating the Redirect as an Error

    If you want the redirect response itself rather than following it, use:

    return http.ErrUseLastResponse

    For example:

    client := &http.Client{
    	CheckRedirect: func(req *http.Request, via []*http.Request) error {
    		return http.ErrUseLastResponse
    	},
    }

    This returns the most recent response without closing its body.

    By contrast, returning an ordinary error stops the redirect and the previous response body is closed. The error is wrapped in *url.Error.

    CheckRedirect Does Not Own Header Sanitization

    net/http applies its built-in redirect header policy while constructing the next request, before CheckRedirect is called.

    A custom CheckRedirect does not disable that built-in policy.

    However, your callback receives the new request and can still modify it. Therefore, custom redirect logic can deliberately override the request headers that net/http prepared.

    The req passed to CheckRedirect is a new request object for the next hop, with its own independent Header map.


    3. Define the Redirect Trust Boundary

    The most important question is not:

    "Should I follow redirects?"

    It is:

    "Which destinations am I willing to trust?"

    For an API client, you may want to remain on the same host:

    client := &http.Client{
    	CheckRedirect: func(req *http.Request, via []*http.Request) error {
    		if len(via) == 0 {
    			return nil
    		}
    
    		if req.URL.Host != via[0].URL.Host {
    			return fmt.Errorf("redirect leaves trusted host: %s", req.URL.Host)
    		}
    
    		return nil
    	},
    }

    For a stricter policy, validate the destination against an explicit allowlist instead of trusting arbitrary hostnames.

    Do not assume that a redirect to a familiar-looking hostname is safe.


    4. Understand How Credentials Are Forwarded

    Redirect handling has security consequences when requests contain credentials.

    Go's net/http client strips sensitive headers such as Authorization when redirecting to a domain that is neither the original domain nor a subdomain of it.

    For example:

    api.example.com → api.example.com

    Sensitive headers may be retained.

    And:

    api.example.com → sub.api.example.com

    Sensitive headers may also be retained.

    But:

    api.example.com → attacker.example.net

    Sensitive headers are stripped.

    This is intentional. Go's redirect policy permits subdomains, unlike the browser Fetch standard. The Go security documentation explicitly describes this behavior.

    That means a subdomain is not automatically a separate credential boundary.

    If your application considers:

    api.example.com
    sub.api.example.com

    to be different security domains, enforce that policy yourself.

    Reject Cross-Host Redirects

    For an API client carrying credentials, a conservative policy is often simpler:

    client := &http.Client{
    	CheckRedirect: func(req *http.Request, via []*http.Request) error {
    		if len(via) > 0 && req.URL.Host != via[0].URL.Host {
    			return fmt.Errorf("cross-host redirect rejected")
    		}
    		return nil
    	},
    }

    Strip Credentials Explicitly When Appropriate

    If your application allows the redirect but does not want credentials forwarded:

    client := &http.Client{
    	CheckRedirect: func(req *http.Request, via []*http.Request) error {
    		if len(via) > 0 && req.URL.Host != via[0].URL.Host {
    			req.Header.Del("Authorization")
    		}
    		return nil
    	},
    }

    There is no need to clone req.Header first: the redirect request already has its own header map.

    Cookies Are Different

    Cookies deserve separate treatment.

    If the client has a CookieJar, cookies can change as redirects are followed. The jar decides which cookies belong to the destination.

    Do not treat Cookie as equivalent to a manually supplied Authorization header.


    5. Redirects and SSRF

    Following redirects makes SSRF defenses harder.

    Suppose your application allows:

    https://example.com

    but the server responds:

    Location: http://169.254.169.254/

    A client that blindly follows redirects may reach an internal service.

    Hostname allowlists alone can also be insufficient.

    Consider:

    https://example.com
    
    https://redirect.example.com
    
    https://internal.example.com

    Every hostname may look legitimate while the final destination violates your actual network policy.

    DNS adds another layer.

    If a later redirect hop requires another DNS resolution, the resolved address can differ from an earlier resolution. This matters when an attacker can influence DNS or redirect destinations.

    For security-sensitive clients, validate every redirect destination against the actual policy you need:

    • allowed schemes
    • allowed hosts
    • allowed ports
    • allowed IP ranges
    • private and link-local address restrictions
    • maximum redirect count

    Do not rely on redirect behavior alone as an SSRF defense.


    6. Redirect Response Bodies Still Have a Lifecycle

    When following a redirect, the intermediate response body is not the final response body.

    The client reads and closes the redirect response before continuing.

    If CheckRedirect returns an ordinary error, the response associated with that failed redirect is also handled by net/http; the caller receives the previous response and the redirect error rather than an open intermediate body.

    If you return:

    http.ErrUseLastResponse

    the most recent response is returned instead, with its body left open for the caller.

    This distinction matters when writing custom redirect policies.


    7. Put a Timeout Around the Entire Operation

    Redirects are part of the HTTP request lifecycle.

    A client timeout covers the overall operation, including redirects and reading the response body:

    client := &http.Client{
    	Timeout: 15 * time.Second,
    }

    For request-specific deadlines, use a context:

    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()
    
    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()

    A redirect chain does not reset the request's overall deadline.

    If the operation involves a large streaming response, be careful with Client.Timeout: the timeout remains active while the response body is being read.

    For more detailed control, configure transport-level timeouts separately, such as:

    • DialContext
    • TLSHandshakeTimeout
    • ResponseHeaderTimeout
    • IdleConnTimeout

    Use those when you need phase-specific behavior rather than one overall deadline.


    8. Distinguish Redirect Success from HTTP Success

    Client.Do returning nil error does not mean the server returned a successful HTTP status.

    A non-2xx response is not itself a Client.Do error. Redirects may also have been followed successfully before the final response is returned.

    After the redirect chain completes, check the final response:

    // Check the final response after the redirect chain completes.
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
    	return fmt.Errorf("unexpected HTTP status: %s", resp.Status)
    }

    This check does not inspect intermediate 301, 302, or other redirect responses. Those have already been handled by the client.

    Always close the final response body:

    defer resp.Body.Close()

    Then read it using an appropriate size limit rather than assuming the response is small.


    9. A Minimal Production Policy

    For an authenticated API client, a reasonable starting point is:

    client := &http.Client{
    	Timeout: 15 * time.Second,
    
    	CheckRedirect: func(req *http.Request, via []*http.Request) error {
    		if len(via) >= 5 {
    			return fmt.Errorf("too many redirects")
    		}
    
    		if len(via) > 0 && req.URL.Host != via[0].URL.Host {
    			return fmt.Errorf("cross-host redirect rejected")
    		}
    
    		return nil
    	},
    }

    Then:

    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    
    req, err := http.NewRequestWithContext(
    	ctx,
    	http.MethodGet,
    	url,
    	nil,
    )
    if err != nil {
    	return err
    }
    
    req.Header.Set("Authorization", "Bearer "+token)
    
    resp, err := client.Do(req)
    if err != nil {
    	return err
    }
    defer resp.Body.Close()
    
    // Check the final response after the redirect chain completes.
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
    	return fmt.Errorf("unexpected HTTP status: %s", resp.Status)
    }

    This gives you three independent controls:

    1. How many redirects are allowed
    2. Where redirects are allowed to go
    3. How long the entire operation may run

    10. Debugging Redirect Chains

    When redirect behavior is surprising, httptrace can help expose what the transport is doing.

    For example:

    trace := &httptrace.ClientTrace{
    	GotConn: func(info httptrace.GotConnInfo) {
    		log.Printf("connection reused=%v", info.Reused)
    	},
    }
    
    ctx := httptrace.WithClientTrace(ctx, trace)
    
    req, err := http.NewRequestWithContext(
    	ctx,
    	http.MethodGet,
    	url,
    	nil,
    )
    if err != nil {
    	return err
    }

    Use tracing to investigate connection reuse, DNS, TLS, and request timing.

    It is a diagnostic tool, not a replacement for a redirect policy.


    Gotchas & Pitfalls

    Non-replayable Request Bodies Cannot Be Reused on 307/308 Redirects

    307 and 308 preserve the method and body.

    If GetBody is unavailable, the client cannot safely recreate the body for another request.

    For non-replayable streams, do not assume a redirect can be followed transparently.

    Seekable Does Not Mean Replayable

    A stream may support Seek without providing the semantics required by net/http.

    If a request may be redirected and the body must be resent, provide GetBody.

    A Subdomain Is Not Automatically a Separate Credential Boundary

    Go may retain sensitive headers when redirecting from a host to one of its subdomains.

    If your security model treats subdomains as separate trust domains, enforce that explicitly.

    Custom CheckRedirect Can Change the Prepared Request

    net/http applies its built-in redirect header policy before calling CheckRedirect.

    Your callback can still modify the upcoming request afterward.

    Do not assume the request you receive is immutable.

    ErrUseLastResponse Is Different from a Normal Redirect Error

    Returning:

    http.ErrUseLastResponse

    returns the latest response with its body open.

    Returning another error causes the redirect to stop and the previous response to be returned with its body already closed.

    Do Returning nil Error Does Not Mean 2xx

    HTTP status handling is your application's responsibility.

    Check the final response status explicitly.

    Redirects Can Multiply Large Uploads

    A 307 or 308 redirect can cause a large request body to be sent again.

    For large uploads, redirect policy is also a bandwidth and latency decision.

    Redirects Can Cross Security Boundaries

    A URL that is safe to request directly may redirect somewhere unsafe.

    Treat every redirect destination as another untrusted input.


    Production Checklist

    Before enabling automatic redirects for a security-sensitive client:

    • Set an overall timeout.
    • Limit the number of redirects.
    • Decide whether cross-host redirects are allowed.
    • Decide whether subdomain redirects are within your trust boundary.
    • Understand how Authorization and cookies are forwarded.
    • Provide GetBody when a request body must survive 307 or 308.
    • Treat file and stream ownership explicitly.
    • Consider SSRF when destinations are user-controlled.
    • Validate every redirect destination when network access is restricted.
    • Check the final HTTP status after redirects complete.
    • Always close the final response body.
    • Use tracing when redirect or connection behavior needs investigation.

    Redirects are not just a convenience feature. They are part of the request's trust boundary, lifecycle, and failure model.

    For production Go code, make that policy explicit rather than letting a server-controlled Location header decide where your client sends the next request.