Go's http.Client: When Redirects Strip Authorization
An API request succeeds when sent directly, but fails with a 401 when it goes through an apparently harmless redirect. The bearer token was present on the first request. It is absent on the second.
GET /v1/report HTTP/1.1
Host: api.example.net
Authorization: Bearer redacted
HTTP/1.1 302 Found
Location: https://storage.example.org/report
GET /report HTTP/1.1
Host: storage.example.org
This is deliberate. Go's http.Client copies ordinary headers while following redirects, but treats several authentication and cookie headers as sensitive. It will not copy them to a destination whose hostname is unrelated to the hostname on the initial request.
That sounds simple. The details are slightly stranger, particularly around subdomains, ports and HTTPS downgrades.
The rule Go actually applies
For a redirect from an initial URL to a new URL, Go retains sensitive headers when the destination hostname is either:
- the same hostname as the initial request; or
- a subdomain of the initial hostname.
Otherwise it strips Authorization, WWW-Authenticate, Cookie, Cookie2, Proxy-Authorization and Proxy-Authenticate. The exact list is visible in the net/http client source.
Some concrete cases make the direction of the rule clearer:
api.example.comtoapi.example.com: retained.example.comtoapi.example.com: retained.api.example.comtoexample.com: stripped.api.example.comtologin.example.com: stripped.example.comtoexample.net: stripped.
The subdomain case is intentionally asymmetric. A credential attached to a request for example.com may be forwarded down to api.example.com, but a credential sent to the narrower api.example.com is not broadened back to the parent domain.
Actually, this bit is interesting: Go compares hostnames, not origins. The scheme and port are not part of the trust decision. Consequently, a redirect from https://api.example.com:8443 to https://api.example.com:9443 retains Authorization. So can a redirect from https://api.example.com to http://api.example.com.
The latter is particularly unpleasant. The Client documentation explicitly describes redirect handling as permissive by modern standards, including retaining sensitive headers across a scheme change on the same host. Go suppresses the Referer header on an HTTPS to HTTP downgrade, but that separate protection does not remove Authorization.
CheckRedirect does not automatically restore the header
A common attempt at fixing the 401 looks like this:
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return nil
},
}
This only permits the redirect. It does not tell Go to copy credentials everywhere. Before calling CheckRedirect, the client has already constructed the upcoming request and applied its header-copying rules. Inspecting req.Header.Get("Authorization") inside the hook therefore shows what the redirected request is about to contain.
The via slice contains earlier requests, oldest first, while req is the proposed next request. That makes the hook a useful place to log the redirect without logging the credential itself:
CheckRedirect: func(req *http.Request, via []*http.Request) error {
previous := via[len(via)-1]
log.Printf(
"redirect: %s -> %s authorization_present=%t",
previous.URL.Redacted(),
req.URL.Redacted(),
req.Header.Get("Authorization") != "",
)
return nil
},
There is a trap hiding here too. Supplying CheckRedirect replaces the default policy, which stops after ten redirects. A custom hook that always returns nil has removed that limit. Redirect loops will eventually stop for some other reason, perhaps a timeout, which is a fairly silly way to discover them.
Use a stricter policy for authenticated clients
For a client carrying bearer tokens or Basic credentials, I generally prefer an exact-origin policy. It prevents both cross-host credential forwarding and same-host HTTPS downgrades:
client := &http.Client{
Timeout: 15 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
initial := via[0].URL
sameScheme := strings.EqualFold(req.URL.Scheme, initial.Scheme)
sameHost := strings.EqualFold(req.URL.Host, initial.Host)
if !sameScheme || !sameHost {
return fmt.Errorf("refusing redirect to %s", req.URL.Redacted())
}
return nil
},
}
This compares URL.Host, which includes the port, rather than URL.Hostname(). It is deliberately conservative: example.com and example.com:443 are rejected as different spellings even though they normally reach the same HTTPS service. Normalise known origins first if that distinction is too strict for the application.
If an API legitimately redirects to a separate download service, use a small allowlist of exact HTTPS origins. Do not merely copy the old header to every destination:
allowed := map[string]bool{
"https://api.example.com": true,
"https://downloads.example.com": true,
}
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
origin := strings.ToLower(req.URL.Scheme + "://" + req.URL.Host)
if !allowed[origin] {
return fmt.Errorf("redirect origin is not allowed: %s", origin)
}
return nil
}
Even an allowlisted service should not automatically receive the original bearer token unless it is meant to accept that token. Tokens can be audience-bound, and a storage service often expects a signed URL rather than the API credential. A redirect is not evidence that two services share an authentication boundary.
Stopping and inspecting a redirect
Returning http.ErrUseLastResponse from the hook stops redirect processing and returns the redirect response with a readable body and a nil error. This is useful when the application wants to inspect Location and make a fresh, explicitly authenticated request:
client := &http.Client{
Timeout: 15 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
Returning an ordinary error also stops the redirect, but Client.Do returns it wrapped in *url.Error, and the previous response body is closed. That distinction matters if the redirect response itself contains useful diagnostic material.
One historical edge worth checking
Older Go releases had a multi-hop flaw: after stripping sensitive headers for a cross-domain redirect, the client could restore them on a later same-domain hop. A chain from a.example to b.example and then another URL on b.example could therefore leak the original credential on the third request.
This was fixed in Go 1.22.11 and Go 1.23.5, and before the Go 1.24 release candidate. The official GO-2025-3420 vulnerability report records the affected versions and CVE-2024-45336. If authenticated HTTP clients run on anything older, upgrading is part of the fix. A clever redirect hook is not a substitute for corrected standard-library state tracking.
Go's default protects against the obvious cross-domain leak, but it is a hostname inheritance rule rather than a strict security boundary. Once credentials and redirects occupy the same client, make the acceptable destinations explicit. Otherwise a 302 response is quietly making an authentication decision on the client's behalf.