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.
Construction
Section titled “Construction”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.
The handlers
Section titled “The handlers”| 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.
The seams under the handlers
Section titled “The seams under the handlers”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.
RequireSession and Identity
Section titled “RequireSession and Identity”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.
RequirePrivilege
Section titled “RequirePrivilege”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.
RequireCapability
Section titled “RequireCapability”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.
Composing error responses
Section titled “Composing error responses”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 cookie
Section titled “The cookie”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.
Refusing cross-origin writes
Section titled “Refusing cross-origin writes”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,HEADandOPTIONS. Never change data on a read. - Writes from your own pages.
- Requests with neither a
Sec-Fetch-Sitenor anOriginheader. 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.