Auth
Use middleware and hooks so provider details stay out of app code.
Auth in GO(A)HT is a transport concern first. Middleware resolves the session, loads the current user, and puts auth state on the request. Handlers and services should not know whether the upstream provider is WorkOS, Auth0, Clerk, or something internal.
The rule is simple: keep provider details out of app code.
The Shape
Auth usually has four pieces:
- provider adapter code that speaks to the external identity system
- middleware that resolves the request into auth state
- hooks for app-specific user synchronization and validation
- context helpers that handlers can read without provider imports
That keeps the rest of the app talking to “current user” and “auth required”, not to tokens, JWKS documents, or provider SDKs.
Required And Optional Auth
Most apps need two middleware paths:
type AuthState struct {
User *model.User
}
type Middleware struct {
sessions SessionStore
users *service.UserService
hooks Hooks
}
func (m *Middleware) Required(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
state, err := m.authenticate(r)
if err != nil || state.User == nil {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
next.ServeHTTP(w, WithAuthState(r, state))
})
}
func (m *Middleware) Optional(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
state, err := m.authenticate(r)
if err == nil {
r = WithAuthState(r, state)
}
next.ServeHTTP(w, r)
})
}
Use required auth for pages and mutations that need an identified user. Use optional auth when the page can render for guests but benefits from a current-user context.
Route Registration
Keep the route contract obvious:
func RegisterRoutes(mux *http.ServeMux, auth *middleware.Middleware, h *Handlers) {
mux.HandleFunc("GET /login", h.Pages.Login)
mux.Handle("GET /games/{id}", auth.Optional(http.HandlerFunc(h.Pages.ShowGame)))
mux.Handle("GET /account", auth.Required(http.HandlerFunc(h.Pages.Account)))
mux.Handle("POST /games", auth.Required(http.HandlerFunc(h.API.CreateGame)))
mux.Handle("POST /games/{id}/join", auth.Required(http.HandlerFunc(h.API.JoinGame)))
}
Wrap each protected route directly, or register the full prefixed paths on the same mux. http.ServeMux does not strip /app/ before dispatching to an inner mux.
Handlers stay focused on app behavior. They do not parse provider tokens directly.
Request Context, Not Provider SDKs
Expose helpers around request context:
type contextKey string
const authStateKey contextKey = "auth_state"
func WithAuthState(r *http.Request, state AuthState) *http.Request {
ctx := context.WithValue(r.Context(), authStateKey, state)
return r.WithContext(ctx)
}
func CurrentUser(r *http.Request) *model.User {
state, _ := r.Context().Value(authStateKey).(AuthState)
return state.User
}
Then handlers read auth the same way everywhere:
func (h *PageHandlers) Dashboard(w http.ResponseWriter, r *http.Request) {
user := middleware.CurrentUser(r)
h.render.Page(w, "pages/dashboard.html", DashboardVM{User: user})
}
That keeps provider-specific code out of pages, partials, JSON handlers, and services.
Post-Auth Hook
The post-auth hook runs after the provider identity has been verified and before the request enters the app. Use it to map provider identity into local app state.
Typical responsibilities:
- find or create the local user record
- sync stable profile fields you trust from the provider
- attach tenant, org, or membership data needed by the app
Example hook shape:
type Hooks interface {
PostAuth(ctx context.Context, subject ProviderSubject) (*model.User, error)
ValidateRequest(ctx context.Context, user *model.User, r *http.Request) error
}
PostAuth is where provider claims become a local User. That translation should happen once, centrally.
Request-Time Validation Hook
The request-time validation hook runs on every authenticated request after the user has been loaded.
Use it for checks that depend on current app state:
- user is still active
- membership still exists
- org access still matches the route
- account has completed required onboarding
Example:
func (m *Middleware) authenticate(r *http.Request) (AuthState, error) {
subject, err := m.sessions.SubjectFromRequest(r)
if err != nil {
return AuthState{}, err
}
user, err := m.hooks.PostAuth(r.Context(), subject)
if err != nil {
return AuthState{}, err
}
if err := m.hooks.ValidateRequest(r.Context(), user, r); err != nil {
return AuthState{}, err
}
return AuthState{User: user}, nil
}
This is the right place for app authorization prerequisites that are broader than one handler and narrower than provider identity itself.
Session And Token Handling
Provider adapters can manage:
- callback exchange
- token refresh
- cookie/session persistence
- JWKS or token verification
App code should not.
The app-facing boundary should answer questions like:
- who is the current user?
- is auth required here?
- does this request still satisfy app-level auth rules?
Not:
- which provider issued this token?
- how do we refresh it?
- what claim name does this vendor use?
Service Boundaries
Services may depend on the current user, but they should receive an app-level user or user ID from the caller. They should not import provider SDKs or decode provider claims themselves.
Good:
err := h.games.Create(r.Context(), middleware.CurrentUser(r).ID, input)
Bad:
claims := workos.ParseClaims(r.Header.Get("Authorization"))
Templates
Templates should receive app-level auth data:
{{ if .User }}
<a href="/account">{{ .User.DisplayName }}</a>
{{ else }}
<a href="/login">Sign in</a>
{{ end }}
Templates do not need raw token claims.
Gotchas
- Optional auth should degrade cleanly. Failure to load a user should not crash a public page.
- Required auth should fail before handler logic runs.
- Keep provider claim mapping in one place. Duplicated claim parsing spreads vendor lock-in through the app.
- Use hooks for app-specific synchronization and validation instead of teaching handlers about identity plumbing.