ZetCode

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.

HeaderDirectionPurpose
OriginrequestOrigin of the calling page.
Access-Control-Allow-OriginresponseAllowed origin, or *.
Access-Control-Allow-MethodsresponseMethods accepted by a preflight request.
Access-Control-Allow-HeadersresponseRequest headers accepted by a preflight request.
Access-Control-Allow-CredentialsresponseWhether cookies and HTTP authentication may be sent.
Access-Control-Expose-HeadersresponseResponse headers that scripts may read.
Access-Control-Max-AgeresponseHow 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.

main.go
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.

main.go
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.

main.go
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.

main.go
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.

main.go
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.

main.go
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.

main.go
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.

main.go
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

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Go tutorials.