Files
go-simple-api/lessons/lesson-08-auth-middleware.md
T
2026-07-16 10:13:46 +03:30

13 KiB

Lesson 8 — Auth Middleware & Route Protection

New Go concepts in this lesson: context.Context in depth (context.WithValue, r.WithContext), private types used purely as unique context keys, type assertions applied for real. This is the concept-heaviest lesson in the course — take it slowly, and don't skip Part A.

Why we need this

Right now, Me manually checks the session and returns 401 if there's no user. As we add more protected routes later, copy-pasting that check into every handler is error-prone: forget it once, and you've got an unprotected route. The fix is middleware that guards routes, plus using the request's context to hand the logged-in user down to whichever handler runs next.

Part A — standalone playground

This lesson is really about one core Go mechanism: context.Context as a way to pass request-scoped values through a middleware chain. Let's build it from scratch, no chi, no scs — just net/http and context.

mkdir ~/go-playground/context-demo && cd ~/go-playground/context-demo
go mod init context-demo

main.go

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
)

// 1. A custom type for our context key. Using a plain string like "user"
// as a key is risky - other packages might use the same string and
// silently collide. A private, unexported type guarantees uniqueness.
type contextKey string

const userContextKey contextKey = "user"

type User struct {
	ID    int
	Email string
}

// 2. Middleware that pretends to authenticate a request (checks a fake
// header instead of a real session, just to isolate the context concept).
func fakeAuthMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		token := r.Header.Get("Authorization")

		if token != "secret-token" {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return // IMPORTANT: we do NOT call next.ServeHTTP - chain stops here
		}

		user := &User{ID: 1, Email: "hamid@example.com"}

		// 3. Store the user in a NEW context, derived from the request's
		// existing context, then build a NEW request carrying that context.
		ctx := context.WithValue(r.Context(), userContextKey, user)
		r = r.WithContext(ctx)

		// 4. Pass the request onward - now carrying the user.
		next.ServeHTTP(w, r)
	})
}

func protectedHandler(w http.ResponseWriter, r *http.Request) {
	// 5. Read the value back out of the context, in the final handler.
	user, ok := r.Context().Value(userContextKey).(*User)
	if !ok {
		// Should never happen if the middleware ran correctly, but
		// defensive code is cheap insurance.
		http.Error(w, "no user in context", http.StatusInternalServerError)
		return
	}

	fmt.Fprintf(w, "hello, %s (id=%d)\n", user.Email, user.ID)
}

func main() {
	mux := http.NewServeMux()
	mux.Handle("/protected", fakeAuthMiddleware(http.HandlerFunc(protectedHandler)))

	log.Println("listening on :4000")
	log.Fatal(http.ListenAndServe(":4000", mux))
}

Run it:

go run .

Test it:

curl http://localhost:4000/protected
# 401 unauthorized

curl -H "Authorization: secret-token" http://localhost:4000/protected
# hello, hamid@example.com (id=1)

Line by line — this is the trickiest idiom in the whole course, worth sitting with:

  • type contextKey string then const userContextKey contextKey = "user" — why not just use the plain string "user" directly? Because context.WithValue keys are compared by both type and value. If two unrelated packages both used the plain string "user" as a key, they'd accidentally read/overwrite each other's data. By defining our own named type contextKey, our key userContextKey can never collide with a plain string key or another package's own custom-typed key — even if the underlying text is identical. This is a well-known, idiomatic Go pattern specifically to avoid that collision class.
  • if token != "secret-token" { http.Error(...); return } — note there's no call to next.ServeHTTP in this branch. This is the entire mechanism of "blocking" a request in middleware: simply don't call the next handler. The chain just stops, and whatever you already wrote to w (here, the 401) is the final response.
  • context.WithValue(r.Context(), userContextKey, user) — contexts are immutable. You can't add a value to an existing context; WithValue returns a brand-new context wrapping the old one plus the new key/value pair. The original r.Context() is untouched.
  • r = r.WithContext(ctx) — similarly, *http.Request is designed so you don't mutate its context in place; WithContext returns a new *http.Request (a shallow copy) with the new context attached. Reassigning r to this new value is how we "carry" the updated context forward.
  • next.ServeHTTP(w, r) — passing the new r (with the user embedded) onward. Anything called after this point — more middleware, or the final handler — can pull the user back out.
  • r.Context().Value(userContextKey).(*User)Value returns any (could be anything, or nil if the key isn't present), so we need a type assertion (.(*User)) to convert it back to our concrete type. The two-value form user, ok := ... is the safe version: ok is false if the assertion fails (wrong type, or key missing) instead of panicking. Always use the two-value form when the value's presence isn't 100% guaranteed — a single-value assertion panics on failure, crashing your whole request.

Try removing the Authorization header check entirely and calling protectedHandler directly, without going through the middleware — you'll see the ok false-path trigger, since nothing populated the context.

Part B — apply it to the project

We'll build real middleware that checks the actual session (Lesson 6), loads the actual user from MySQL (Lesson 4), and stores it in context using the exact pattern from Part A.

internal/middleware/require_auth.go

package middleware

import (
	"context"
	"log/slog"
	"net/http"

	"github.com/alexedwards/scs/v2"

	"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
	"git.hamidsoltani.com/hamid/go-simple-api/internal/session"
)

type contextKey string

const userContextKey contextKey = "current_user"

// RequireAuth is a middleware FACTORY - same three-layer shape as
// RequestLogger from Lesson 2. It takes the dependencies it needs
// (sessions, userRepo, logger), and returns the actual chi middleware.
func RequireAuth(sessions *scs.SessionManager, userRepo *models.UserRepository, logger *slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			userID := sessions.GetInt(r.Context(), session.UserIDKey)
			if userID == 0 {
				writeUnauthorized(w)
				return
			}

			user, err := userRepo.FindByID(r.Context(), userID)
			if err != nil {
				// Covers both "not found" (e.g. account deleted after
				// login) and real DB errors - either way, this request
				// cannot proceed as authenticated.
				logger.Error("require auth: find user failed", "error", err, "user_id", userID)
				writeUnauthorized(w)
				return
			}

			ctx := context.WithValue(r.Context(), userContextKey, user)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

// CurrentUser is how handlers pull the authenticated user back out.
// Handlers never touch userContextKey directly - they just call this.
func CurrentUser(r *http.Request) *models.User {
	user, ok := r.Context().Value(userContextKey).(*models.User)
	if !ok {
		return nil
	}
	return user
}

func writeUnauthorized(w http.ResponseWriter) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusUnauthorized)
	w.Write([]byte(`{"error":"unauthorized"}`))
}

This should now read very familiarly — it's Part A's pattern with the fake pieces swapped for real ones:

  • sessions.GetInt(...) — same check Me did manually in Lesson 6.
  • userRepo.FindByID(...) — same repository lookup Me did.
  • context.WithValue / r.WithContext / next.ServeHTTP(w, r.WithContext(ctx)) — identical mechanism from Part A.
  • CurrentUser(r *http.Request) *models.User — a small exported helper function, not a method, wrapping the type assertion so handlers never need to know about userContextKey at all (it's unexported — package-private — precisely so only this file can create or read that specific key). This pairs a private context key with a public accessor function, a common Go idiom.
  • writeUnauthorized — a tiny local helper, written by hand instead of reusing handlers.writeError, because internal/middleware and internal/handlers are separate packages, and writeError is unexported in handlers. This is an intentional package boundary, not an oversight — if we wanted to share it, we'd need to export it (WriteError) from a package both can import.

Simplify Me in internal/handlers/auth.go now that middleware does the lookup:

func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
	user := middleware.CurrentUser(r)
	if user == nil {
		writeError(w, http.StatusUnauthorized, "not logged in")
		return
	}

	writeJSON(w, http.StatusOK, map[string]any{
		"id":    user.ID,
		"email": user.Email,
	})
}

Add the import: "git.hamidsoltani.com/hamid/go-simple-api/internal/middleware". Me no longer touches h.sessions or does a FindByID itself at all — the middleware already did that work before Me ever runs, and just hands us the result via CurrentUser(r). The nil check stays as a defensive safety net (in case someone wires this handler up without the middleware by mistake), but in normal operation it should never trigger.

Update internal/router/router.go to apply RequireAuth to /me, using chi's route grouping:

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/config"
	"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"
	"git.hamidsoltani.com/hamid/go-simple-api/internal/oauth"
)

func New(logger *slog.Logger, db *sql.DB, sessions *scs.SessionManager, cfg config.Config) *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))
	r.Use(sessions.LoadAndSave)

	r.Get("/health", handlers.Health)

	userRepo := models.NewUserRepository(db)
	authHandler := handlers.NewAuthHandler(userRepo, sessions, logger)
	requireAuth := middleware.RequireAuth(sessions, userRepo, logger)

	r.Post("/register", authHandler.Register)
	r.Post("/login", authHandler.Login)
	r.Post("/logout", authHandler.Logout)

	// Group: every route inside here goes through requireAuth first.
	r.Group(func(r chi.Router) {
		r.Use(requireAuth)
		r.Get("/me", authHandler.Me)
	})

	googleConfig := oauth.NewGoogleConfig(cfg)
	googleHandler := handlers.NewGoogleOAuthHandler(googleConfig, userRepo, sessions, logger)

	r.Get("/auth/google/login", googleHandler.Login)
	r.Get("/auth/google/callback", googleHandler.Callback)

	return r
}
  • requireAuth := middleware.RequireAuth(sessions, userRepo, logger) — calling the middleware factory once, producing the actual func(http.Handler) http.Handler (same "call it once to get the real middleware" pattern as RequestLogger(logger) in Lesson 2).
  • r.Group(func(r chi.Router) { ... }) — chi's way of scoping middleware to a subset of routes instead of the whole router. Inside the group, r.Use(requireAuth) only applies to routes registered within that same closure/me is protected, but /register//login//logout (registered outside the group) are not. Add future authenticated-only routes inside this same r.Group(...) block.

Try it

go run ./cmd/api
curl http://localhost:8080/me
# {"error":"unauthorized"}

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
# now works

Try logging out and hitting /me again — should go back to unauthorized, this time via the middleware instead of manual logic inside the handler.

A sanity check on your understanding: if you comment out r.Use(requireAuth) inside the Group, /me will still correctly return 401 (via Me's defensive nil check on CurrentUser(r)), not a crash — because middleware.CurrentUser(r) finds nothing in the context (the middleware never ran to put it there), and Me's check catches that. Try it and read the log line that gets printed.

Once the protected/unprotected split works, move to Lesson 9 — rate limiting & security hardening.