Skip to content

Sessions over HTTP

authkit turns gouncer’s primitives into a working session transport. It exports handlers and middleware, never a router, so it mounts the same way on the standard library’s mux, chi, or anything else that populates r.PathValue.

auth := authkit.New(authkit.Config{
Store: store, // any gouncer.Store
CookieName: "__Host-myapp_session", // empty applies "__Host-session"
SessionTTL: 0, // zero applies gouncer's default
Privileged: gouncer.Roles{"admin"}, // empty admits every role
Capabilities: registry, // nil fills no capabilities
})

One Config value carries every knob. SessionTTL bounds the issued session and the cookie’s MaxAge from the same value, so the two expiries cannot drift apart. Privileged names the roles that pass RequirePrivilege, explained below. authkit copies the list, so changing your slice later does not change the gate. Capabilities is optional: a role registry such as goncierge, read by RequireCapability.

Handler Route shape Behavior
auth.Login POST /api/auth/login Verifies credentials, issues the session, sets the cookie, responds with the identity
auth.Logout POST /api/auth/logout Deletes the session server side and clears the cookie
auth.Session GET /api/auth/session Reports the logged-in identity, mounted behind RequireSession

Login rejects unknown emails, wrong passwords, and disabled accounts with one indistinguishable 401. Logout without a cookie is a success, and logging out twice is not an error.

Every handler above is a thin wrapper around a plain method that does the real work, and those methods are public. Call them directly when you have no HTTP request in hand, for example from a GraphQL resolver, a CLI command, or a test:

Seam Answers
auth.Authenticate(ctx, email, password) The Identity, or ErrInvalidCredentials
auth.StartSession(ctx, userID) The *http.Cookie to set
auth.EndSession(ctx, token) The clearing *http.Cookie
auth.SessionIdentity(ctx, token) The Identity behind a token
auth.CookieName() The configured cookie name
identity, err := auth.Authenticate(ctx, email, password)
if errors.Is(err, authkit.ErrInvalidCredentials) {
return unauthorized()
}
cookie, err := auth.StartSession(ctx, identity.ID)

ErrInvalidCredentials covers unknown emails, wrong passwords, and disabled accounts alike, so a caller cannot tell which of the three happened. Authenticate also does the same amount of password work whether the email exists or not, so a login attempt takes the same time either way and response timing leaks nothing. You get both protections automatically by calling the seam.

The two session seams return a cookie instead of setting one, leaving it to you to attach it to whatever response you are building. CookieName tells you which cookie carries the token, so you never hard-code a name that Config can change.

mux.Handle("GET /api/reports", auth.RequireSession(http.HandlerFunc(handleReports)))

RequireSession admits only requests carrying a usable session cookie. Downstream handlers read the authenticated user from the request context:

identity := authkit.IdentityFromContext(r.Context())

Identity carries the id, email, name and role. With a Capabilities source it also carries capabilities, what the role may do. If that source also answers Grantable, as goncierge’s registry does, it carries grantable, the roles the account may hand out. Each list is sent empty rather than missing. Without a source neither key appears. Credential material never enters the request context. For middleware of your own that composes with authkit’s, WithIdentity is exported too.

Some routes are for administrators only. RequirePrivilege sits after RequireSession and admits only a role named in Config.Privileged:

mux.Handle("GET /api/settings", auth.RequireSession(auth.RequirePrivilege(http.HandlerFunc(handleSettings))))

Any other role gets a 403 with the code role_insufficient. An account with no role never passes. If Privileged is empty the middleware admits everyone, so adding it to a route changes nothing until you name the roles.

A check like “is this role admin” ties every route to your role names. For anything finer, ask for a capability instead.

mux.Handle("POST /api/events", auth.RequireSession(auth.RequireCapability("events.manage")(http.HandlerFunc(handleEvents))))

RequireCapability admits a request whose identity carries the capability. Any other gets a 403 with the code capability_missing, and meta.capability names the capability.

Mind the opposite default. Without a Capabilities source no identity carries a capability, so every request is refused.

The capabilities are read once per request, when RequireSession resolves the session. A grant or a revoke in the registry counts from the next request.

Every error authkit writes is a JSON object with two parts. The error field is a message in English. The code field is a short fixed name like credentials_invalid. Clients should match on the code. The message can change, the code does not.

{"error": "invalid credentials", "code": "credentials_invalid"}

Your own handlers can answer in the same shape. Respond writes a value, RespondError writes an ErrorResponse with its message and code, and a bounded Decode caps request bodies and rejects trailing content. For error mapping, chain your domain’s cases in front of the auth mapping:

func errorResponseFor(err error) (int, authkit.ErrorResponse) {
switch {
case errors.Is(err, myapp.ErrNotFound):
return http.StatusNotFound, authkit.ErrorResponse{Message: err.Error(), Code: "item_not_found"}
}
if status, response, ok := authkit.ErrorResponseForAuthError(err); ok {
return status, response
}
return http.StatusInternalServerError, authkit.ErrorResponse{Message: "internal error", Code: "internal"}
}

Unrecognized errors mask as internal so backend details never leak into responses.

The codes authkit can answer with:

Code Meaning
credentials_invalid Wrong email or password
session_absent No usable session cookie
role_insufficient The role may not use this route
capability_missing The role lacks the capability, meta.capability names it
role_unknown No role source declares the role, meta.field is role
role_beyond_reach The actor may not hand out that role or change that account
role_changed The account’s role changed before the write landed
body_malformed The request body is not valid JSON
body_too_large The request body is over the cap
body_field_required A required field is missing, meta.field names it
email_invalid, email_taken The email is malformed, or already in use
name_required, name_too_long The name is empty, or over the cap
password_too_short, password_too_long The password is outside the bounds
user_id_malformed, user_not_found The id is not a UUID, or matches no account
self_disable_refused, self_role_refused An account changing itself
last_privileged_refused The last administrator cannot be removed
request_cross_origin A browser write came from another origin
internal Something failed on the server

The rate limiter adds login_rate_limited. The React package turns the role codes into typed errors a screen can catch.

The session cookie is __Host- prefixed, HttpOnly, Secure, Path=/, and SameSite=Lax. Browsers enforce the prefix’s guarantees: no Domain attribute, secure origins only. The practical consequences, including why development on localhost just works and why production requires TLS in front, live in the operations contract.

The browser decides which cookies travel with a request, so a write can carry your session cookie even when another site’s page sent it. SameSite=Lax stops most of these, but it treats every subdomain of your domain as the same site. CrossOriginGuard refuses a browser write whose page stands at another origin. Mount it once at the root of your router, before any route:

router.Use(authkit.CrossOriginGuard(logger))

A nil logger writes to slog.Default. The guard is the standard library’s http.CrossOriginProtection with authkit’s error answer.

What passes:

  • Reads: GET, HEAD and OPTIONS. Never change data on a read.
  • Writes from your own pages.
  • Requests with neither a Sec-Fetch-Site nor an Origin header. Scripts, other servers and API clients send neither.

Every browser since 2023 names where a request came from in Sec-Fetch-Site. An older browser sends only Origin, and the guard compares its host with the request’s Host header. It never compares the scheme, so send Strict-Transport-Security to keep older browsers on HTTPS. Behind a proxy, pass the visitor’s Host header through unchanged.

A refused write gets a 403 with the code request_cross_origin. The server logs write refused with the reason fetch-site when the browser sent Sec-Fetch-Site, or origin when it sent only Origin.