Go CORS
last modified October 30, 2026
In this article we explain CORS and show how to handle it in a Go HTTP server.
Browsers enforce the same-origin policy: a script may read a response
from another origin only when that origin explicitly allows it. An origin is
the combination of scheme, host and port, so https://example.com
and https://api.example.com are different origins, and so are
http://localhost:3000 and http://localhost:8080.
CORS (Cross-Origin Resource Sharing) relaxes the policy with HTTP
headers. The browser sends Origin with the request, and the server
answers with Access-Control-Allow-Origin and, for preflight
requests, with the allowed methods, headers and cache lifetime.
| Header | Direction | Purpose |
|---|---|---|
Origin | request | Origin of the calling page. |
Access-Control-Allow-Origin | response | Allowed origin, or *. |
Access-Control-Allow-Methods | response | Methods accepted by a preflight request. |
Access-Control-Allow-Headers | response | Request headers accepted by a preflight request. |
Access-Control-Allow-Credentials | response | Whether cookies and HTTP authentication may be sent. |
Access-Control-Expose-Headers | response | Response headers that scripts may read. |
Access-Control-Max-Age | response | How long a preflight result may be cached, in seconds. |
The examples were compiled and run with Go 1.27.0 and tested with
curl. curl does not enforce CORS, so the responses show
the headers the browser would act on. The Date header is omitted
from the outputs because it changes on every request.
Allowing one origin
The smallest working setup adds Access-Control-Allow-Origin to the
response. The wildcard allows every origin and is suitable only for public,
unauthenticated data.
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
fmt.Fprintf(w, "hello %s\n", r.Method)
})
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
The response carries the CORS header together with the body. A browser on a different origin can now read the text; without the header it would hide the response from the script.
$ go run main.go # in another terminal $ curl -s -i http://127.0.0.1:8080/hello HTTP/1.1 200 OK Access-Control-Allow-Origin: * Content-Length: 10 Content-Type: text/plain; charset=utf-8 hello GET
Handling a preflight request
A request with a non-simple method or a custom header is preceded by an
OPTIONS preflight. The server must answer it with the allowed
origin, methods and headers, and should return an empty 204 response.
package main
import (
"fmt"
"log"
"net/http"
)
func hello(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "https://example.com")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
fmt.Fprintln(w, "hello from the server")
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/hello", hello)
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
The 204 response has no body and lists the method and header that the browser
asked about. A real request with the POST method then uses the same
handler and receives the ordinary body.
$ go run main.go # in another terminal
$ curl -s -i -X OPTIONS -H "Origin: https://example.com" \
-H "Access-Control-Request-Method: POST" http://127.0.0.1:8080/hello
HTTP/1.1 204 No Content
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Origin: https://example.com
Routing OPTIONS separately
Since Go 1.22 the pattern of a ServeMux route can include the HTTP
method. Registering GET and OPTIONS for the same path
keeps the CORS reply separate from the application logic.
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
fmt.Fprintln(w, "GET hello")
})
mux.HandleFunc("OPTIONS /hello", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.WriteHeader(http.StatusNoContent)
})
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
The preflight is answered by the OPTIONS /hello route, and a
GET request reaches the other one. Requests with other methods get
a 405 response from the mux.
$ go run main.go # in another terminal $ curl -s -i -X OPTIONS -H "Origin: https://example.com" http://127.0.0.1:8080/hello HTTP/1.1 204 No Content Access-Control-Allow-Methods: GET, OPTIONS Access-Control-Allow-Origin: *
A middleware with an origin allowlist
A production server usually allows a few known origins instead of every origin.
The middleware checks the Origin request header and echoes it back
only when it is on the list, adding Vary: Origin so that caches
keep the responses for different origins apart.
package main
import (
"fmt"
"log"
"net/http"
)
var allowed = map[string]bool{
"https://example.com": true,
"https://app.example.com": true,
}
func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if allowed[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Add("Vary", "Origin")
}
next.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /data", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, `{"value": 42}`)
})
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", withCORS(mux)))
}
The allowed origin receives the header, the other one does not, and both requests get the same body. The browser blocks the second response; a server that echoes every origin would allow any site to read the data.
$ go run main.go # in another terminal
$ curl -s -i -H "Origin: https://example.com" http://127.0.0.1:8080/data
$ curl -s -i -H "Origin: https://evil.example" http://127.0.0.1:8080/data
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://example.com
Vary: Origin
Content-Length: 14
Content-Type: text/plain; charset=utf-8
{"value": 42}
HTTP/1.1 200 OK
Content-Length: 14
Content-Type: text/plain; charset=utf-8
{"value": 42}
Cookies and credentials
When a request sends cookies or HTTP authentication, the browser requires
Access-Control-Allow-Credentials: true and an explicit origin.
The wildcard is not accepted together with credentials.
package main
import (
"fmt"
"log"
"net/http"
)
func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Origin") == "https://example.com" {
w.Header().Set("Access-Control-Allow-Origin", "https://example.com")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Add("Vary", "Origin")
}
next.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /me", func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err != nil {
fmt.Fprintln(w, "no session")
return
}
fmt.Fprintln(w, "session:", cookie.Value)
})
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", withCORS(mux)))
}
The handler reads the session cookie and the response allows the page on
https://example.com to use it. On the client side the fetch call
must also set credentials: "include".
$ go run main.go # in another terminal
$ curl -s -i -H "Origin: https://example.com" -H "Cookie: session=abc123" \
http://127.0.0.1:8080/me
HTTP/1.1 200 OK
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: https://example.com
Vary: Origin
Content-Length: 16
Content-Type: text/plain; charset=utf-8
session: abc123
One middleware for every route
Wrapping the whole mux applies CORS to every handler and answers all preflight requests in one place. The example also accepts a JSON body.
package main
import (
"encoding/json"
"log"
"net/http"
)
func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-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 {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("POST /echo", func(w http.ResponseWriter, r *http.Request) {
var msg struct {
Text string `json:"text"`
}
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"echo": msg.Text})
})
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", withCORS(mux)))
}
The preflight returns 204, and the POST is decoded and answered with JSON. The middleware sets the CORS headers before the handler writes the body, which is required because headers cannot be changed after the response starts.
$ go run main.go # in another terminal
$ curl -s -i -X OPTIONS -H "Origin: https://example.com" \
-H "Access-Control-Request-Method: POST" http://127.0.0.1:8080/echo
$ curl -s -i -X POST -H "Origin: https://example.com" \
-H "Content-Type: application/json" -d '{"text":"hi"}' \
http://127.0.0.1:8080/echo
HTTP/1.1 204 No Content
Access-Control-Allow-Headers: Content-Type
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Origin: *
HTTP/1.1 200 OK
Access-Control-Allow-Headers: Content-Type
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Origin: *
Content-Type: application/json
Content-Length: 14
{"echo":"hi"}
Caching preflight results
Access-Control-Max-Age tells the browser how long it may reuse a
preflight result. Without it, every non-simple request may be preceded by
another OPTIONS request.
package main
import (
"log"
"net/http"
)
func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Max-Age", "600")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /ping", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("pong\n"))
})
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", withCORS(mux)))
}
The response allows the preflight to be cached for 600 seconds. Browsers cap the value, so a very large number is not honoured indefinitely.
$ go run main.go # in another terminal
$ curl -s -i -X OPTIONS -H "Origin: https://example.com" \
-H "Access-Control-Request-Method: GET" http://127.0.0.1:8080/ping
HTTP/1.1 204 No Content
Access-Control-Allow-Methods: GET, OPTIONS
Access-Control-Allow-Origin: *
Access-Control-Max-Age: 600
Exposing response headers
Scripts can read only the CORS-safelisted response headers by default. A custom
header such as X-Total-Count must be named in
Access-Control-Expose-Headers.
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
http.HandleFunc("/items", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Expose-Headers", "X-Total-Count")
w.Header().Set("X-Total-Count", "3")
fmt.Fprintln(w, "falcon, sky, earth")
})
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
The count is visible to the script only because the response exposes it. The header itself is sent either way; the CORS header controls whether the browser lets JavaScript read it.
$ go run main.go # in another terminal $ curl -s -i -H "Origin: https://example.com" http://127.0.0.1:8080/items HTTP/1.1 200 OK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: X-Total-Count X-Total-Count: 3 Content-Length: 19 Content-Type: text/plain; charset=utf-8 falcon, sky, earth
Source
MDN - Cross-Origin Resource Sharing (CORS)
Fetch Standard - CORS protocol
In this article we have shown how to handle CORS in Go: a wildcard for public
data, preflight replies, method-based routing, an origin allowlist with
Vary: Origin, credentialed requests, a middleware for the whole
mux, cached preflights and exposed response headers.
Author
List all Go tutorials.