A redirect is a response that asks a client to make a new request at a different target. Its Location field and status code describe both destination and method behavior. Browsers often follow redirects automatically, but a library can stop, inspect, or reject them. Treat every hop as observable protocol data: the final page alone cannot tell you whether a POST became a GET, whether credentials crossed an origin boundary, or whether a loop consumed the retry budget.
Permanent and temporary mean different things
301 and 308 describe a permanent move; 302 and 307 describe a temporary one. 303 explicitly asks the client to retrieve a representation with GET (or HEAD). Permanent status can be cached and copied into bookmarks and search indexes, so do not use it for a maintenance detour. A temporary status tells clients to keep the original address for later attempts. RFC 9111’s cache rules and the resource’s policy still matter; “permanent” is not a command to forget authorization or to trust an arbitrary host.
Method preservation is explicit
For historical compatibility, many clients change POST to GET after 301, 302, or 303 and drop the body. 307 and 308 preserve the original method and body, provided the client can replay that body. A redirect does not make a non-idempotent action safe, and preserving a method does not prove that the origin accepted the replay. The server should return a redirect whose semantics match the operation, while the client should require a replayable body and an appropriate retry policy.
Relative Location values resolve against the effective request URI. A client should record each resolved hop and enforce a maximum count. A missing Location leaves no new target to follow. A self-loop or a long chain is a server defect, not a reason to increase the limit indefinitely. Redirects can also cross schemes, ports, or origins. Apply an explicit allowlist before forwarding cookies, bearer tokens, or client certificates; a link to another origin is not permission to send credentials there.
When it breaks
A login proxy may redirect an API POST to HTML, and a convenience client may silently turn it into GET. A permanent redirect cached at an edge can keep sending traffic to a retired host after a rollback. A 307 with a streaming or one-shot body may be impossible to replay, so the client must expose the original response or return a clear error rather than inventing an empty request. Redirect middleware can also follow an attacker-controlled absolute URL, leak an Authorization header, or hide a loop until its timeout.
Observe status, Location, method, body length, final origin, and response body at every hop. Disable automatic following while diagnosing. Do not assume a successful final 200 means the original operation happened: the final resource may be a different representation. Use one policy for same-origin redirects and a stricter one for cross-origin moves. Keep credentials out of logs and test rollback paths before publishing a permanent move.
Check it quickly
Save this as redirect_probe.go and run it with Go 1.23 or newer. Two loopback servers record every request. The client follows same-origin redirects only, permits at most three requests including the initial one, refuses a different origin, and stops when a request body cannot be replayed. The destination echoes the method and body it actually received.
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
type visit struct {
Path, Method, Body string
}
func must(err error) {
if err != nil {
panic(err)
}
}
func check(ok bool, message string) {
if !ok {
panic(message)
}
}
func main() {
var foreignCalls atomic.Int64
foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
foreignCalls.Add(1)
w.WriteHeader(http.StatusNoContent)
}))
defer foreign.Close()
var mu sync.Mutex
var visits []visit
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1024))
must(err)
seen := visit{r.URL.Path, r.Method, string(body)}
mu.Lock()
visits = append(visits, seen)
mu.Unlock()
if r.URL.Path == "/destination" {
w.Header().Set("Content-Type", "application/json")
must(json.NewEncoder(w).Encode(seen))
return
}
status, location := http.StatusFound, ""
switch r.URL.Path {
case "/loop":
location = "/loop"
case "/foreign":
location = foreign.URL + "/destination"
case "/missing":
default:
status, err = strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/r/"))
must(err)
location = "/destination"
}
if location != "" {
w.Header().Set("Location", location)
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
fmt.Fprint(w, "redirect")
}))
server.Config.ReadHeaderTimeout = time.Second
server.Config.ReadTimeout = 2 * time.Second
server.Start()
defer server.Close()
origin, err := url.Parse(server.URL)
must(err)
errOrigin := errors.New("redirect changes origin")
errBudget := errors.New("redirect budget exhausted")
transport := &http.Transport{}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 3 * time.Second,
CheckRedirect: func(request *http.Request, via []*http.Request) error {
if request.URL.Scheme != origin.Scheme || request.URL.Host != origin.Host {
return errOrigin
}
if len(via) >= 3 {
return errBudget
}
return nil
},
}
read := func(response *http.Response) []byte {
body, readError := io.ReadAll(response.Body)
closeError := response.Body.Close()
must(readError)
must(closeError)
check(response.Proto == "HTTP/1.1", "HTTP/1.1 fixture required")
return body
}
request := func(route string, body io.Reader) *http.Request {
req, err := http.NewRequest(http.MethodPost, server.URL+route, body)
must(err)
return req
}
snapshot := func() []visit {
mu.Lock()
defer mu.Unlock()
return append([]visit(nil), visits...)
}
const payload = "action=replace"
follow := func(status int) {
route := "/r/" + strconv.Itoa(status)
req := request(route, strings.NewReader(payload))
check(req.GetBody != nil, "fixture body must be replayable")
before := len(snapshot())
response, err := client.Do(req)
must(err)
var final visit
must(json.Unmarshal(read(response), &final))
method, body := "POST", payload
if status == 301 || status == 302 || status == 303 {
method, body = "GET", ""
}
check(response.StatusCode == 200 && response.Request.URL.Path == "/destination", "wrong final response")
check(final == (visit{"/destination", method, body}), "wrong redirected method/body")
actual := snapshot()[before:]
check(len(actual) == 2 && actual[0] == (visit{route, "POST", payload}) && actual[1] == final, "both wire hops must match")
fmt.Printf("%d: POST -> %s body=%q\n", status, method, body)
}
for _, status := range []int{301, 302, 303, 307, 308} {
follow(status)
}
stopped := *client
stopped.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
before := len(snapshot())
response, err := stopped.Do(request("/r/307", strings.NewReader(payload)))
must(err)
check(string(read(response)) == "redirect" && response.StatusCode == 307, "inspect original redirect")
check(response.Header.Get("Location") == "/destination" && len(snapshot()) == before+1, "no-follow must stop before target")
fmt.Println("no-follow: 307 with relative Location; target not requested")
before = len(snapshot())
unreplayable := request("/r/307", io.NopCloser(strings.NewReader(payload)))
unreplayable.ContentLength = int64(len(payload))
check(unreplayable.GetBody == nil, "fixture must not support replay")
response, err = client.Do(unreplayable)
must(err)
check(string(read(response)) == "redirect" && response.StatusCode == 307, "Go must retain the unreplayable redirect")
check(len(snapshot()) == before+1, "unreplayable body must not reach target")
fmt.Println("unreplayable: 307 returned without following")
for _, test := range []struct {
route string
err error
hops int
}{{"/foreign", errOrigin, 1}, {"/loop", errBudget, 3}} {
before = len(snapshot())
response, err = client.Do(request(test.route, strings.NewReader(payload)))
check(errors.Is(err, test.err), "wrong redirect policy error")
if response != nil {
_ = response.Body.Close() // Already closed by Client.Do on policy rejection.
}
check(len(snapshot()) == before+test.hops, "wrong number of admitted hops")
fmt.Println(test.route, test.err)
}
check(foreignCalls.Load() == 0, "different-origin target must never be contacted")
before = len(snapshot())
response, err = client.Do(request("/missing", strings.NewReader(payload)))
must(err)
check(string(read(response)) == "redirect" && response.StatusCode == 302, "missing Location must retain 302")
check(len(snapshot()) == before+1, "missing Location cannot select a target")
fmt.Println("missing Location: original 302 returned")
follow(307)
check(len(snapshot()) == 19 && foreignCalls.Load() == 0, "wrong final request counts")
fmt.Println("nineteen same-origin requests; zero different-origin requests")
fmt.Println(runtime.Version())
}
The fixture prints five method comparisons: 301, 302, and 303 become GET without a body, while 307 and 308 remain POST with action=replace. A no-follow client sees the original 307 and does not request the destination. A non-replayable body also leaves the 307 un-followed. The origin policy rejects a foreign target before its server is contacted; a loop stops after three requests (two followed redirects); a missing Location returns the original 302. A final 307 succeeds, proving recovery. This isolates client redirect behavior and does not claim browser cookie policy, cache storage, or a safe business retry.
Further reading
Sources
- RFC 9110: HTTP Semantics, redirection status codes, Location resolution, and method semantics, checked 2026-09-09.
- RFC 9111: HTTP Caching, freshness, explicit cache controls, and stored response behavior, checked 2026-09-09.
- MDN: Redirections in HTTP, browser behavior and status-code distinctions, checked 2026-09-09.
- Go net/http, CheckRedirect, replayable bodies, and ErrUseLastResponse, checked 2026-09-09.