Sessions in Go With Cookies and a Server Side Store
Build session management in Go: random session IDs, a pluggable store, loading middleware, ID rotation at login, idle and absolute timeouts, and tests.

Almost every web application with accounts needs to remember who the current user is across requests, and the long-standing answer is the session: the server keeps a record, the browser carries a key to it. The design is old and well understood, and it fails in ways that are well understood too. Predictable IDs, sessions that never expire, a login that does not rotate the ID, a logout that only clears the cookie: each one has been the root of real breaches.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This guide builds session management step by step. It uses cookies with the secure attributes, a store behind an interface, a middleware that loads the session into the request context, ID rotation, two kinds of timeout, and logout, with tests for the attacks it defends against. It supports authentication middleware, CSRF protection and password login, builds on middleware, and belongs to the web cluster that ends in a production HTTP server. Everything ran on Go 1.25.6.
The design
Four decisions define the system, and each has a reason.
- The cookie contains only a random ID. Not a user ID, not a role, not a signed blob. The data lives on the server, so the user cannot read or alter it, and revoking a session is deleting a row.
- The ID comes from
crypto/rand, at least 128 bits (16 bytes), encoded as URL-safe base64. A guessable ID (a counter, a timestamp,math/rand) lets an attacker walk into other people's sessions. - The store is an interface. An in-memory map is correct for one process and for tests. Redis, a SQL table or a signed-cookie scheme are different implementations of the same three methods.
- A new ID at every privilege change. Logging in creates a new session ID and discards the old one.
The store
package main
import (
"sync"
"time"
)
// Session is the server-side record behind a cookie.
type Session struct {
ID string
UserID string // empty for an anonymous session
Created time.Time
LastSeen time.Time
}
// Store is where sessions live. Implementations must be safe for concurrent use.
type Store interface {
Get(id string) (*Session, bool)
Put(s *Session)
Delete(id string)
}
// MemoryStore keeps sessions in a map. It suits one process and tests; sessions vanish on restart.
type MemoryStore struct {
mu sync.Mutex
m map[string]*Session
}
func NewMemoryStore() *MemoryStore { return &MemoryStore{m: map[string]*Session{}} }
func (s *MemoryStore) Get(id string) (*Session, bool) {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.m[id]
if !ok {
return nil, false
}
cp := *sess // hand out a copy so callers cannot race with the store
return &cp, true
}
func (s *MemoryStore) Put(sess *Session) {
s.mu.Lock()
defer s.mu.Unlock()
cp := *sess
s.m[sess.ID] = &cp
}
func (s *MemoryStore) Delete(id string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.m, id)
}
func (s *MemoryStore) Len() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.m)
}The store copies sessions on the way in and out. Without the copies, two requests for the same user would share one *Session and race on its fields. A Redis or SQL implementation gets that isolation for free because it serializes. A real store also needs expired sessions removed, not only rejected: a janitor goroutine as in the cache article, a TTL in Redis, or a periodic DELETE.
The manager and middleware
The manager owns the policy: how IDs are made, how long a session lives, how the cookie looks. Its middleware runs on every request, finds the session if there is a valid one, and attaches it to the context:
package main
import (
"context"
"crypto/rand"
"encoding/base64"
"net/http"
"time"
)
type Manager struct {
Store Store
Name string // cookie name; use "__Host-session" when Secure is true
Idle time.Duration // a session unused this long is dead
Absolute time.Duration // a session older than this is dead, however busy
Secure bool // true in production; false only for plain-HTTP tests
}
type ctxKey struct{}
// FromContext returns the current session, if the request has a valid one.
func FromContext(ctx context.Context) (*Session, bool) {
s, ok := ctx.Value(ctxKey{}).(*Session)
return s, ok
}
func newID() string {
b := make([]byte, 32) // 256 bits
if _, err := rand.Read(b); err != nil {
panic(err) // the system's random source failing is not recoverable
}
return base64.RawURLEncoding.EncodeToString(b)
}
func (m *Manager) setCookie(w http.ResponseWriter, id string, maxAge int) {
http.SetCookie(w, &http.Cookie{
Name: m.Name, Value: id, Path: "/", MaxAge: maxAge,
HttpOnly: true, Secure: m.Secure, SameSite: http.SameSiteLaxMode,
})
}
// Login starts a new authenticated session for userID, replacing any existing one.
func (m *Manager) Login(w http.ResponseWriter, r *http.Request, userID string) {
if old, err := r.Cookie(m.Name); err == nil {
m.Store.Delete(old.Value) // rotate: the previous ID must stop working
}
now := time.Now()
s := &Session{ID: newID(), UserID: userID, Created: now, LastSeen: now}
m.Store.Put(s)
m.setCookie(w, s.ID, int(m.Absolute.Seconds()))
}
// Logout deletes the server-side record and clears the cookie.
func (m *Manager) Logout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(m.Name); err == nil {
m.Store.Delete(c.Value)
}
m.setCookie(w, "", -1)
}
// Load is middleware that attaches a valid session to the request context.
func (m *Manager) Load(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(m.Name); err == nil {
if s, ok := m.Store.Get(c.Value); ok {
now := time.Now()
if now.Sub(s.LastSeen) > m.Idle || now.Sub(s.Created) > m.Absolute {
m.Store.Delete(s.ID) // expired: remove it, and treat the request as anonymous
} else {
s.LastSeen = now
m.Store.Put(s) // slide the idle window
r = r.WithContext(context.WithValue(r.Context(), ctxKey{}, s))
}
}
}
next.ServeHTTP(w, r)
})
}
// RequireLogin rejects requests without an authenticated session.
func (m *Manager) RequireLogin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s, ok := FromContext(r.Context()); !ok || s.UserID == "" {
http.Error(w, "please sign in", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}Notice the choices again. Load never fails the request: no cookie, an unknown ID or an expired session means no session in the context, and RequireLogin is the gate that turns "no session" into a 401. Keeping them separate means public pages can read the session optionally. An unknown ID gets no new session created for it: the server only ever issues IDs, so a cookie carrying an ID the server did not generate is ignored. That is the property that makes fixation harder. And expiry is checked against two clocks. The idle timeout slides with every request through LastSeen; the absolute timeout is measured from Created and never moves, so even a session in continuous use must reauthenticate eventually, which limits the damage from a stolen cookie.
Why login rotates the ID
Session fixation works like this. An attacker obtains a valid anonymous session ID from your site and tricks the victim's browser into using it (a link with the ID, a cookie set from a sibling subdomain). The victim then logs in, and if the server keeps the same ID and attaches the user to it, the attacker's copy of that ID is now logged in as the victim. The defence is cheap: Login generates a new ID and deletes the old one. The pre-login ID becomes worthless at the moment of authentication. Do the same on any privilege change, such as a password reset or an elevation to admin.
A full flow
The demo wires the manager into a small server with /login, /me and /logout, then plays a browser using a cookie jar. It also checks fixation: the session ID before login and after must differ.
package main
import (
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"time"
)
func main() {
store := NewMemoryStore()
m := &Manager{Store: store, Name: "session", Idle: 30 * time.Minute, Absolute: 12 * time.Hour}
mux := http.NewServeMux()
mux.HandleFunc("POST /login", func(w http.ResponseWriter, r *http.Request) {
m.Login(w, r, r.FormValue("user")) // a real handler verifies a password first
fmt.Fprint(w, "welcome")
})
mux.Handle("GET /me", m.RequireLogin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s, _ := FromContext(r.Context())
fmt.Fprintf(w, "you are %s", s.UserID)
})))
mux.HandleFunc("POST /logout", func(w http.ResponseWriter, r *http.Request) {
m.Logout(w, r)
fmt.Fprint(w, "bye")
})
srv := httptest.NewServer(m.Load(mux))
defer srv.Close()
jar, _ := cookiejar.New(nil)
client := &http.Client{Jar: jar}
u, _ := url.Parse(srv.URL)
show := func(resp *http.Response, err error) string {
if err != nil {
panic(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return fmt.Sprintf("%d %s", resp.StatusCode, b)
}
get := func(path string) string { return show(client.Get(srv.URL + path)) }
post := func(path string, form url.Values) string { return show(client.PostForm(srv.URL+path, form)) }
cookieID := func() string {
for _, c := range jar.Cookies(u) {
if c.Name == "session" {
return c.Value[:8] + "..."
}
}
return "(none)"
}
fmt.Println("before login: GET /me ->", get("/me"))
planted := &http.Cookie{Name: "session", Value: "attacker-chosen-id", Path: "/"}
jar.SetCookies(u, []*http.Cookie{planted})
fmt.Println("planted id in the browser:", cookieID())
fmt.Println("POST /login ->", post("/login", url.Values{"user": {"ada"}}))
fmt.Println("id after login:", cookieID(), "(differs from the planted one)")
fmt.Println("GET /me ->", get("/me"))
fmt.Println("sessions in the store:", store.Len())
fmt.Println("POST /logout ->", post("/logout", url.Values{}))
fmt.Println("GET /me after logout ->", get("/me"))
fmt.Println("sessions in the store:", store.Len())
measureSize()
}before login: GET /me -> 401 please sign in
planted id in the browser: attacker...
POST /login -> 200 welcome
id after login: j6NM5tpM... (differs from the planted one)
GET /me -> 200 you are ada
sessions in the store: 1
POST /logout -> 200 bye
GET /me after logout -> 401 please sign in
sessions in the store: 0
100,000 sessions use about 15 MiB: roughly 162 bytes eachThe sequence shows each property. An unauthenticated /me is rejected. A cookie with an ID the server never issued does nothing, and logging in replaces it with a fresh random ID, so the planted value cannot be used afterwards. /me then works, logout empties the store (one record in, none left), and the next /me is a 401 again. Because logout deleted the record on the server, copying the old cookie value into another browser would not work either.
Testing the timeouts
Time-based behavior is where bugs hide, and testing/synctest makes it testable without sleeping (synctest). These tests use recorders inside a bubble, so an hour of idleness takes microseconds:
package main
import (
"net/http"
"net/http/httptest"
"testing"
"testing/synctest"
"time"
)
func newManager() *Manager {
return &Manager{Store: NewMemoryStore(), Name: "session", Idle: 30 * time.Minute, Absolute: 2 * time.Hour}
}
// signedIn logs in and returns the cookie the browser would hold.
func signedIn(t *testing.T, m *Manager) *http.Cookie {
t.Helper()
rec := httptest.NewRecorder()
m.Login(rec, httptest.NewRequest("POST", "/login", nil), "ada")
cookies := rec.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("Login set %d cookies, want 1", len(cookies))
}
return cookies[0]
}
func authed(m *Manager, c *http.Cookie) int {
h := m.Load(m.RequireLogin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})))
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(c)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Code
}
func TestIdleTimeoutSlidesWithActivity(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
m := newManager()
c := signedIn(t, m)
for range 3 { // three visits 20 minutes apart: each is inside the idle window
time.Sleep(20 * time.Minute)
if code := authed(m, c); code != http.StatusOK {
t.Fatalf("active session rejected with %d", code)
}
}
time.Sleep(31 * time.Minute) // now idle for longer than 30 minutes
if code := authed(m, c); code != http.StatusUnauthorized {
t.Fatalf("idle session accepted (%d)", code)
}
})
}
func TestAbsoluteLifetimeEndsABusySession(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
m := newManager()
c := signedIn(t, m)
for range 8 { // 8 visits 20 minutes apart is 160 minutes, past the 2 hour limit
time.Sleep(20 * time.Minute)
authed(m, c)
}
if code := authed(m, c); code != http.StatusUnauthorized {
t.Fatalf("a session older than the absolute lifetime was accepted (%d)", code)
}
})
}
func TestLoginRotatesTheSessionID(t *testing.T) {
m := newManager()
first := signedIn(t, m)
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/login", nil)
req.AddCookie(first)
m.Login(rec, req, "ada")
second := rec.Result().Cookies()[0]
if first.Value == second.Value {
t.Fatal("the session ID did not change at login")
}
if code := authed(m, first); code != http.StatusUnauthorized {
t.Fatalf("the pre-login ID still works (%d)", code)
}
}
func TestLogoutInvalidatesTheOldCookie(t *testing.T) {
m := newManager()
c := signedIn(t, m)
req := httptest.NewRequest("POST", "/logout", nil)
req.AddCookie(c)
m.Logout(httptest.NewRecorder(), req)
if code := authed(m, c); code != http.StatusUnauthorized {
t.Fatalf("the cookie still works after logout (%d)", code)
}
}
func TestCookieHasTheSecureAttributes(t *testing.T) {
m := newManager()
m.Secure = true
c := signedIn(t, m)
if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteLaxMode || c.Path != "/" {
t.Fatalf("weak cookie: %+v", c)
}
}=== RUN TestIdleTimeoutSlidesWithActivity
--- PASS: TestIdleTimeoutSlidesWithActivity (0.00s)
=== RUN TestAbsoluteLifetimeEndsABusySession
--- PASS: TestAbsoluteLifetimeEndsABusySession (0.00s)
=== RUN TestLoginRotatesTheSessionID
--- PASS: TestLoginRotatesTheSessionID (0.00s)
=== RUN TestLogoutInvalidatesTheOldCookie
--- PASS: TestLogoutInvalidatesTheOldCookie (0.00s)
=== RUN TestCookieHasTheSecureAttributes
--- PASS: TestCookieHasTheSecureAttributes (0.00s)
PASS
ok example.test/lab 1.292sThe idle test checks both sides of the sliding window: activity every 20 minutes keeps the session alive for an hour, and 31 minutes of silence kills it. The absolute test is the one people forget: eight visits in a row, each inside the idle window, still end at the two-hour mark. The rotation test asserts the ID changes and that the old one stops working. Run them under -race; the store's mutex and copies are what the race detector is checking.
Data in the session, and lost updates
So far a session holds a user ID and timestamps. Real applications add more: a CSRF token, a shopping cart, flash messages ("your profile was saved") shown once on the next page. The tempting design is a Values map[string]any on the session. It works, with one trap that the middleware above makes visible: Load reads the session, the handler changes it, and something writes it back with Put. Two concurrent requests from the same browser (a page with several fetches) each read the session, each change a different value, and the later Put overwrites the earlier one's change. That is a lost update.
The fix is to make updates go through the store, so the store applies a function atomically:
type Store interface {
Get(id string) (*Session, bool)
Put(s *Session)
Update(id string, fn func(*Session)) bool // applies fn under the store's lock
Delete(id string)
}
func (s *MemoryStore) Update(id string, fn func(*Session)) bool {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.m[id]
if ok {
fn(sess)
}
return ok
}The sliding idle window in Load should use Update too, setting only LastSeen, so that a request that only touches the timestamp never overwrites data another request just saved. A Redis store implements the same idea with a hash and HSET of single fields. Flash messages are then a value that the next request reads and deletes in one Update.
Remember me
A "keep me signed in" checkbox is a request for a second, longer-lived credential, and it deserves its own design instead of stretching the session's lifetime. Issue a separate cookie containing a random selector and a random validator. Store the selector and a hash of the validator on the server, along with the user and an expiry. When a request arrives with no valid session but a valid remember-me cookie, check the hash with a constant-time comparison, create a fresh session, and rotate the remember-me token. Because only a hash of the validator is stored, a database leak does not yield usable tokens, and rotation means a stolen token is detectable when the real user presents the old one. Treat it as a weaker credential: allow sensitive actions (changing the email, the password, payment details) only after a recent password confirmation.
Revocation and listing
Server-side sessions can be revoked, which is their advantage over stateless cookies. Index sessions by user so you can delete all of them: on a password change, on "log out everywhere", when an account is suspended. A "your active sessions" page that lists device, last seen and location, with a revoke button for each, is a small feature that users value and security teams like, and it falls out of the same index. Keep the extra metadata (user agent, IP) minimal and clear in your privacy notice.
Logging and privacy
A session ID is a credential for as long as the session lives. Do not log it in full, put it in a URL, or send it to analytics. Log a short prefix or a hash if you need to correlate requests, and make sure request logging middleware excludes the Cookie and Set-Cookie headers (logging middleware).
What a session costs
People worry that server-side sessions are expensive. They are cheap enough to be a non-issue below very large scale, and the number is easy to measure. The function below stores 100,000 sessions in the memory store and reports the heap it uses; the demo above calls it as its last step:
package main
import (
"fmt"
"runtime"
"time"
)
func measureSize() {
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
store := NewMemoryStore()
now := time.Now()
for range 100_000 {
store.Put(&Session{ID: newID(), UserID: "user-12345", Created: now, LastSeen: now})
}
runtime.GC()
runtime.KeepAlive(store) // the store must still be reachable when we measure
runtime.ReadMemStats(&after)
perSession := (after.HeapAlloc - before.HeapAlloc) / 100_000
fmt.Printf("100,000 sessions use about %d MiB: roughly %d bytes each\n", (after.HeapAlloc-before.HeapAlloc)>>20, perSession)
}On this machine 100,000 sessions took about 15 MiB, roughly 162 bytes each: the struct, a 43-character ID string and the map entry. A million concurrent sessions would be around 160 MB, and most applications have far fewer. The figure grows if sessions carry data (a cart, a map of values), so measure with your real session shape before planning capacity, and remember that Redis stores the same data with its own overhead per key. The reason to move the store out of process is durability and horizontal scaling, not memory. The cost that does grow with traffic is the write per request that the sliding idle window causes. If that matters, write LastSeen only when it is older than a minute or so (a coarse window is just as good for a 30 minute timeout), which cuts store writes by orders of magnitude.
Sessions or stateless tokens
For browser applications served from your own domain, server-side sessions are my default. Revocation is immediate, nothing sensitive crosses the wire except an opaque ID, the cookie stays tiny, and there is no key-management story beyond the random source. Stateless tokens such as JWTs earn their place when many independent services must verify identity without calling a central store, or for APIs consumed by non-browser clients that cannot hold cookies. They give up easy revocation, make logout advisory until expiry, and tempt people to put authorization data into a token that outlives the data it describes. If you choose them, keep them short-lived with a refresh mechanism (JWT in Go, carefully).
Beyond one process
The memory store works for a single instance and loses sessions on restart. The usual next steps:
A shared store. Redis with a TTL is the common choice: Get and Put become GET and SET ... EX, and expiry is free. Serialize the session as JSON. A SQL table works too, with a periodic cleanup. The Store interface above is the whole contract.
Sticky sessions are a smell. Routing a user to the same instance papers over an in-memory store. A shared store is simpler to operate.
Stateless alternatives. A signed or encrypted cookie holding the session data avoids the store, at the cost of cookie size, no way to revoke a single session without a denylist, and key management. JWTs are the better-known form; they bring their own problems, and for browser sessions a server-side store is usually the better default.
Operating it
A few operational habits keep the system honest. Export the number of active sessions as a gauge; a sudden spike is a bot, a sudden drop is a deploy that lost the memory store. Count login successes and failures separately, because a rising failure rate is credential stuffing in progress. Log session creation and destruction events with a hashed ID so incident response can reconstruct what happened. And rehearse the "invalidate everything" button, which for a shared store is a key-prefix delete, since the day you need it is not the day to write it.
A short security checklist
The ID is random, long and generated by crypto/rand. The cookie is HttpOnly, Secure, SameSite=Lax, host-only and has a lifetime. The ID changes at login and at privilege changes. There are idle and absolute timeouts. Logout deletes the server-side record. State-changing requests are protected against cross-site forgery (CSRF). Login attempts are rate limited (rate limiting middleware). And nothing sensitive is logged with the session ID. The OWASP Session Management Cheat Sheet covers each item in depth, and the net/http cookie documentation the Go side of the attributes.


