13 KiB
Lesson 8 — Auth Middleware & Route Protection
New Go concepts in this lesson:
context.Contextin 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 stringthenconst userContextKey contextKey = "user"— why not just use the plain string"user"directly? Becausecontext.WithValuekeys 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 typecontextKey, our keyuserContextKeycan never collide with a plainstringkey 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 tonext.ServeHTTPin 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 tow(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;WithValuereturns a brand-new context wrapping the old one plus the new key/value pair. The originalr.Context()is untouched.r = r.WithContext(ctx)— similarly,*http.Requestis designed so you don't mutate its context in place;WithContextreturns a new*http.Request(a shallow copy) with the new context attached. Reassigningrto this new value is how we "carry" the updated context forward.next.ServeHTTP(w, r)— passing the newr(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)—Valuereturnsany(could be anything, ornilif the key isn't present), so we need a type assertion (.(*User)) to convert it back to our concrete type. The two-value formuser, ok := ...is the safe version:okisfalseif 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 checkMedid manually in Lesson 6.userRepo.FindByID(...)— same repository lookupMedid.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 aboutuserContextKeyat 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 reusinghandlers.writeError, becauseinternal/middlewareandinternal/handlersare separate packages, andwriteErroris unexported inhandlers. 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 actualfunc(http.Handler) http.Handler(same "call it once to get the real middleware" pattern asRequestLogger(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 —/meis protected, but/register//login//logout(registered outside the group) are not. Add future authenticated-only routes inside this samer.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.