BuildBot

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 ServeMux routes 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 sends Authorization: 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 read localStorage and 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. Marked SameSite=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.

Middleware chain: 401 or next (JS model)

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.

Loading editor…
Knowledge check

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.