One Binary, Two Worlds
Auth across the boundary
Lesson 4 of 5
What you'll learn
- Choose between a session cookie and a bearer token for a same-origin SPA
- Write Go auth middleware and wrap individual
ServeMuxroutes with it - Handle 401 centrally in the SPA's fetch helper
Two mainstream ways to carry "who is this?" across the boundary:
- Bearer token — login returns a token, the client stores it (usually
localStorage) and sendsAuthorization: Bearer .... Works from any origin and any client — and that's its real use case: APIs consumed by third parties or native apps. The cost: any XSS can readlocalStorageand exfiltrate the token. - Session cookie — login sets a cookie; the browser attaches it automatically. Marked
HttpOnly, it's invisible to JavaScript, so XSS can't steal it. MarkedSameSite=Lax, cross-site POSTs don't carry it, which blunts CSRF. The cost: cookies are origin-scoped and browsers attach them automatically — which is fine here, because our SPA and API share one origin by design.
For this app's shape — one binary, one origin, our own frontend — the cookie wins:
http.SetCookie(w, &http.Cookie{
Name: "session", Value: token,
Path: "/", HttpOnly: true, Secure: true,
SameSite: http.SameSiteLaxMode,
})
Middleware, the Go way
Go middleware is just a function that takes a handler and returns a handler — same closures-over-deps shape you've used since the last course, one level up:
func requireAuth(sessions *SessionStore, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("session")
if err != nil || !sessions.Valid(c.Value) {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// mux.Handle("POST /api/notes", requireAuth(sessions, handleCreateNote(store)))
// mux.HandleFunc("POST /api/login", handleLogin(sessions)) // public, unwrapped
Because you wrap per route, public endpoints like /api/login never pass through the check — no allowlist config, the wiring is the policy.
401 from an API means JSON, not a redirect
A server-rendered app redirects to /login on auth failure. An API must not — the SPA's fetch would opaquely follow it and try to parse a login page as JSON. Return 401 with a JSON body and let the client decide what "logged out" looks like.
On the client, don't scatter 401 checks across components. The api<T>() helper from lesson 3 is the one choke point: on res.status === 401, clear the cached user and send the router to /login. Every component gets correct behavior for free.
The challenge models the middleware chain: wrappers deciding 401-or-next around plain handler functions.
Run it. requireAuth wraps a handler and decides: no valid session cookie means 401 and the inner handler never runs; a valid one means pass through. Note the login route is deliberately left unwrapped.
Why prefer an HttpOnly session cookie over a localStorage bearer token for this app?
Next: collapsing the two worlds back into one file — vite build, //go:embed, and the SPA fallback route.
Saved on this device. Sign in to sync your progress everywhere.