15 KiB
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:
docker run --name redis-demo -p 6379:6379 -d redis:8
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
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:
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:
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:
docker exec -it redis-demo redis-cli KEYS '*'
docker exec -it redis-demo redis-cli GET "scs:session:<token-from-above>"
Line by line:
redis.Pool{...}— the same "connection pool" concept as*sql.DBfrom Lesson 3, just for Redis instead of MySQL.Dialis a function the pool calls whenever it needs a fresh connection.scs.New()— creates a*scs.SessionManagerwith 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.Storeswaps 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.cookiewon'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 viasessionManager.Put/Get, usingr.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 alsoGetBool,GetFloat,GetTime, and a genericGetreturninganyfor 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:
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:
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:
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:
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:
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:
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 wholeUserstruct. Anything else about the user (email, etc.) is looked up fresh from the database when needed (as inMe) — this keeps the session small and avoids serving stale cached user data.Mereads backsession.UserIDKeyviaGetInt, then does a realFindByIDlookup. 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:
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:
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
docker run --name redis-api -p 6379:6379 -d redis:8
go run ./cmd/api
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:
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.