Skip to content
gohacks.dev
Web

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.

8 min read
Summarize in AI
Flat illustration of a shield with a lock, a key and a password field.

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.

cookie.goGo
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:

  • HttpOnly hides the cookie from document.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.
  • Secure tells 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.
  • SameSite controls whether the cookie accompanies cross-site requests. Lax sends 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. Strict withholds it even on navigation, which breaks "click a link and arrive logged in". None sends it everywhere and requires Secure; use it only for embeds that need it.
  • Path scopes 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 is HttpOnly.
  • MaxAge (seconds) or Expires (a date) makes the cookie persistent. With neither, it is a session cookie that lives until the browser closes. Prefer MaxAge, which does not depend on the client's clock. Negative means delete.
  • Domain lets 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.

Go
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.Value

r.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:

main.goGo
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)
}
Text
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.

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:

sign.goGo
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
}
main.goGo
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)
}
Text
signed value : theme=dark.bKwKX_olsCZiONwXCpXTCXbygH4bTI_jYK0yp6g-jHg
untouched    : theme=dark <nil>
tampered     : cookie signature is invalid
wrong secret : cookie signature is invalid

Changing 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.

Also inSecurity
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.