A browser login checks credentials once, then stores an account identifier in
the session. On later requests, auth.SessionIdentity loads the current account
from that identifier. You own the accounts, password checks, and account-status
policy; Sessions owns cookie
configuration, storage, and response-time persistence.
The examples assume an existing router web, session store store, configured
hash.Hasher, and application repository accounts.
Import auth, session, and response from their arkoren.dev/core/http/
packages, csrf from arkoren.dev/core/http/middleware/csrf, and standard-library
context, errors, and net/http where used.
Load Accounts
Keep the session small: store an account ID, not a snapshot of permissions. The loader can then reject a deleted or disabled account on the next request. This application separates the handler identity from its password record:
type User struct {
ID int
Name string
}
type Account struct {
User
PasswordHash []byte
Disabled bool
SessionVersion int
}
Here accounts.ByID(ctx, id) returns Account; the application sentinel
ErrAccountNotFound means absence, while other errors retain storage failures.
Bind the loader to the identifier type int:
loadUser := func(ctx context.Context, id int) (User, error) {
account, err := accounts.ByID(ctx, id)
if errors.Is(err, ErrAccountNotFound) {
return User{}, auth.ErrNoCredentials
}
if err != nil {
return User{}, err
}
if account.Disabled {
return User{}, auth.ErrNoCredentials
}
return account.User, nil
}
The loader runs on every request with a usable stored identifier. Absence and disabled status become anonymous requests; a storage outage instead stops the request. Returning a zero user with nil error would authenticate it, so do not use that result for absence. If you cache accounts, your invalidation policy determines how quickly account changes take effect.
Enable Login
Construct one identity definition and share it between middleware and handlers:
identity := auth.NewSessionIdentity(loadUser)
web.Use(session.Middleware(store))
web.Use(csrf.Middleware())
web.Use(auth.Middleware(identity.Authenticator()))
Session middleware must run first. Keep CSRF protection
on login, logout, and other state-changing browser routes: browsers send session
cookies automatically, including on requests another site can initiate. Add
auth.Require[User]() only to routes requiring a signed-in account, not to the
login route itself.
Construction does not call the loader. Check identity.Err() during setup
when configuration is assembled dynamically; a zero definition or nil loader
returns auth.ErrInvalidSessionIdentity, not an anonymous result. Missing
sessions, missing identity fields, and incompatible stored types are anonymous.
Persisted JSON that remains compatible can still decode after an identifier
type change; a type change is not a revocation mechanism.
Check Passwords
Start with validated request input, not an arbitrary email or account ID from a header. A login request has only the fields needed to check credentials:
type LoginInput struct {
Email string `json:"email"`
Password string `json:"password"`
}
Use the same rejection for an unknown email, a wrong password, and a disabled
account. Otherwise login becomes an account-enumeration endpoint. The following
fragment runs in your login handler with validated in LoginInput and a
configured hasher *hash.Hasher. accounts.ByEmail returns the same application
absence sentinel as ByID:
account, err := accounts.ByEmail(r.Context(), in.Email)
if err != nil && !errors.Is(err, ErrAccountNotFound) {
return err
}
found := err == nil
storedHash := dummyHash
if found {
storedHash = account.PasswordHash
}
dummyHash is a hash of a non-secret placeholder, computed once at startup
with the same hasher and cost as real passwords. Check it on the missing-account
path so that path does not skip the deliberately expensive password check:
matches, err := hasher.Check(in.Password, storedHash)
if err != nil {
return err
}
if !found || !matches || account.Disabled {
return auth.ErrUnauthenticated
}
The explicit !found check prevents a password matching the dummy hash from
creating a login. This reduces a timing difference; it does not guarantee equal
response times. Preserve storage and hash-check errors rather than classifying
them as bad passwords, and keep passwords and hashes out of error messages.
Apply login rate limits before credential
checks to limit guessing and hashing work.
Create password hashes at registration using the hashing guide. Keep the dummy hash aligned with cost changes. For old hashes, follow hash upgrades, including its conditional write that avoids overwriting a concurrent password reset.
Start Sessions
After the credential checks succeed, resolve the identity’s selected session and store the verified account ID. Do both before writing response headers:
current, err := identity.Session(r.Context())
if err != nil {
return err
}
if err := identity.Login(current, account.ID); err != nil {
return err
}
return response.Status(w, http.StatusNoContent)
Login writes the identifier and then regenerates the session ID. Rotation
protects against session fixation, where an attacker knows an ID planted
before the victim signs in. If the field write fails, no rotation occurs and
you must not report success. Login preserves other session values, creation
time, and expiry; configure idle and absolute limits in
session lifetimes.
This call changes the request’s session, not persistent storage. On a non-5xx
response, session middleware saves changes and retires the old ID before
committing the response. Failed persistence or retirement can leave an old ID
usable; configure and monitor the middleware’s error handling rather than
treating Login success as proof of durable revocation. See
Save Changes for the commit and
concurrency boundaries.
End Sessions
Mount logout as a CSRF-protected POST, not a GET. Resolve the same identity’s
session and remove its login before sending a response:
current, err := identity.Session(r.Context())
if err != nil {
return err
}
if err := identity.Logout(current); err != nil {
return err
}
return response.Status(w, http.StatusNoContent)
Logout removes even a stale, wrongly typed identifier and rotates the ID after
successful deletion. Other values, such as a language preference, remain.
Use current.Destroy() instead when the whole session must end. Both paths
still depend on response-time persistence; a failed retirement can leave the
old ID valid.
Revoke Account Sessions
To end sessions across devices after a password change or compromise, store an account version with the ID. This is an alternative identity definition, not an additional identity entry:
type SessionUser struct {
ID int
Version int
}
Change your loader to func(context.Context, SessionUser) (User, error).
After loading the account and handling absence or storage errors as above,
reject a stale version:
if account.Disabled || account.SessionVersion != saved.Version {
return User{}, auth.ErrNoCredentials
}
return account.User, nil
Here saved is the loader’s SessionUser argument. Reconstruct the definition
with that loader and supply both values after successful credential checks:
if err := identity.Login(current, SessionUser{
ID: account.ID,
Version: account.SessionVersion,
}); err != nil {
return err
}
Increment the persisted SessionVersion to invalidate older versions on each
device’s next account lookup. No session enumeration is required. Existing
in-flight requests are not retroactively canceled, and account caching can
delay the effect.
Select Sessions
If you configured a nondefault session context slot, share one handle between the middleware and the identity:
handle := session.NewHandle()
identity := auth.NewSessionIdentity(loadUser, func(binding *auth.SessionBuilder) {
binding.Handle(handle)
})
web.Use(session.Middleware(store, func(cookies *session.Builder) {
cookies.Handle(handle)
}))
Register CSRF and authentication after this session middleware as before.
identity.Session(ctx) selects only this slot and returns session.ErrNoSession
for an absent or nil session; it never falls back to the default slot. A zero
handle selects the default. Explicit Login and Logout arguments are not
checked against the handle, so use identity.Session to select them consistently.
A handle is not cookie or storage isolation. Definitions writing to the same
physical session share arkoren:auth:user: the last login replaces the identity,
and logout removes it. Independent browser sessions also need distinct cookie
and storage configuration in Sessions.
For authentication tests, combine direct identity tests
with a real cookie-based login/logout test that exercises persistence.
For all functions and options, see the
auth API reference.