The wristband at the door
You build a login page. A user types their password. The server checks the database. Now the user visits /dashboard. How does the server know this request comes from the same person who just logged in? HTTP is stateless. Every request is a stranger. You need a way to hand the browser a secret token that proves identity without sending the password again. That token is a session.
How sessions work
Think of a session like a wristband at a club. You show your ID at the door. The bouncer checks it, writes a random number on a wristband, and hands it to you. You keep the wristband. The bouncer keeps a list of valid wristband numbers. When you walk to the bar, you don't show your ID again. You just show the wristband. The bartender checks the list. If the number matches, you get a drink.
The session ID is the wristband number. The server-side map is the bouncer's list. The cookie is the wristband on your wrist. The browser sends the cookie automatically with every request to the domain. The server reads the cookie, looks up the ID, and grants access.
Session IDs are secrets. Treat them like passwords.
Minimal session implementation
Here's the core loop: generate a random ID, store it, send it as a cookie.
package main
import (
"crypto/rand"
"encoding/hex"
"net/http"
)
// sessions tracks active IDs. Use a database in production.
var sessions = make(map[string]bool)
func generateSessionID() string {
// 32 bytes provides 256 bits of entropy.
b := make([]byte, 32)
rand.Read(b)
// Hex encoding yields a safe string for transport.
return hex.EncodeToString(b)
}
The handler uses the generator and sets the cookie.
func loginHandler(w http.ResponseWriter, r *http.Request) {
id := generateSessionID()
sessions[id] = true
// HttpOnly blocks JS. Secure requires HTTPS.
cookie := &http.Cookie{
Name: "session_id",
Value: id,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
}
http.SetCookie(w, cookie)
w.Write([]byte("OK"))
}
Verification checks the cookie against the map.
func protectedHandler(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session_id")
// Reject if missing or invalid.
if err != nil || !sessions[cookie.Value] {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
w.Write([]byte("Access granted"))
}
func main() {
http.HandleFunc("/login", loginHandler)
http.HandleFunc("/protected", protectedHandler)
http.ListenAndServe(":8080", nil)
}
What happens under the hood
The compiler checks types. http.Cookie fields must match the struct definition. If you pass a string where a bool is expected, the compiler rejects with cannot use "true" (untyped string constant) as bool value in struct literal. Go is strict about types. This catches mistakes early.
The http.Cookie struct has flags that control browser behavior. These flags are not optional. They protect against common attacks.
HttpOnly tells the browser to block JavaScript access to the cookie. If a malicious script runs on your page, it cannot read document.cookie. This mitigates XSS theft. Without HttpOnly, an XSS vulnerability lets an attacker steal the session ID and hijack the account.
Secure tells the browser to send the cookie only over HTTPS. If the site loads over HTTP, the browser drops the cookie. This prevents man-in-the-middle attacks where an attacker on the network intercepts the cookie.
SameSite controls when the browser sends the cookie with cross-site requests. Lax allows the cookie on top-level navigations like clicking a link. Strict blocks all cross-site requests. None allows cross-site requests but requires Secure. Lax is the safe default for most apps. It blocks CSRF attacks while allowing normal navigation.
Path scopes the cookie to a URL path. / makes the cookie available everywhere. /app restricts it to that path. Use the narrowest path that works.
Expires sets an absolute expiration time. MaxAge sets a relative duration in seconds. MaxAge is preferred because it avoids clock skew issues. The browser calculates expiration based on the received time.
The rand.Read call returns an error. Ignoring errors is a bad habit. The compiler won't stop you, but go vet might warn. Check the error. If rand.Read fails, the system is broken. Return an error or panic. The convention is if err != nil { return err }. This boilerplate makes the unhappy path visible. It forces you to handle failures.
Run gofmt on your code. It formats everything consistently. Don't argue about indentation. Let the tool decide. Most editors run it on save.
Flags are not optional. Set them every time.
Realistic session management
Real apps need expiration. Real apps store user data. Real apps handle logout.
Define a session struct with metadata.
type Session struct {
UserID string
Expires time.Time
}
var sessions = make(map[string]Session)
Store the session and set the cookie with expiration.
func loginHandler(w http.ResponseWriter, r *http.Request) {
id := generateSessionID()
sessions[id] = Session{
UserID: "user_123",
Expires: time.Now().Add(24 * time.Hour),
}
// Cookie expires when session expires.
cookie := &http.Cookie{
Name: "session_id",
Value: id,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(24 * time.Hour),
}
http.SetCookie(w, cookie)
w.Write([]byte("OK"))
}
Verification checks expiration.
func protectedHandler(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session_id")
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
sess, ok := sessions[cookie.Value]
// Reject if missing or expired.
if !ok || time.Now().After(sess.Expires) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
w.Write([]byte("Access granted"))
}
Logout deletes the session and clears the cookie.
func logoutHandler(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session_id")
if err == nil {
delete(sessions, cookie.Value)
}
// Set past expiry to force browser deletion.
cookie = &http.Cookie{
Name: "session_id",
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
}
http.SetCookie(w, cookie)
w.Write([]byte("Logged out"))
}
Expire sessions. Rotate IDs. Clean up the map.
Pitfalls and runtime panics
Go maps are not thread-safe. If two requests hit /login at the exact same time, the map panics. The runtime crashes with fatal error: concurrent map writes. You need a mutex to protect the map.
var (
sessions = make(map[string]Session)
mu sync.Mutex
)
func loginHandler(w http.ResponseWriter, r *http.Request) {
id := generateSessionID()
mu.Lock()
sessions[id] = Session{
UserID: "user_123",
Expires: time.Now().Add(24 * time.Hour),
}
mu.Unlock()
// ... set cookie ...
}
Lock the map on every read and write. Forgetting to lock causes race conditions. The race detector catches this during testing. Run go test -race to find data races.
Session fixation is an attack where an attacker sets a known session ID before the user logs in. If the server accepts the existing ID, the attacker can hijack the session. Fix this by generating a new ID on login. Never trust a session ID sent by the client during login.
Timing attacks exploit differences in comparison time. If you compare session IDs character by character, an attacker can measure response times to guess the ID. Use subtle.ConstantTimeCompare for secret comparisons. It takes the same time regardless of input.
import "crypto/subtle"
func verifySession(id1, id2 string) bool {
return subtle.ConstantTimeCompare([]byte(id1), []byte(id2)) == 1
}
Maps panic under concurrency. Lock the map or use a database.
When to use sessions
Use session-based auth when you need server-side control over active sessions and want to revoke access instantly.
Use JWT tokens when you want stateless authentication across multiple services without a shared database.
Use API keys when authenticating machine-to-machine traffic where user context is irrelevant.
Use OAuth2 when you want users to log in with their Google or GitHub account instead of managing passwords.
Pick the auth method that matches your threat model.