Files
go-simple-api/lessons/lesson-06-sessions-scs-redis.md
2026-07-16 10:13:46 +03:30

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.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:

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 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:

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.