# Lesson 6 — Server-Side Sessions with scs + Redis > **New Go concepts in this lesson:** working with a connection pool for a > second kind of backing store (Redis, via redigo), middleware composing > with something other than chi's own middleware. Nothing brand new at > the language level here — mostly applying everything from the Go Basics > lessons to a new library. ## What "server-side session" means, concretely The browser only ever holds a random, meaningless token in a cookie. All the actual session **data** (which user is logged in, etc.) lives in Redis, keyed by that token. This is different from storing data directly inside a signed/encrypted cookie: server-side sessions can be instantly revoked (delete the Redis key), don't grow the cookie as you store more data, and never expose their contents to the browser at all. ## Part A — standalone playground First, run Redis: ```bash docker run --name redis-demo -p 6379:6379 -d redis:8 ``` ```bash mkdir ~/go-playground/session-demo && cd ~/go-playground/session-demo go mod init session-demo go get github.com/alexedwards/scs/v2@latest go get github.com/alexedwards/scs/redisstore@latest go get github.com/gomodule/redigo@latest ``` **`main.go`** ```go package main import ( "fmt" "log" "net/http" "time" "github.com/alexedwards/scs/redisstore" "github.com/alexedwards/scs/v2" "github.com/gomodule/redigo/redis" ) // A package-level session manager - scs is designed to be created once // and reused everywhere, similar to how we handle *sql.DB. var sessionManager *scs.SessionManager func main() { // 1. Build a Redis connection pool (redigo, not redis/go-redis - this // is the client library scs's redisstore is built on). pool := &redis.Pool{ MaxIdle: 10, Dial: func() (redis.Conn, error) { return redis.Dial("tcp", "127.0.0.1:6379") }, } // 2. Create the session manager and point its Store at Redis. sessionManager = scs.New() sessionManager.Store = redisstore.New(pool) sessionManager.Lifetime = 24 * time.Hour sessionManager.Cookie.Name = "session_id" sessionManager.Cookie.HttpOnly = true sessionManager.Cookie.SameSite = http.SameSiteLaxMode mux := http.NewServeMux() mux.HandleFunc("/set", setHandler) mux.HandleFunc("/get", getHandler) mux.HandleFunc("/clear", clearHandler) // 3. Wrap the whole mux with LoadAndSave - this is scs's own // middleware, same shape as chi's: func(http.Handler) http.Handler. log.Println("listening on :4000") log.Fatal(http.ListenAndServe(":4000", sessionManager.LoadAndSave(mux))) } func setHandler(w http.ResponseWriter, r *http.Request) { // Put stores a value in the session, keyed by string. sessionManager.Put(r.Context(), "username", "hamid") sessionManager.Put(r.Context(), "visits", 1) fmt.Fprintln(w, "session data set") } func getHandler(w http.ResponseWriter, r *http.Request) { // GetString / GetInt read back typed values. If the key doesn't // exist, they return the zero value ("" or 0), not an error. username := sessionManager.GetString(r.Context(), "username") visits := sessionManager.GetInt(r.Context(), "visits") // Exists checks presence explicitly, useful to distinguish "never // set" from "set to zero value". if !sessionManager.Exists(r.Context(), "username") { fmt.Fprintln(w, "no session data yet - try /set first") return } fmt.Fprintf(w, "username=%s visits=%d\n", username, visits) } func clearHandler(w http.ResponseWriter, r *http.Request) { // Destroy wipes the session entirely and tells the browser to delete // the cookie. if err := sessionManager.Destroy(r.Context()); err != nil { http.Error(w, "failed to destroy session", http.StatusInternalServerError) return } fmt.Fprintln(w, "session destroyed") } ``` Run it: ```bash go run . ``` In another terminal, **use `-c cookies.txt -b cookies.txt`** so curl remembers the session cookie between requests, just like a browser would: ```bash curl -c cookies.txt -b cookies.txt http://localhost:4000/set curl -c cookies.txt -b cookies.txt http://localhost:4000/get curl -c cookies.txt -b cookies.txt http://localhost:4000/clear curl -c cookies.txt -b cookies.txt http://localhost:4000/get # back to "no session data yet" ``` While it's running, peek into Redis directly to *see* the session data living server-side: ```bash docker exec -it redis-demo redis-cli KEYS '*' docker exec -it redis-demo redis-cli GET "scs:session:" ``` Line by line: - `redis.Pool{...}` — the same "connection pool" concept as `*sql.DB` from Lesson 3, just for Redis instead of MySQL. `Dial` is a function the pool calls whenever it needs a fresh connection. - `scs.New()` — creates a `*scs.SessionManager` with sensible defaults (in-memory store, no cookie config yet). - `sessionManager.Store = redisstore.New(pool)` — by default scs stores sessions **in memory** (lost on restart, useless across multiple server instances). Setting `.Store` swaps the backend to Redis — same manager, same API, completely different storage underneath. This is the same "swap the implementation, keep the interface" idea from Lesson 2's Text/JSON handler swap. - `sessionManager.Lifetime = 24 * time.Hour` — how long a session stays valid since it was created. - `sessionManager.Cookie.HttpOnly = true` — the browser's JavaScript can't read this cookie (`document.cookie` won't show it), blocking a large class of XSS-based session theft. - `sessionManager.Cookie.SameSite = http.SameSiteLaxMode` — restricts when the browser sends this cookie on cross-site requests, mitigating CSRF (more on this in Lesson 9). - **How it all connects**: `sessionManager.LoadAndSave(mux)` wraps your entire mux, same middleware pattern from Lesson 2. On every request: it reads the session cookie, loads that session's data from Redis into the request's context, lets your handler run (which reads/writes session data via `sessionManager.Put`/`Get`, using `r.Context()` to know *which* session it's operating on), then after your handler finishes, saves any changes back to Redis and writes/refreshes the cookie on the response. You never touch cookies or Redis directly. - `sessionManager.Put(r.Context(), "username", "hamid")` — stores a value under a string key, scoped to the session identified by this request's cookie. - `sessionManager.GetString(...)` / `GetInt(...)` — typed getters. There's also `GetBool`, `GetFloat`, `GetTime`, and a generic `Get` returning `any` for custom types. - `sessionManager.Destroy(r.Context())` — deletes the session from Redis and instructs the browser (via response headers) to expire the cookie. Try stopping and restarting your Go program (Ctrl+C, `go run .` again) *without* restarting Redis — set a session, restart the app, `GET` again. The session survives, because it never lived in your Go process's memory in the first place. ## Part B — apply it to the project **Add the dependencies:** ```bash go get github.com/alexedwards/scs/v2@latest go get github.com/alexedwards/scs/redisstore@latest go get github.com/gomodule/redigo@latest ``` **Extend `internal/config/config.go`** with Redis settings: ```go type Config struct { Port string DBHost string DBPort string DBUser string DBPassword string DBName string RedisAddr string } func Load() Config { return Config{ Port: getEnv("PORT", "8080"), DBHost: getEnv("DB_HOST", "127.0.0.1"), DBPort: getEnv("DB_PORT", "3306"), DBUser: getEnv("DB_USER", "root"), DBPassword: getEnv("DB_PASSWORD", "devpass"), DBName: getEnv("DB_NAME", "go_simple_api"), RedisAddr: getEnv("REDIS_ADDR", "127.0.0.1:6379"), } } ``` **`internal/session/session.go`** — builds the shared session manager: ```go package session import ( "net/http" "time" "github.com/alexedwards/scs/redisstore" "github.com/alexedwards/scs/v2" "github.com/gomodule/redigo/redis" "git.hamidsoltani.com/hamid/go-simple-api/internal/config" ) func New(cfg config.Config) *scs.SessionManager { pool := &redis.Pool{ MaxIdle: 10, Dial: func() (redis.Conn, error) { return redis.Dial("tcp", cfg.RedisAddr) }, } manager := scs.New() manager.Store = redisstore.New(pool) manager.Lifetime = 24 * time.Hour manager.Cookie.Name = "session_id" manager.Cookie.HttpOnly = true manager.Cookie.SameSite = http.SameSiteLaxMode return manager } ``` Identical to Part A's setup, wrapped in `New(cfg)` so `main.go` builds it the same way it builds `database.NewMySQL(...)` and `logging.New()`. **`internal/session/keys.go`** — a central place for session data keys: ```go package session const UserIDKey = "user_id" ``` Defining this constant once avoids typos across files that would silently break authentication (e.g. one file writes `"user_id"`, another reads `"userId"` — the compiler can't catch that for you if they're raw strings; a shared constant makes that class of bug impossible). **Update `internal/handlers/auth.go`** — inject the session manager, and actually start a session on login. Update the struct and constructor: ```go package handlers import ( "encoding/json" "errors" "log/slog" "net/http" "github.com/alexedwards/scs/v2" "golang.org/x/crypto/bcrypt" "git.hamidsoltani.com/hamid/go-simple-api/internal/models" "git.hamidsoltani.com/hamid/go-simple-api/internal/session" ) type AuthHandler struct { userRepo *models.UserRepository sessions *scs.SessionManager logger *slog.Logger } func NewAuthHandler(userRepo *models.UserRepository, sessions *scs.SessionManager, logger *slog.Logger) *AuthHandler { return &AuthHandler{userRepo: userRepo, sessions: sessions, logger: logger} } ``` (`Register` is unchanged from Lesson 5 — leave it as-is.) Update `Login` to actually create a session, and add `Logout` + `Me`: ```go func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { var req loginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } user, err := h.userRepo.FindByEmail(r.Context(), req.Email) if errors.Is(err, models.ErrUserNotFound) { writeError(w, http.StatusUnauthorized, "invalid email or password") return } if err != nil { h.logger.Error("find user by email failed", "error", err) writeError(w, http.StatusInternalServerError, "internal error") return } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { writeError(w, http.StatusUnauthorized, "invalid email or password") return } // Prevent session fixation: issue a fresh session token now that the // user's privilege level is about to change (anonymous -> authenticated). if err := h.sessions.RenewToken(r.Context()); err != nil { h.logger.Error("renew token failed", "error", err) writeError(w, http.StatusInternalServerError, "internal error") return } h.sessions.Put(r.Context(), session.UserIDKey, user.ID) writeJSON(w, http.StatusOK, map[string]any{ "id": user.ID, "email": user.Email, }) } func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { if err := h.sessions.Destroy(r.Context()); err != nil { h.logger.Error("destroy session failed", "error", err) writeError(w, http.StatusInternalServerError, "internal error") return } writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"}) } func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { userID := h.sessions.GetInt(r.Context(), session.UserIDKey) if userID == 0 { writeError(w, http.StatusUnauthorized, "not logged in") return } user, err := h.userRepo.FindByID(r.Context(), userID) if errors.Is(err, models.ErrUserNotFound) { writeError(w, http.StatusUnauthorized, "not logged in") return } if err != nil { h.logger.Error("find user by id failed", "error", err) writeError(w, http.StatusInternalServerError, "internal error") return } writeJSON(w, http.StatusOK, map[string]any{ "id": user.ID, "email": user.Email, }) } ``` What's new: - `h.sessions.RenewToken(r.Context())` — generates a brand-new session token while keeping the session's existing data intact, invalidating the old token. This is **preventing session fixation**: if an attacker somehow got a victim to use a *known* session token before login, renewing it at the moment of authentication makes the pre-login token useless. Always call this right before a privilege change (login here). - `h.sessions.Put(r.Context(), session.UserIDKey, user.ID)` — this is the entire "session" from the server's perspective: we store the user's ID, not the whole `User` struct. Anything else about the user (email, etc.) is looked up fresh from the database when needed (as in `Me`) — this keeps the session small and avoids serving *stale* cached user data. - `Me` reads back `session.UserIDKey` via `GetInt`, then does a real `FindByID` lookup. This route is your template for **any** future route that needs "the current logged-in user" — in Lesson 8 we'll extract the "check the session, else 401" part into reusable middleware instead of repeating it in every handler. **Update `internal/router/router.go`**: ```go package router import ( "database/sql" "log/slog" "time" "github.com/alexedwards/scs/v2" "github.com/go-chi/chi/v5" chimw "github.com/go-chi/chi/v5/middleware" "git.hamidsoltani.com/hamid/go-simple-api/internal/handlers" "git.hamidsoltani.com/hamid/go-simple-api/internal/middleware" "git.hamidsoltani.com/hamid/go-simple-api/internal/models" ) func New(logger *slog.Logger, db *sql.DB, sessions *scs.SessionManager) *chi.Mux { r := chi.NewRouter() r.Use(chimw.RequestID) r.Use(middleware.RequestLogger(logger)) r.Use(chimw.Recoverer) r.Use(chimw.Timeout(60 * time.Second)) // scs's own middleware must wrap every route that touches sessions. // Simplest for now: wrap the whole router. r.Use(sessions.LoadAndSave) r.Get("/health", handlers.Health) userRepo := models.NewUserRepository(db) authHandler := handlers.NewAuthHandler(userRepo, sessions, logger) r.Post("/register", authHandler.Register) r.Post("/login", authHandler.Login) r.Post("/logout", authHandler.Logout) r.Get("/me", authHandler.Me) return r } ``` `r.Use(sessions.LoadAndSave)` — exactly like Part A's manual wrapping, but as chi middleware. `sessions.LoadAndSave` already has the `func(http.Handler) http.Handler` shape chi's `Use` expects, so it's passed directly (same as `chimw.Recoverer`). **Update `cmd/api/main.go`**: ```go sessions := session.New(cfg) logger.Info("session manager configured", "redis_addr", cfg.RedisAddr) r := router.New(logger, db, sessions) ``` (Add `"git.hamidsoltani.com/hamid/go-simple-api/internal/session"` to imports.) ## Try it ```bash docker run --name redis-api -p 6379:6379 -d redis:8 go run ./cmd/api ``` ```bash curl -c cookies.txt -X POST http://localhost:8080/login \ -H "Content-Type: application/json" \ -d '{"email":"hamid@example.com","password":"secret123"}' curl -b cookies.txt http://localhost:8080/me curl -b cookies.txt -c cookies.txt -X POST http://localhost:8080/logout curl -b cookies.txt http://localhost:8080/me # should now be unauthorized ``` Check Redis directly to see your real session sitting there server-side: ```bash docker exec -it redis-api redis-cli KEYS '*' ``` Once `/me` correctly returns your user after login and fails after logout, move to Lesson 7 — Google OAuth login.