The same-origin policy in practice
The Web has always been a tug-of-war between attackers finding new exploits and browser vendors patching them. One of the oldest and most fundamental defensive mechanisms is the same-origin policy (SOP), which restricts what JavaScript running on one site can do with resources from another site.
The core problem: when you visit https://catvideos.meow, the browser executes JavaScript from that page. That JavaScript can fetch resources from other domains—and it's commonly used for images, analytics scripts, CDN modules and similar. But if the site is compromised, the same capability could be abused. Malicious code running on catvideos.meow could send requests to https://yourbank.com; since the browser executes that code in your session, it would include your saved cookies and login state. That's effectively like opening a new tab for your bank's site and letting the attacker drive.
To stop this, the SOP dictates that browsers only allow JavaScript to make cross-origin requests in a limited set of "safe" cases (mostly legacy behaviors like image loads and form submissions). A request is cross-origin when it goes from origin A to origin B and protocol, domain or port differ between the two:
If protocol, domain and port all match, the request is same-origin and always allowed—the URL path doesn't matter.
You can see the SOP in action with a minimal local setup. Create two HTML files in the same directory. One can be a bare page named page.html; the other, do-fetch.html, tries to fetch the first via the browser fetch() API:
<html>
<head>
<title>Fetch another page</title>
</head>
<body>
<script>
var url = 'http://127.0.0.1:8080/page.html'
fetch(url)
.then(response => {
console.log(response.status);
})
.catch(error => {
console.log("ERROR:", error);
});
</script>
</body>
</html>
Experiment 1: serve the directory on port 8080 using any static file server, then open http://127.0.0.1:8080/do-fetch.html and check the console. You'll see 200, the successful HTTP status, because the fetch is same-origin:
$ go install github.com/eliben/static-server@latest $ ls do-fetch.html page.html $ static-server -port 8080 . 2023/09/03 06:02:10.111818 Serving directory "." on http://127.0.0.1:8080
Experiment 2: while that server still runs, start a second instance of the same server on port 9999:
$ ls do-fetch.html page.html $ static-server -port 9999 . 2023/09/03 06:12:19.742790 Serving directory "." on http://127.0.0.1:9999
Now browse to http://127.0.0.1:9999/do-fetch.html. The fetch fails with something like this in the console:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://127.0.0.1:8080/page.html. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing).
The difference between the two cases is that the request from port 9999 to port 8080 is cross-origin because their ports differ, and the SOP blocks it by default. Note that the error message already references a CORS header—that's a hint about the mechanism that can override the SOP.
How CORS extends the SOP
Cross-Origin Resource Sharing (CORS) is an HTTP-header-based protocol that lets a server explicitly tell the browser which origins are allowed to load its resources. When a browser makes a cross-origin request, it adds an Origin header containing the requesting origin.
If you inspect the failed request in your browser's Network tab from Experiment 2, you'll see the browser sent this header to the server:
GET /page.html HTTP/1.1 Host: 127.0.0.1:8080 User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/117.0 Accept: */* Accept-Language: en-US,en;q=0.5 Accept-Encoding: gzip, deflate, br Referer: http://127.0.0.1:9999/ Origin: http://127.0.0.1:9999
That line tells the server where the request originates. In the response, the server didn't include the header Access-Control-Allow-Origin, which the browser treats as a denial. To make the request succeed, the server must explicitly reply with that header—either echoing the request's origin or using the wildcard * to accept any origin:
A Go API server with opt-in CORS
Static file servers are rarely the subject of CORS concerns; the real question is how to protect API endpoints. Here's a minimal Go server exposing a single hard-coded JSON endpoint at /api:
func apiHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, `{"message": "hello"}`)
}
func main() {
port := ":8080"
mux := http.NewServeMux()
mux.HandleFunc("/api", apiHandler)
http.ListenAndServe(port, mux)
}
Now create a client page that tries to call this API from a different origin:
<html>
<head>
<title>Access API through CORS</title>
</head>
<body>
<script>
var url = 'http://localhost:8080/api'
fetch(url)
.then(response => {
if (response.ok) {
return response.json();
} else {
throw new Error('Failed to fetch data');
}
})
.then(data => {
document.body.innerHTML += data.message;
})
.catch(error => {
document.body.innerHTML += "ERROR: " + error;
});
</script>
</body>
</html>
Serve that HTML file from the other static server instance on port 9999:
$ static-server -port 9999 . 2023/09/03 08:01:22.413757 Serving directory "." on http://127.0.0.1:9999
Opening http://127.0.0.1:9999/access-through-cors.html will fail with the same CORS error you saw in the earlier experiment:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://127.0.0.1:8080/api. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing).
The key point to internalize: CORS is opt-in. If a server never inspects the Origin header and never sends Access-Control-Allow-Origin, the browser assumes all cross-origin requests are denied. An oblivious server is, of necessity, a CORS-rejecting server.
To support CORS, implement it as standard Go middleware that wraps your HTTP handler:
var originAllowlist = []string{
"http://127.0.0.1:9999",
"http://cats.com",
"http://safe.frontend.net",
}
func checkCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if slices.Contains(originAllowlist, origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
}
w.Header().Add("Vary", "Origin")
next.ServeHTTP(w, r)
})
}
Here's the logic:
- Check whether the
Originheader is present; if absent, `Get` returns an empty string. - If the origin value matches an entry in an allow-list, echo it back in the response header
Access-Control-Allow-Origin. Set
Vary: Originon the response so shared caches don't serve one origin's response to another.If the header is missing or the origin isn't allow-listed, leave response headers untouched—that's equivalent to refusing the cross-origin request.
The allow-list approach is a template; you're free to implement any logic that fits your use case. Public-facing APIs that want to be callable from any origin can simply hard-code Access-Control-Allow-Origin: * and skip the Vary header entirely.
Finally, wire the middleware into the server so it wraps all endpoints you add later:
func main() {
port := ":8080"
mux := http.NewServeMux()
mux.HandleFunc("/api", apiHandler)
http.ListenAndServe(port, checkCORS(mux))
}
Restart the Go server and reload the client page: the fetch now returns "hello" with no console errors. Inspecting the response headers confirms why:
HTTP/1.1 200 OK Access-Control-Allow-Origin: http://127.0.0.1:9999 Content-Type: application/json Vary: Origin Date: Sun, 03 Sep 2023 16:33:00 GMT Content-Length: 21
The middleware added the CORS response headers that match the request's origin (http://127.0.0.1:9999), permitting the browser to expose the response to the page's JavaScript. As an exercise, change the middleware to reply with * instead of echoing the specific origin, restart the server, and verify the response header changes accordingly.
Preflight: Asking First
Some cross-origin requests carry inherent risk. A DELETE or PUT from a rogue origin could mutate server state before the browser has a chance to enforce CORS. That's where preflight comes in.
For anything that isn't a "simple" request — roughly, GET, HEAD, or POST with safe headers and content types — the browser first sends an OPTIONS request to the server before issuing the real one. This preflight asks the server whether it accepts the method and origin. Only an affirmative answer lets the browser proceed with the actual request.
The protocol works like this:
- The browser sends an
OPTIONSrequest with anOriginheader and anAccess-Control-Request-Methodheader declaring the intended method. - The server replies with
Access-Control-Allow-Methods, listing methods permitted for that origin. - If the requested method appears in the allow-list, the browser sends the original request. That request then goes through the normal CORS flow again.
There's a parallel mechanism for headers that aren't safe to send cross-origin. If the client includes such a header, the preflight carries an Access-Control-Request-Headers header, and the server must respond with Access-Control-Allow-Headers to confirm.
Handling Preflight in Go
To see preflight in action, first update the page's fetch call to use a non-simple method like DELETE:
var url = 'http://localhost:8080/api'
fetch(url, {method: 'DELETE'})
.then(response => {
if (response.ok) {
return response.json();
} else {
throw new Error('Failed to fetch data');
}
})
.then(data => {
document.body.innerHTML += data.message;
})
.catch(error => {
document.body.innerHTML += "ERROR: " + error;
});
Serving that page from 127.0.0.1:9999 and pointing at a server on port 8080 that handles CORS but no preflight yields an error in the console:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://localhost:8080/api. (Reason: Did not find method in CORS header ‘Access-Control-Allow-Methods’).
Looking at the network traffic shows the browser sent an OPTIONS request with these headers:
Access-Control-Request-Method: DELETE Origin: http://127.0.0.1:9999
The server, however, replies to every method with the same generic response — OPTIONS included — so no Access-Control-Allow-Methods header comes back:
HTTP/1.1 200 OK Access-Control-Allow-Origin: http://127.0.0.1:9999 Content-Type: application/json Vary: Origin Date: Sun, 03 Sep 2023 21:34:03 GMT Content-Length: 21
The preflight therefore fails, and the browser aborts without ever sending the DELETE request.
To fix this, the server needs to distinguish preflight requests and respond appropriately. A request counts as preflight only when all three conditions hold: it's an OPTIONS request, it has an Origin header, and it carries Access-Control-Request-Method:
func isPreflight(r *http.Request) bool {
return r.Method == "OPTIONS" &&
r.Header.Get("Origin") != "" &&
r.Header.Get("Access-Control-Request-Method") != ""
}
With that check in place, the middleware can branch: handle the preflight with its own response, or fall through to the normal CORS processing for actual requests:
var originAllowlist = []string{
"http://127.0.0.1:9999",
"http://cats.com",
"http://safe.frontend.net",
}
var methodAllowlist = []string{"GET", "POST", "DELETE", "OPTIONS"}
func checkCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isPreflight(r) {
origin := r.Header.Get("Origin")
method := r.Header.Get("Access-Control-Request-Method")
if slices.Contains(originAllowlist, origin) && slices.Contains(methodAllowlist, method) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", strings.Join(methodAllowlist, ", "))
}
} else {
// Not a preflight: regular request.
origin := r.Header.Get("Origin")
if slices.Contains(originAllowlist, origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
}
}
w.Header().Add("Vary", "Origin")
next.ServeHTTP(w, r)
})
}
Now the server replies to the preflight with the proper allow-list:
HTTP/1.1 200 OK Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS Access-Control-Allow-Origin: http://127.0.0.1:9999 Content-Type: application/json Vary: Origin Date: Sun, 03 Sep 2023 13:12:29 GMT Content-Length: 21
The browser then sends the actual DELETE request:
DELETE /api HTTP/1.1 Host: localhost:8080 User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/117.0 Accept: */* Accept-Language: en-US,en;q=0.5 Accept-Encoding: gzip, deflate, br Referer: http://127.0.0.1:9999/ Origin: http://127.0.0.1:9999
And that request succeeds:
HTTP/1.1 200 OK Access-Control-Allow-Origin: http://127.0.0.1:9999 Content-Type: application/json Vary: Origin Date: Sun, 03 Sep 2023 13:12:29 GMT Content-Length: 21
Credentials and Cross-Origin Requests
CORS also governs whether the browser attaches cookies to a cross-origin request. By default it won't. Consider a server that sets a cookie on a route like /getcookie:
func main() {
port := ":8080"
mux := http.NewServeMux()
mux.HandleFunc("/api", apiHandler)
mux.HandleFunc("/getcookie", getCookieHandler)
http.ListenAndServe(port, checkCORS(mux))
}
And a handler that sets it:
func getCookieHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Set-Cookie", "somekey=somevalue")
fmt.Fprintln(w, `{"message": "you're welcome"}`)
}
Visiting the route directly from the browser, the cookie appears in the response headers:
HTTP/1.1 200 OK Set-Cookie: somekey=somevalue Date: Sun, 03 Sep 2023 13:25:09 GMT Content-Length: 30 Content-Type: text/plain; charset=utf-8
And the browser associates it with 127.0.0.1:8080:
Now, a fetch from a page on port 9999 targeting the same server won't send that cookie unless explicitly told to. The credentials: 'include' option enables that:
<html>
<head>
<title>CORS with credentials</title>
</head>
<body>
<script>
var url = 'http://localhost:8080/api'
fetch(url, {credentials: "include"})
.then(response => {
if (response.ok) {
return response.json();
} else {
throw new Error('Failed to fetch data');
}
})
.then(data => {
document.body.innerHTML += data.message;
})
.catch(error => {
document.body.innerHTML += "ERROR: " + error;
});
</script>
</body>
</html>
The request now carries the cookie:
Cookie: somekey=somevalue
But the browser still reports a CORS error:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://localhost:8080/api. (Reason: expected ‘true’ in CORS header ‘Access-Control-Allow-Credentials’).
The reason: for requests with credentials, the server must respond with Access-Control-Allow-Credentials: true:
w.Header().Set("Access-Control-Allow-Credentials", "true")
With that header in place, the credentialed request succeeds. Note that for simple requests, the browser will send the cookie anyway — it simply blocks the response from reaching the page unless the server grants CORS permission with credentials. For non-simple requests, the preflight response must carry Access-Control-Allow-Credentials or the browser won't include cookies in the follow-up request at all.
Going Further
Most Go web projects won't need to implement CORS from scratch. Frameworks like Gin and Echo ship CORS middleware, and the rs/cors package works as a framework-agnostic option. For the underlying details, the fetch spec is the authoritative reference, and MDN has a thorough CORS guide. All sample code from this walkthrough is available on GitHub.



