Sessions and Cookies
A cookie holds a session id, the server holds everything else, and three flags decide whether that cookie can be stolen.
http.SetCookie(w, &http.Cookie{Name: "session", Value: id, Path: "/", MaxAge: 3600, HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode})HTTP has no memory. Each request arrives on its own, and the server cannot tell that two of them came from the same person. A cookie fixes that. A cookie is a small named piece of text that the server asks the browser to keep. The browser then sends it back with every later request to that site.
Put as little as possible in it. Store a session id: a long random string that means nothing on its own. The server keeps the real facts, such as which user the session belongs to, in a map or a table that the id points at. If the cookie is stolen the thief gets a key you can throw away, not the data.
The flags that matter
http.SetCookie(w http.ResponseWriter, cookie *http.Cookie) writes the cookie. Four fields on http.Cookie decide how safe it is. HttpOnly: true stops JavaScript on the page reading the cookie, so a script injected into your HTML cannot take the session. Secure: true tells the browser to send it only over HTTPS. SameSite: http.SameSiteLaxMode keeps it off cross-site POST requests and off requests for images and scripts made by other sites, which blocks a class of forged submissions; it is still sent when a visitor follows a link to you. Path: "/" sends it to every page of the site.
Max-Age and Expires
MaxAge counts seconds from now. Expires is an absolute time. They do the same job, and where both appear the browser uses MaxAge.
Issue a new session id when someone logs in and drop the old one, so an id a visitor arrived holding cannot become a logged-in session.
Example
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
// setSession asks the browser to hold the session id and send it back.
func setSession(w http.ResponseWriter, id string) {
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: id,
Path: "/",
MaxAge: 3600,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
func main() {
// httptest.NewRecorder is a stand-in ResponseWriter that remembers
// everything a handler wrote, including headers.
rec := httptest.NewRecorder()
setSession(rec, "s_8f3c")
fmt.Println(rec.Header().Get("Set-Cookie"))
// A later request from that browser carries the cookie back.
req := httptest.NewRequest("GET", "/account", nil)
req.AddCookie(&http.Cookie{Name: "session", Value: "s_8f3c"})
c, err := req.Cookie("session")
if err != nil {
fmt.Println("no session:", err)
return
}
fmt.Println("session id:", c.Value)
}
// Output:
// session=s_8f3c; Path=/; Max-Age=3600; HttpOnly; Secure; SameSite=Lax
// session id: s_8f3c
When to use it
- Keep a signed-in visitor signed in across requests to a server-rendered site.
- Log someone out by deleting the server-side session and expiring the cookie.
- Offer a remember-me option by setting a longer MaxAge on the session cookie.
- Assert in a test that a login handler sets HttpOnly, Secure and SameSite.
More examples
The server side of a session, and logging out
This is the half that never leaves your server. The mutex is there because many requests are handled at the same time and a plain map cannot be written by two of them at once. Note that logging out needs both halves: deleting the server-side entry is what actually ends the session, because a browser that ignores the Set-Cookie header would otherwise keep sending a working id.
package main
import (
"crypto/rand"
"fmt"
"net/http"
"net/http/httptest"
"sync"
)
// store holds everything real. The browser only ever sees an id.
type store struct {
mu sync.Mutex
byID map[string]string // session id -> user id
}
// create makes an id nobody can guess. rand.Text, added in Go 1.24, returns
// base32 text carrying at least 128 bits of randomness.
func (s *store) create(userID string) string {
id := rand.Text()
s.mu.Lock()
defer s.mu.Unlock()
s.byID[id] = userID
return id
}
func (s *store) userFor(id string) (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
user, ok := s.byID[id]
return user, ok
}
func main() {
s := &store{byID: map[string]string{}}
id := s.create("u_42") // the id is random, so it is never printed
user, ok := s.userFor(id)
fmt.Println(user, ok)
_, known := s.userFor("a-guessed-id")
fmt.Println("guessed id known:", known)
// Logging out is two steps: forget the session, then tell the browser
// to drop the cookie with MaxAge: -1.
delete(s.byID, id)
rec := httptest.NewRecorder()
http.SetCookie(rec, &http.Cookie{
Name: "session",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
fmt.Println(rec.Header().Get("Set-Cookie"))
}
// Output:
// u_42 true
// guessed id known: false
// session=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax
Reading the session cookie in a handler
A missing cookie is not a server problem, it is an anonymous visitor, so the answer is 401 and not a crash. http.ErrNoCookie is the sentinel error to match against when you want to tell "not signed in" apart from anything else. Writing the check this way also makes the handler testable without a browser, which is what httptest is for.
package main
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
)
func account(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("session")
if err != nil { // http.ErrNoCookie is the only error this returns
http.Error(w, "sign in first", http.StatusUnauthorized)
return
}
fmt.Fprintln(w, "session", c.Value)
}
func main() {
with := httptest.NewRequest("GET", "/account", nil)
with.AddCookie(&http.Cookie{Name: "session", Value: "s_8f3c"})
rec := httptest.NewRecorder()
account(rec, with)
fmt.Print(rec.Body.String())
bare := httptest.NewRecorder()
account(bare, httptest.NewRequest("GET", "/account", nil))
fmt.Println("no cookie:", bare.Code)
_, err := httptest.NewRequest("GET", "/", nil).Cookie("session")
fmt.Println("missing:", errors.Is(err, http.ErrNoCookie))
}
// Output:
// session s_8f3c
// no cookie: 401
// missing: true
Replace the session id when someone logs in
An attacker can set a cookie on someone else's browser in several ways, then wait for them to log in with it. Issuing a fresh id at login, and deleting the old one, closes that off. Do the same thing in reverse on logout and on a password change, so every session but the current one stops working.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
type sessions map[string]string // session id -> user id
// login throws away whatever session the browser arrived with and issues a new
// id. Keeping the old id would let someone who planted it in the visitor's
// browser ride along once that visitor signs in.
func (s sessions) login(w http.ResponseWriter, r *http.Request, newID, userID string) {
if old, err := r.Cookie("session"); err == nil {
delete(s, old.Value)
}
s[newID] = userID
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: newID,
Path: "/",
MaxAge: 3600,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
func main() {
s := sessions{"planted": ""}
r := httptest.NewRequest("POST", "/login", nil)
r.AddCookie(&http.Cookie{Name: "session", Value: "planted"})
w := httptest.NewRecorder()
// newID is a parameter here so the output is fixed. Real code calls
// crypto/rand.Text instead.
s.login(w, r, "s_new", "u_42")
_, stillThere := s["planted"]
fmt.Println("planted id still usable:", stillThere)
fmt.Println("user for new id:", s["s_new"])
fmt.Println(w.Header().Get("Set-Cookie"))
}
// Output:
// planted id still usable: false
// user for new id: u_42
// session=s_new; Path=/; Max-Age=3600; HttpOnly; Secure; SameSite=Lax
Discussion