CORS: letting another origin read your replies

What a browser is really asking with CORS, how to answer a preflight, and why a wildcard origin is rarely right.

Syntaxw.Header().Set("Access-Control-Allow-Origin", origin)

An origin is a scheme, a host and a port taken together, such as https://app.example.com. Browsers enforce a rule called the same-origin policy: code running on one origin may send a request to another, but it may not read the reply unless the replying server says so. CORS, Cross-Origin Resource Sharing, is that permission, expressed as response headers.

What the browser is asking

For a plain GET the browser sends an Origin header and then checks the reply for Access-Control-Allow-Origin. If it is missing or different, the browser throws the response away and the caller sees a network error.

For anything else, including a PUT, a DELETE, a JSON content type or an Authorization header, the browser first sends a preflight: an OPTIONS request carrying Access-Control-Request-Method and Access-Control-Request-Headers. Answer it with Access-Control-Allow-Methods, Access-Control-Allow-Headers, an optional Access-Control-Max-Age in seconds, a 2xx status and no body. Only then does the real request go out.

Why the wildcard is rarely right

Access-Control-Allow-Origin: * lets every site on the web read the reply. For a public, read-only endpoint that is honest. As soon as cookies or credentials are involved the browser refuses the wildcard: you must echo the exact origin and add Access-Control-Allow-Credentials: true. So keep an allow-list, compare Origin against it, and add Vary: Origin so no cache hands one site's permission to another.

CORS governs browsers only. curl ignores it, so authorisation still has to be checked in the handler.

Example

Example · go
package main

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

var allowed = map[string]bool{"https://app.example.com": true}

func cors(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		origin := r.Header.Get("Origin")
		w.Header().Add("Vary", "Origin") // the reply depends on this header
		if allowed[origin] {
			w.Header().Set("Access-Control-Allow-Origin", origin)
			w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
			w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
		}
		if r.Method == http.MethodOptions { // preflight: headers only, no body
			w.WriteHeader(http.StatusNoContent)
			return
		}
		next.ServeHTTP(w, r)
	})
}

func main() {
	h := cors(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, "ok")
	}))

	req := httptest.NewRequest(http.MethodOptions, "/items", nil)
	req.Header.Set("Origin", "https://app.example.com")
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	fmt.Println(rec.Code, rec.Header().Get("Access-Control-Allow-Origin"))

}

// Output:
// 204 https://app.example.com

When to use it

  • Let a single-page app on a different domain call your JSON API.
  • Allow http://localhost:5173 during development without opening the API to everyone.
  • Make an Authorization header work from the browser by answering the preflight.
  • Publish a read-only endpoint that any site may fetch.

More examples

Credentialed CORS for a front end that sends cookies

The shape to use when the browser sends a session cookie or an Authorization header with each call. The list holds the production origin and the local development server. slices.Contains is in the standard library from Go 1.21 and reads better than a hand-written loop.

Example · go
package main

import (
	"net/http"
	"slices"
)

var origins = []string{"https://app.example.com", "http://localhost:5173"}

func corsCredentials(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		origin := r.Header.Get("Origin")
		w.Header().Add("Vary", "Origin")

		if slices.Contains(origins, origin) {
			// With credentials the exact origin is required. A wildcard here
			// makes the browser reject the whole response.
			w.Header().Set("Access-Control-Allow-Origin", origin)
			w.Header().Set("Access-Control-Allow-Credentials", "true")
			w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
			w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
			w.Header().Set("Access-Control-Max-Age", "600") // cache the preflight
		}

		if r.Method == http.MethodOptions {
			w.WriteHeader(http.StatusNoContent)
			return
		}
		next.ServeHTTP(w, r)
	})
}

Letting the router answer the preflight

Useful when only one or two routes are called cross-origin, so a blanket middleware would be more than you need. Reproduce the preflight by hand with: curl -i -X OPTIONS http://localhost:8080/items -H 'Origin: https://app.example.com' -H 'Access-Control-Request-Method: POST'

Example · go
package main

import (
	"log"
	"net/http"
)

func main() {
	mux := http.NewServeMux()

	// Method patterns need Go 1.22 or newer. An explicit OPTIONS route keeps
	// the preflight out of the handler that does the real work.
	mux.HandleFunc("OPTIONS /items", func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("Access-Control-Request-Method") == "" {
			http.Error(w, "not a preflight", http.StatusBadRequest)
			return
		}
		w.Header().Set("Access-Control-Allow-Origin", "https://app.example.com")
		w.Header().Set("Access-Control-Allow-Methods", "POST")
		w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
		w.Header().Add("Vary", "Origin")
		w.WriteHeader(http.StatusNoContent)
	})

	mux.HandleFunc("POST /items", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Access-Control-Allow-Origin", "https://app.example.com")
		w.WriteHeader(http.StatusCreated)
	})

	log.Fatal(http.ListenAndServe(":8080", mux))
}

A test that proves an unknown origin gets nothing

CORS mistakes are silent until a browser hits them, which is why this belongs in a test. Add a row for every origin you deliberately allow; a change that widens the list then has to change the test as well.

Example · go
package main

import (
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestCORSAllowList(t *testing.T) {
	h := cors(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))

	cases := []struct {
		name   string
		origin string
		want   string
	}{
		{"known origin is echoed", "https://app.example.com", "https://app.example.com"},
		{"unknown origin gets no header", "https://evil.example", ""},
		{"no origin at all", "", ""},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			req := httptest.NewRequest(http.MethodGet, "/items", nil)
			if c.origin != "" {
				req.Header.Set("Origin", c.origin)
			}
			rec := httptest.NewRecorder()
			h.ServeHTTP(rec, req)

			got := rec.Header().Get("Access-Control-Allow-Origin")
			if got != c.want {
				t.Errorf("got %q, want %q", got, c.want)
			}
		})
	}
}

Discussion

  • Be the first to comment on this lesson.