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:

  1. provider adapter code that speaks to the external identity system
  2. middleware that resolves the request into auth state
  3. hooks for app-specific user synchronization and validation
  4. 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.