Code

Diagnose and fix — the dev exemption + the token

The diagnosis. "referer not supplied" is the Origin/Referer half of the defense talking — not the token half. gorilla/csrf assumes production HTTPS, so for every unsafe request it demands proof of same-secure-origin: a valid Origin or Referer header over TLS. Your dev server is plain http://localhost, which offers no such proof, so the check rejects the request before it ever looks at the token. That's why adding a token field alone wouldn't have helped — you have to get past the origin check first.

So the fix is two moves: (a) tell the middleware "this request really is plaintext HTTP" in dev only, and (b) give the forms their token so the second half passes too.

Five files as tabs: router.gonav.goauth.goauth.templlayout.templ.

1. internal/handlers/router.go (modify) — the dev-only exemption, added before csrf.Protect:

if !h.Cfg.IsProd() {
	r.Use(func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
			next.ServeHTTP(w, csrf.PlaintextHTTPRequest(req))
		})
	})
}

PlaintextHTTPRequest relaxes only the Origin/Referer check, and only when !IsProd(). Production (HTTPS at Railway's edge) never runs this, so its strict origin protection stays fully armed. The token check is untouched in both.

2. internal/web/nav.go (modify) — Nav gains a CSRFToken field.

3. internal/handlers/auth.go (modify) — nav() fills it: web.Nav{CSRFToken: csrf.Token(r)}. (csrf.Token reads the per-request token the middleware set.)

4. internal/web/auth.templ (modify) — an authCSRF helper renders the hidden field, and every form calls it:

templ authCSRF(token string) {
	<input type="hidden" name="gorilla.csrf.Token" value={ token }/>
}
// inside each <form>:  @authCSRF(nav.CSRFToken)

5. internal/web/layout.templ (modify) — the logout form in the header gets the same hidden field.

Verify — it works now. templ generate && go run ./cmd/signflow. Log in: you reach the dashboard. Register, log out — all fine. View source on /login and you'll see the token:

<input type="hidden" name="gorilla.csrf.Token" value="Ck3p...long-random...">

And the forgery still fails: a POST without the field is rejected even in dev (the token half is always on):

$ curl -si -X POST localhost:8080/login -d 'email=a@b.co&password=x' | head -1
HTTP/1.1 403 Forbidden        ← no token → still blocked. Exactly right.

HTMX and the X-CSRF-Token header. Later, uploads POST via HTMX with a multipart body. Re-reading that body just to find a hidden field is wasteful — so gorilla/csrf also accepts the token in an X-CSRF-Token header. Put the token in a <meta> tag and have HTMX send it on every request:

<meta name="csrf-token" content={ nav.CSRFToken }/>
<body hx-headers='{"X-CSRF-Token": "…"}'>

Same token, sent in a header instead of the body — the multipart upload validates without anyone re-parsing it. You'll use this in the documents phase.

The lesson wasn't the fix — it was the 403. You now know CSRF defense is two checks: prove same-origin intent (Origin/Referer) and echo a secret token. The token is the half everyone remembers; the origin check is the half that 403'd you. Both have to pass.