Authentication
ghtmx ships a first-party secure cookie session authentication
middleware: a framework-agnostic core (github.com/go-monolith/ghtmx/auth)
plus thin glue packages for the framework adapters that have middleware
systems. The library owns the request-side mechanics โ cookie hygiene,
opaque token handling, CSRF, htmx-aware login redirects โ behind one
small interface the application implements. It does not own users,
passwords, or authorization.
htmx's own security guidance says it plainly: the best way to do
authentication with htmx is cookies, set with Secure, HttpOnly, and
SameSite. This package makes those defaults โ and the harder parts
the essays defer to OWASP โ correct by construction.
The one interface you implement
type Authenticator[ID any] interface {
// Authenticate returns the identity behind a session token, or an
// error wrapping auth.ErrUnauthorized when the session is unusable
// (expired, revoked, unknown).
Authenticate(ctx context.Context, token string) (ID, error)
}
Session storage, lookup, expiry policy, user records, password hashing โ
all behind this interface, all your application's business. The ID
type parameter is whatever identity shape your application uses: a user
ID, a struct, anything.
Authenticate is where real session expiry and revocation are
enforced. The cookie's TTL only tells a well-behaved browser when to
drop the cookie; a stolen token must die server-side. When your store
says a token is dead, return an error wrapping auth.ErrUnauthorized โ
the middleware clears the cookie (so the browser stops resending a
value that can never work) and redirects to login. Any other error is
answered with a plain 500: an auth-store outage must not degrade into
"please sign in".
Configuration
cfg := auth.Config[string]{
Authenticator: store, // required
LoginURL: "/login", // required
CookieName: "ghtmx_session", // default
CookiePath: "/", // default
SameSite: auth.SameSiteStrict, // default; SameSiteLax allowed
TTL: 30 * 24 * time.Hour, // 0 = browser-session cookie
// Insecure: true // local http dev only
}
The zero value of every optional field is the safe choice:
HttpOnlyis always on and not configurable โ there is no good reason to turn it off for a session cookie, and an option would invite it.Secureis on unless you setInsecure: true(the field is inverted so that forgetting it means secure). UseInsecureonly for local http development.SameSiteisStrict(default) orLaxโNoneis unrepresentable. The choice is UX, not security: the always-on CSRF layer keeps both settings safe.Strictis right for portals and dashboards nothing links into.Laxis right for an app users arrive at via external links โ underStrictthat first navigation carries no cookie and a signed-in user lands on the login page.- The cookie is always host-only โ no
Domainattribute is ever set, so the session is never shared with subdomains. - The
__Host-prefix is applied automatically when the cookie is site-wide (CookiePath/) and Secure.Config.EffectiveCookieNamereports the name on the wire. SettingCookiePathto a sub-app path (say/admin) keeps the token off the rest of the host's requests, but is mutually exclusive with the__Host-prefix, which requiresPath=/โ pick per app.
Config.Validate reports anything unusable. The middleware
constructors call it and panic at startup โ a bad Config is a wiring
bug and must not serve a single request. Call Validate yourself first
if you prefer an error.
Installing the middleware
Install the session middleware first, then the CSRF middleware. The CSRF token is derived from the session and travels in the request context; with no session middleware ahead of it, the CSRF layer rejects every unsafe request โ it fails closed, never open.
net/http and chi
The core package's middleware is net/http's shape, so
adapters/nethttp servers and chi routers use it directly โ there is
no separate glue package:
import "github.com/go-monolith/ghtmx/auth"
r := chi.NewRouter() // or any net/http mux
r.Group(func(r chi.Router) {
r.Use(auth.Middleware(cfg), auth.CSRF())
r.Get("/private", privateHandler)
})
In handlers:
id, ok := auth.IdentityFrom[string](r.Context())
if !ok {
// Middleware not installed ahead of this handler, or the wrong ID
// type โ a wiring bug either way. Refuse the request and say why
// in the log; never treat it as anonymous.
}
token, _ := auth.CSRFTokenFrom(r.Context())
gin, echo, fiber, fiber v3
Each framework has a glue package with the same seven-function surface
(New, CSRF, IdentityFrom, SetSessionCookie,
ClearSessionCookie, SetLoginCSRFCookie, ValidLoginCSRF), enforced
by a parity gate:
| Framework | Package |
|---|---|
| gin | github.com/go-monolith/ghtmx/adapters/gin/ginauth |
| echo | github.com/go-monolith/ghtmx/adapters/echo/echoauth |
| fiber v2 | github.com/go-monolith/ghtmx/adapters/fiber/fiberauth |
| fiber v3 | github.com/go-monolith/ghtmx/adapters/fiberv3/fiberv3auth |
// gin
priv := r.Group("/", ginauth.New(cfg), ginauth.CSRF())
// echo
priv := e.Group("", echoauth.New(cfg), echoauth.CSRF())
// fiber v2
priv := app.Group("/", fiberauth.New(cfg), fiberauth.CSRF())
// fiber v3 โ same shape, different package
priv := app.Group("/", fiberv3auth.New(cfg), fiberv3auth.CSRF())
The gin and echo glue delegate to the same engine functions the core
middleware uses. The fiber glue is implemented natively on fasthttp โ
including each fiber major's own correct cookie-deletion serialization,
which differ โ but authentication, token comparison, cookie attribute
policy, and the rule deciding when a request body may carry a token
(auth.HasFormContentType) are all shared code, not restatements of
it. Auditing one adapter therefore tells you about the others.
Cookie deletion is the one place the two fiber majors genuinely diverge,
and it is worth knowing why, because the surface reason is wrong:
neither major serializes cookies through net/http. Both write the
header with fasthttp. The fasthttp release fiber v3 requires emits
Max-Age=0 for a negative MaxAge, so fiberv3auth.ClearSessionCookie
is the plain MaxAge -1; the older release fiber v2 pins drops a
non-positive Max-Age from the header entirely, which would leave the
cookie alive, so fiberauth.ClearSessionCookie adds the past-Expires
incantation. Each package's tests pin its own emitted header.
Observing rejections
CSRF takes options, and they are observability only โ nothing about
them changes whether a request is accepted. auth.WithOnReject runs
just before the 403, so an application can count probes or debug a
migration without wrapping the middleware:
hook := auth.WithOnReject(func(ctx context.Context, rej auth.CSRFRejection) {
// The identity, when there is one, comes from the same context โ
// which is why one hook shape works for every adapter.
id, _ := auth.IdentityFrom[string](ctx)
log.Warn("csrf rejected", "method", rej.Method, "path", rej.Path, "user", id)
})
http.Handler(auth.CSRF(hook)(next)) // core
priv := app.Group("/", fiberv3auth.New(cfg), fiberv3auth.CSRF(hook)) // any adapter
The option type is the core package's, so the same hook value moves
between frameworks unchanged. rej.Path is the request path in every
adapter โ never the route pattern, and never the query string, which is
not a token channel here. The hook runs on the request's goroutine, so
hand anything expensive to a queue.
The login flow
The login page is yours; the middleware only needs its URL. A complete flow with the core package (the glue packages mirror it):
// GET /login โ render the form with a pre-session CSRF token.
func loginForm(w http.ResponseWriter, r *http.Request) {
formValue, err := auth.SetLoginCSRFCookie(w, cfg)
if err != nil { /* 500 */ }
// Render your login page with:
// <input type="hidden" name="login_csrf" value={ formValue }>
}
// POST /login โ check the token before the credentials.
func login(w http.ResponseWriter, r *http.Request) {
if !auth.ValidLoginCSRF(r, cfg, r.PostFormValue("login_csrf")) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// ... verify credentials your way (bcrypt, argon2, LDAP, ...) ...
token, storedHash, err := auth.NewSessionToken()
if err != nil { /* 500 */ }
// Persist storedHash (never the token) with the user and expiry.
auth.SetSessionCookie(w, cfg, token)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// POST /logout โ inside the middleware chain.
func logout(w http.ResponseWriter, r *http.Request) {
// Delete the session record server-side, then:
auth.ClearSessionCookie(w, cfg)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
Why the login form needs its own token: SameSite does not protect
the login form โ on either setting. It governs whether an existing
cookie is sent, not whether an unauthenticated POST is accepted โ so
login CSRF (an attacker submitting their credentials from the
victim's browser, after which the victim unknowingly works, and is
audited, as the attacker) still works without it. The session CSRF
token can't help either: there is no session yet. The pre-session
double-submit cookie closes the gap.
Session fixation is impossible by construction: there is no anonymous
session to upgrade. A token is minted only at successful sign-in
(auth.NewSessionToken), never read from a pre-login cookie and
promoted.
Tokens: store the hash
auth.NewSessionToken returns a 256-bit random token and its SHA-256
hex. Only the hash is ever stored server-side, so a database disclosure
(backup, dump, read-only SQL injection) cannot be replayed as a live
session; at lookup time, hash the presented token with
auth.HashSessionToken and query by that. Plain SHA-256 is correct
here โ the input is uniform randomness, so a slow KDF would buy nothing
but per-request latency. (Password hashing is a different problem and
stays your application's business.)
CSRF
The CSRF layer is always on, per-session, and needs no storage: the token is derived from the session token (domain-separated, so it is computable from neither the stored hash nor vice versa) and installed in the request context by the session middleware.
Unsafe methods (everything but GET, HEAD, OPTIONS, and QUERY โ the
auth.SafeMethod list; QUERY is htmx 4's read-only method with a body)
must carry the token in one of two channels:
-
The
X-CSRF-Tokenheader (ghtmx.DefaultCSRFHeaderName) โ the stronger channel: a browser won't attach a custom header cross-origin without a CORS preflight. For htmx elements, the existingghtmx.CSRFHeaderhelper emits thehx-headersattribute. Placed on a common ancestor (e.g.<body>) it reaches every element below it โ but how it reaches them depends on the pinned htmx version:<!-- htmx 2.0.x: attributes are inherited implicitly --> <body hx-headers={ ghtmx.CSRFHeader(token) }> <!-- htmx 4.0.0: inheritance is explicit; without :inherited the header never leaves <body> and every unsafe request is rejected --> <body hx-headers:inherited={ ghtmx.CSRFHeader(token) }>with
tokenread from the request context viaauth.CSRFTokenFrom. Under an htmx 4 pin the compiler reports the first form asGHTMX-W0202(with a CSRF remark), so the mistake does not reach production silently; seedocs/official/pages/htmx-versions.mdfor the rest of the 2 โ 4 differences. -
A hidden form field named
_csrf(auth.DefaultCSRFFormField) for plain HTML form posts:<input type="hidden" name="_csrf" value={ token }>
The query string is never consulted โ tokens must not ride in URLs,
where they leak into logs and Referer headers. Comparison is
constant-time and rejects empty strings outright
(subtle.ConstantTimeCompare("", "") returns 1; auth.EqualTokens
does not repeat that mistake).
Why keep the token layer when SameSite is already set? Because
SameSite protects at the registrable-domain level, not the subdomain
level: a hostile โ or merely XSS'd โ sibling subdomain is same-site and
can still forge requests. The token also keeps the middleware
fail-closed if the cookie policy is ever loosened.
Note on form parsing: the form fallback uses Request.PostFormValue,
which parses and caches the request body (multipart bodies at the
stdlib's 32 MB default). A handler that later calls
ParseMultipartForm with its own limit sees the cached parse. Prefer
the header channel for htmx elements; it reads no body at all.
The safe-method list, and declining QUERY
v0.2.0 widened the safe-method list for every application, whatever
htmx version it pins. Before it, GET, HEAD, and OPTIONS were exempt;
from it, QUERY is too. auth.SafeMethod is runtime code and cannot see
the compile-time htmxVersion pin, so upgrading the module is enough
to change the CSRF surface โ no hx-query and no htmx 4 pin required.
The precondition is that some route answers QUERY at all โ which is
easier to hit than it sounds. An http.ServeMux pattern that names no
method matches every method, so a plain mux.Handle("/x", h) or
mux.HandleFunc("/x", h) hands a QUERY request straight to its
handler; only patterns written "POST /x" restrict it. Fiber v3 carries
QUERY in its DefaultMethods, so a route registered with app.All(...)
โ or a handler mounted as middleware โ answers it too. gin and echo
route QUERY only where a route names it explicitly. On v0.1.x such a
request was refused for a missing token; from v0.2.0 it is exempt. If
any handler of yours mutates state on QUERY, it must stop: QUERY is
safe and idempotent by specification, and the work belongs on POST,
PUT, PATCH, or DELETE.
To decline the exemption instead, give the middleware its own
safe-list. auth.WithSafeMethods replaces the default wholesale, and
works identically for auth.CSRF and for every adapter's glue:
// The pre-v0.2.0 safe-list: QUERY needs a token like any other method.
htmx2 := auth.WithSafeMethods(
http.MethodGet, http.MethodHead, http.MethodOptions)
mux.Handle("/", auth.Middleware(cfg)(auth.CSRF(htmx2)(app)))
// ... or, behind an adapter:
priv := app.Group("/", fiberv3auth.New(cfg), fiberv3auth.CSRF(htmx2))
auth.DefaultSafeMethods() returns the default list if you would
rather extend it than replace it. The list is matched exactly, so pass
methods in upper case; calling auth.WithSafeMethods() with no
arguments makes every method unsafe, GET included.
Testing your handlers
Handler tests don't need to run the middleware โ build the context directly:
ctx := auth.ContextWithIdentity(context.Background(), "user-1")
ctx = auth.ContextWithCSRFToken(ctx, "test-token")
req := httptest.NewRequest("GET", "/private", nil).WithContext(ctx)
What this package deliberately does not do
- User/password storage, password hashing policy, registration,
lockout, throttling โ application concerns behind
Authenticator. - Roles and authorization โ identity is generic; layer your own guards
on top of
IdentityFrom. - Login page UI โ the middleware only needs
LoginURL. - JWTs / stateless sessions โ opaque server-side tokens are the right
default for server-rendered apps, and the
token -> identityinterface keeps the library out of the crypto-policy business.