Skip to content
gohacks.dev
Web

Redirects in Go and the Right Status Code to Use for Each

Pick between 301, 302, 303, 307 and 308 in Go with http.Redirect, see which keep the method and body, and avoid open redirects from a query value.

4 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

A redirect is two decisions: where to send the client, and whether the client may change the method on the way. Go's http.Redirect handles the mechanics, and the status code carries both decisions, so picking it deliberately saves a class of subtle bugs, such as a form that quietly turns into a GET and loses its body.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

The program below sends a POST through each code with a client that follows redirects, and the server reports what it received on the second hop.

main.goGo
package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
)

func main() {
	mux := http.NewServeMux()
	for _, code := range []int{301, 302, 303, 307, 308} {
		mux.HandleFunc(fmt.Sprintf("/go-%d", code), func(w http.ResponseWriter, r *http.Request) {
			http.Redirect(w, r, "/landed", code)
		})
	}
	mux.HandleFunc("/landed", func(w http.ResponseWriter, r *http.Request) {
		body, _ := io.ReadAll(r.Body)
		fmt.Fprintf(w, "method=%s body=%q", r.Method, body)
	})
	srv := httptest.NewServer(mux)
	defer srv.Close()

	for _, code := range []int{301, 302, 303, 307, 308} {
		res, err := http.Post(fmt.Sprintf("%s/go-%d", srv.URL, code), "text/plain", strings.NewReader("payload"))
		if err != nil {
			panic(err)
		}
		out, _ := io.ReadAll(res.Body)
		res.Body.Close()
		fmt.Printf("POST via %d -> %s\n", code, out)
	}

	// What the first response itself looks like, without following it.
	client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
	res, _ := client.Get(srv.URL + "/go-308")
	fmt.Println("first hop:", res.Status, "Location:", res.Header.Get("Location"))
}
Text
POST via 301 -> method=GET body=""
POST via 302 -> method=GET body=""
POST via 303 -> method=GET body=""
POST via 307 -> method=POST body="payload"
POST via 308 -> method=POST body="payload"
first hop: 308 Permanent Redirect Location: /landed

301, 302 and 303 all turned the POST into a GET with no body; 307 and 308 kept both. That is the whole difference in practice. The permanent and temporary split is for caches and search engines: browsers may remember a 301 or 308 indefinitely, so use them only for moves you will never undo, and use 302 or 307 while you are unsure. Pick the method-preserving pair when the request has a body that must arrive intact, for instance when you move an API endpoint, and 303 for the common form flow, where the handler saves the data and sends the browser to a result page with a GET, so a refresh shows the page and does not repost the form.

A note on what http.Redirect does for you: it writes Location, adds a short HTML body for GET and HEAD, and cleans a relative path (../x) against the request path. The status-code semantics are in RFC 9110 section 15.4.

The mistake that matters is redirecting to a target the client supplied. A login page that sends the user to ?next= after sign-in can be pointed at https://evil.example in a phishing link. Accept only paths on your own site: reject anything that does not start with a single /, and reject //evil.example and a backslash variant of it, which browsers also treat as another host. Check it with url.Parse and require an empty Host and Scheme. The same discipline applies in the login article and to any handler that redirects based on input. A small helper is enough:

Go
func safeNext(next string) string {
	u, err := url.Parse(next)
	if err != nil || u.Scheme != "" || u.Host != "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
		return "/"
	}
	return next
}

Two more redirect habits worth having. Redirect from http to https with 301 or 308 at the edge, and add Strict-Transport-Security so browsers skip the redirect next time, as covered in the autocert article. And mind redirect loops: a trailing-slash rule that adds a slash in one handler while another strips it produces ERR_TOO_MANY_REDIRECTS. The Go client stops after ten redirects with an error that includes the last URL, which makes the loop easy to find. When you write a client that must not follow redirects, for example one that checks a URL's status, set CheckRedirect to return http.ErrUseLastResponse as the program does, and read the Location header yourself. The second reference for the codes is the MDN redirect guide.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.