Set and Read Cookies in Go the Secure Way by Default
Set and read cookies in Go with net/http: HttpOnly, Secure, SameSite, Path, MaxAge, the __Host- prefix, deleting cookies and signing values with HMAC.

A cookie is a small piece of text the server asks the browser to send back with every request. It is how HTTP, a stateless protocol, remembers who you are: a session ID, a theme preference, a consent flag. It is also one of the most misused parts of the web platform, because the same mechanism carries the keys to an account and the contents of a banner dismissal, and the browser treats both the same unless you tell it otherwise.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
Go's net/http makes cookies simple to set and read. The safety lives in the attributes you choose and in what you put in the value. This article sets a cookie with secure defaults, reads it back, explains each attribute with the attack it prevents, shows deletion and the __Host- prefix, and signs a value so tampering is detected. It is a building block for sessions, CSRF protection and authentication middleware, and belongs to the web cluster that ends in a production HTTP server. Everything ran on Go 1.25.6.
Setting a cookie
package main
import (
"net/http"
"time"
)
// setSession sends a session cookie with the secure attributes switched on.
func setSession(w http.ResponseWriter, id string) {
http.SetCookie(w, &http.Cookie{
Name: "__Host-session", // the prefix makes browsers enforce Secure, Path=/ and no Domain
Value: id,
Path: "/",
MaxAge: int((24 * time.Hour).Seconds()),
HttpOnly: true, // not readable from JavaScript, so XSS cannot steal it
Secure: true, // sent over HTTPS only
SameSite: http.SameSiteLaxMode, // not sent on cross-site subrequests, which blunts CSRF
})
}
// clearSession tells the browser to delete the cookie.
func clearSession(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: "__Host-session",
Value: "",
Path: "/", // must match the attributes the cookie was set with
MaxAge: -1, // delete now
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}http.SetCookie appends a Set-Cookie header to the response, so it must run before the body is written, the same ordering rule as any header. It validates the name and value and silently drops a cookie with invalid characters, which is a small trap: if a cookie never appears, check for a space, comma or semicolon in the value. Each attribute exists for a reason:
HttpOnlyhides the cookie fromdocument.cookie. If an attacker injects a script into your page, they cannot read a session cookie, though they can still make requests with it. It is not a substitute for preventing XSS, but it removes the simplest theft.Securetells the browser to send the cookie only over HTTPS. Without it, one request over plain HTTP, such as a user typing the bare domain, sends the cookie in the clear.SameSitecontrols whether the cookie accompanies cross-site requests.Laxsends it on top-level navigations (clicking a link) but not on cross-site subrequests (images, forms posted from another site, fetch calls), which blocks the common form of CSRF.Strictwithholds it even on navigation, which breaks "click a link and arrive logged in".Nonesends it everywhere and requiresSecure; use it only for embeds that need it.Pathscopes the cookie to a path. Use/for site-wide cookies; a narrower path does not protect anything from other pages on the same origin, since scripts on the origin can still reach it unless it isHttpOnly.MaxAge(seconds) orExpires(a date) makes the cookie persistent. With neither, it is a session cookie that lives until the browser closes. PreferMaxAge, which does not depend on the client's clock. Negative means delete.Domainlets subdomains share a cookie. Leaving it unset keeps the cookie host-only, which is safer.
The __Host- prefix is a browser feature, not a Go one: a cookie whose name begins with __Host- is accepted only if it is Secure, has Path=/ and has no Domain attribute. That closes the attack where a compromised sibling subdomain plants a cookie for your domain. The related __Secure- prefix only requires Secure.
Reading a cookie
c, err := r.Cookie("__Host-session")
if errors.Is(err, http.ErrNoCookie) {
// not signed in
} else if err != nil {
// a malformed cookie header
}
id := c.Valuer.Cookie returns the first cookie with that name, or http.ErrNoCookie. The browser sends only name and value back; the attributes (Path, HttpOnly, MaxAge) are not in the request, so you cannot read them to check anything. r.Cookies() returns all of them. If several cookies share a name (set with different paths or domains), the first wins, and the order is up to the browser.
Seeing the exchange
This program runs a small server that sets a cookie on one route and reads it on another, with a real HTTP client carrying a cookie jar. It prints the raw Set-Cookie header, then shows what the jar does with the Secure flag when the server speaks plain HTTP:
package main
import (
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /login", func(w http.ResponseWriter, r *http.Request) {
setSession(w, "abc123")
fmt.Fprint(w, "logged in")
})
mux.HandleFunc("GET /me", func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("__Host-session")
if err != nil {
http.Error(w, "no session cookie ("+err.Error()+")", http.StatusUnauthorized)
return
}
fmt.Fprint(w, "session is "+c.Value)
})
srv := httptest.NewServer(mux) // plain HTTP
defer srv.Close()
resp, _ := http.Get(srv.URL + "/login")
fmt.Println("Set-Cookie:", resp.Header.Get("Set-Cookie"))
resp.Body.Close()
jar, _ := cookiejar.New(nil)
client := &http.Client{Jar: jar}
client.Get(srv.URL + "/login")
u, _ := url.Parse(srv.URL)
fmt.Println("cookies the jar will send over plain HTTP:", len(jar.Cookies(u)))
resp, _ = client.Get(srv.URL + "/me")
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
fmt.Printf("GET /me -> %d %s", resp.StatusCode, body)
}Set-Cookie: __Host-session=abc123; Path=/; Max-Age=86400; HttpOnly; Secure; SameSite=Lax
cookies the jar will send over plain HTTP: 0
GET /me -> 401 no session cookie (http: named cookie not present)The raw header shows every attribute exactly as the browser will receive it. The more instructive part is the second half: the cookie jar, which behaves like a browser, refuses to send the Secure cookie back over plain HTTP, so /me answers 401 with named cookie not present. That is the Secure flag doing its job, and it is also the most common reason cookies "do not work" in local development: the dev server speaks HTTP while the cookie demands HTTPS. Browsers treat localhost as a secure context and accept Secure cookies there, but other development hostnames over HTTP do not. Do not remove Secure to make development easier; make the dev environment use HTTPS or localhost.
What to put in a cookie
A cookie value is under the client's control. Anything stored in it can be read and edited. Three designs are common, in order of how often they are the right one:
An opaque session ID. A long random string, with the real data (user, permissions, cart) stored on the server and looked up by the ID. The cookie is a key, nothing more. Generate the ID with crypto/rand, at least 128 bits (sessions in Go).
A signed value. For small, non-secret state that you want to read without a lookup (a preference, a flash message), sign the value with an HMAC and verify it on the way back. The user can read it but cannot change it without detection:
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"strings"
)
var errBadSignature = errors.New("cookie signature is invalid")
func sign(secret []byte, value string) string {
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(value))
return value + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func verify(secret []byte, signed string) (string, error) {
i := strings.LastIndexByte(signed, '.')
if i < 0 {
return "", errBadSignature
}
value, got := signed[:i], signed[i+1:]
want := sign(secret, value)[i+1:]
if !hmac.Equal([]byte(got), []byte(want)) { // constant-time comparison
return "", errBadSignature
}
return value, nil
}package main
import "fmt"
func main() {
secret := []byte("a-long-random-secret-loaded-from-configuration")
cookie := sign(secret, "theme=dark")
fmt.Println("signed value :", cookie)
v, err := verify(secret, cookie)
fmt.Println("untouched :", v, err)
tampered := "theme=light" + cookie[len("theme=dark"):]
_, err = verify(secret, tampered)
fmt.Println("tampered :", err)
_, err = verify([]byte("a different secret"), cookie)
fmt.Println("wrong secret :", err)
}signed value : theme=dark.bKwKX_olsCZiONwXCpXTCXbygH4bTI_jYK0yp6g-jHg
untouched : theme=dark <nil>
tampered : cookie signature is invalid
wrong secret : cookie signature is invalidChanging the value breaks the signature, and so does verifying with a different key. hmac.Equal compares in constant time so the comparison does not leak how many bytes matched (the JWT article says more about comparing secrets safely). A signed cookie is authenticated but not confidential: the value is readable, so do not put secrets in it. If you need confidentiality, encrypt (AES-GCM) and sign together, or keep the data on the server. Rotating the secret invalidates all cookies, so support a list of keys with the newest signing and all verifying.
An encrypted value. Authenticated encryption (AES-GCM or XChaCha20-Poly1305) hides and protects the content. It is the right choice for stateless sessions in small apps, at the cost of key management and the 4 KB cookie size limit. Packages exist (gorilla/securecookie, golang.org/x/crypto), and the guidance is to use one rather than assemble primitives yourself.
Limits and housekeeping
Browsers allow about 4 KB per cookie and a few dozen cookies per domain, and every cookie travels with every request to the domain, including requests for images and scripts. A 2 KB cookie adds 2 KB to every request. Keep cookies small and scoped. Do not put personal data in cookies you do not need; consent rules such as the GDPR's apply to non-essential cookies, and a session cookie for login is generally considered essential while analytics cookies are not.
Go 1.23 added Partitioned to http.Cookie, which sets the Partitioned attribute (CHIPS) for third-party cookies that should be keyed by the top-level site. http.Cookie.String() renders the header value, which is handy in tests. For testing handlers that set cookies, read them back from the recorder with rec.Result().Cookies() and assert on HttpOnly, Secure and SameSite, so a refactor cannot silently weaken them.
The standard is RFC 6265 and its successor work, the MDN Set-Cookie reference lists every attribute with browser support, and the http.Cookie documentation covers the Go fields. The OWASP session management cheat sheet is the best checklist for what a session cookie must do.


