How to handle cookies in Go

Go handles cookies manually using the net/http package to read and write http.Cookie objects.

The first request

A browser hits your server for the first time. It sends a request with an empty Cookie header. Your server responds with a Set-Cookie header containing a random string. The browser saves that string. Every subsequent request to your domain automatically attaches it. You just built state into a stateless protocol. That is exactly what cookies do in Go.

What a cookie actually is

HTTP was designed to be forgetful. The server answers a request and immediately drops the connection. Cookies patch that gap by giving the client a small notebook. The server writes a note, the client carries it back on every visit, and the server reads it to remember who is asking. In Go, the net/http package exposes this notebook through the http.Cookie struct. You do not need a third-party library to read or write them. The standard library handles the header parsing, URL encoding, and timestamp formatting automatically.

Go follows a simple convention here: accept interfaces, return structs. The http.Cookie type is a plain struct with exported fields. You populate it, pass it to the standard library, and let the framework handle the HTTP wire format. The community accepts this direct approach because it removes hidden magic. You see exactly what goes into the header and what comes out.

Reading and writing the basics

Here is the simplest way to read a cookie and write a new one in a single handler.

package main

import (
	"net/http"
)

// handleCookie demonstrates reading and writing a single cookie.
func handleCookie(w http.ResponseWriter, r *http.Request) {
	// Check if the browser already sent a session cookie.
	if c, err := r.Cookie("session_id"); err == nil {
		// c.Value holds the string the browser returned.
		w.Write([]byte("Found session: " + c.Value))
		return
	}

	// Create a new cookie with explicit security flags.
	cookie := &http.Cookie{
		Name:     "session_id",
		Value:    "new-session-abc123",
		Path:     "/",
		HttpOnly: true,
		Secure:   true,
	}
	// Attach the cookie to the response headers.
	http.SetCookie(w, cookie)
	w.Write([]byte("Created new session"))
}

func main() {
	http.HandleFunc("/", handleCookie)
	http.ListenAndServe(":8080", nil)
}

The r.Cookie("session_id") call scans the Cookie header the browser sent. If the key exists, it returns a populated http.Cookie and a nil error. If the key is missing, it returns a nil cookie and an error. The idiomatic pattern checks err == nil to confirm presence. When you call http.SetCookie, Go formats the http.Cookie struct into a properly escaped Set-Cookie header. The Path: "/" flag tells the browser to send this cookie to every route on the domain. HttpOnly blocks JavaScript from reading it, which stops most cross-site scripting attacks from stealing the value. Secure forces the browser to only send it over HTTPS.

Go does not magically persist cookies between requests. The browser does that. Your job is just to read what comes in and emit what goes out. The standard library handles the tedious parts: URL-encoding values that contain spaces, formatting expiration dates into GMT strings, and splitting multiple cookies in a single header line.

How the struct maps to headers

The http.Cookie struct mirrors the HTTP specification almost one-to-one. Each exported field controls a specific attribute in the Set-Cookie header. Understanding the mapping prevents subtle bugs when browsers interpret flags differently.

The Name and Value fields are mandatory. If you leave Name empty, http.SetCookie silently drops the cookie. The Domain field controls which subdomains receive the cookie. Leaving it empty defaults to the current host, which is usually the safest choice. Setting it to .example.com shares the cookie across api.example.com and www.example.com. The Path field restricts the cookie to specific URL prefixes. A path of /admin means the browser only sends the cookie when the request URL starts with /admin.

Expiration works through two fields: Expires and MaxAge. The Expires field takes a time.Time value. Go converts it to the exact Expires=Wed, 21 Oct 2025 07:28:00 GMT format the HTTP spec requires. The MaxAge field takes an integer representing seconds. Browsers that understand MaxAge will use it, while older clients fall back to Expires. Setting MaxAge to zero tells the browser to delete the cookie immediately. This is the standard way to log users out.

The SameSite field controls cross-origin request behavior. It accepts constants like http.SameSiteStrictMode, http.SameSiteLaxMode, or http.SameSiteNoneMode. Strict blocks the cookie on all cross-site requests. Lax allows it on top-level navigations like clicking a link. None allows it everywhere, but requires Secure: true to be set. Modern browsers default to Lax when the attribute is missing, so you should always set it explicitly.

Go's error handling style applies here too. If you need to validate a cookie value, you write the check explicitly. There is no silent failure. If r.Cookie returns an error, you handle it immediately. The community accepts the if err != nil boilerplate because it makes the missing-data path impossible to ignore. Trust the explicit check. It saves hours of debugging later.

Real-world session handling

Production code rarely just echoes a cookie back. You usually need to track expiration, handle missing values gracefully, and respect Go's standard library patterns. Here is a handler that manages a user preference cookie with a fixed lifetime and proper validation.

package main

import (
	"net/http"
	"time"
)

// setTheme stores a UI theme preference with a fixed expiration.
func setTheme(w http.ResponseWriter, r *http.Request) {
	// Parse the query parameter or default to "light".
	theme := r.URL.Query().Get("theme")
	if theme == "" {
		theme = "light"
	}

	// Build the cookie with a precise expiration time.
	cookie := &http.Cookie{
		Name:     "ui_theme",
		Value:    theme,
		Path:     "/",
		Expires:  time.Now().Add(1 * time.Hour),
		SameSite: http.SameSiteStrictMode,
	}

	// Send the updated cookie to the browser.
	http.SetCookie(w, cookie)
	w.WriteHeader(http.StatusNoContent)
}

func main() {
	http.HandleFunc("/theme", setTheme)
	http.ListenAndServe(":8080", nil)
}

The handler reads a query parameter, falls back to a default, and constructs a fresh http.Cookie. The Expires field gets a relative time one hour from now. Go handles the timezone conversion and GMT formatting automatically. The SameSite field is locked to StrictMode to prevent accidental leakage to third-party iframes or embedded widgets. The response returns 204 No Content because the browser only cares about the header, not the response body.

If you try to access a field on a nil cookie without checking the error first, the program crashes at runtime with a panic: runtime error: invalid memory address or nil pointer dereference. The compiler will not catch this at build time because r.Cookie returns two values. You must check the error or use the blank identifier to discard it intentionally: c, _ := r.Cookie("optional"). The underscore tells the compiler you considered the second return value and chose to drop it. Use it sparingly with errors, but it is perfectly valid for optional cookie reads.

Where things go wrong

Cookie handling trips up developers in predictable ways. The most common mistake is assuming a cookie exists. If you call r.Cookie("missing") and try to use the returned value without checking the error, the program panics when you access a nil pointer. Always verify presence before reading c.Value.

Another trap is overwriting cookies with different attributes. If you send two Set-Cookie headers with the same name but different paths, the browser treats them as separate cookies. If you send the same name and path but different values, the last one wins. Go does not merge them for you. You must construct the exact http.Cookie struct you want to emit. If you need to update a cookie, recreate the struct with the new value and call http.SetCookie again.

Encoding issues also surface when storing JSON or special characters. The http.Cookie struct automatically percent-encodes the value when writing, and decodes it when reading. If you bypass http.SetCookie and manually write to w.Header().Set("Set-Cookie", ...), you lose that safety net and risk breaking the header parser. Stick to http.SetCookie. The standard library has already solved the escaping edge cases.

Browser limits are another reality check. Most browsers cap cookies at 4KB per domain and around 50 cookies per site. If you try to shove a massive JSON payload into a cookie, the browser will silently drop it or truncate the header. The compiler will happily accept your oversized http.Cookie struct, but the network layer will reject it at runtime. Keep cookies small. Use them for identifiers, not data stores.

Goroutine leaks rarely happen with cookies, but they do happen when you spawn background tasks to refresh session tokens and forget to cancel them. Always pass a context.Context as the first parameter to functions that manage long-lived state. The convention is strict: ctx goes first, and every function in the call chain respects cancellation. Context is plumbing. Run it through every long-lived call site.

Picking the right storage

Use a cookie when you need the browser to automatically send a small identifier with every request to your domain. Use a server-side session store when you need to keep sensitive data off the client and only pass a random token in the cookie. Use a JSON Web Token when you want stateless authentication that can be verified without a database lookup. Use browser local storage when the data is purely client-side and never needs to reach the server. Use a database or cache when you need to store large payloads, complex relationships, or data that outlives the browser session.

Cookies are just headers with persistence. Treat them as transport tokens, not databases.

Where to go next