Compose authentication for a browser application
Use this when: a browser user needs to sign in and stay signed in.
This is an integration guide: it assumes an application, account storage, and browser routes. It is not a prerequisite for your first HTTP endpoint.
Keep separate: authentication identifies a user; authorization controls what that user may access.
sequenceDiagram
participant B as Browser
participant A as Your application
participant S as Session store
B->>A: Submit username + password
A->>A: Verify password
A->>S: Create session
A-->>B: Set session cookie
B->>A: Later request + cookie
A->>S: Look up session
S-->>A: Authenticated principalYou want a user to type a password once, and then have every later request know who they are. The flow has four moves:
- Check the password against a stored hash.
- Issue an opaque session cookie and set it on the response.
- On later requests, look the cookie up and recover the
Principal. - On logout, revoke the cookie so it stops working.
Everything below assembles those four moves with the same providers the starter uses. The framework deliberately keeps them separate: you choose a password verifier, a session mechanism, and a CSRF mechanism independently, then connect them with two small trait implementations.
Snippet convention
Snippets are fragments, not complete crates. // fragment: lines say what is
assumed. Services, AppState, and similar names are yours. The shapes follow
examples/htmx-starter/servers/web/src/providers.rs and the auth stack crates.
Prerequisites
- A database if you want real credential and profile records
(
iet-auth-basic-timescale,iet-auth-profiles-timescale). The session and CSRF providers here do not need one. - The crates:
iet-auth,iet-auth-basic,iet-auth-basic-timescale,iet-auth-profiles-timescale,iet-auth-session-opaque,iet-auth-session-cache,iet-cache-memory,iet-core, and the compositionsiet-auth-login-htmx-axumandiet-stack-auth-htmx-axum. Addiet-stack-web-axum(an alias forRestApi) if you mount account modules. - HTTPS in production. The default session cookie is
__Host-fw_session, which a browser only accepts over a secure origin. The route validator rejects a__Host-/__Secure-name whensecure_cookiesisfalse.
The minimal path
1. Build the providers
This one struct is the whole assembly. Each line names a real constructor.
// fragment: `database: TimescaleDatabase`, `bcrypt: BcryptConfig`, and
// `session_cache_capacity: usize` come from your startup code.
use ;
use BasicAuth;
use CredentialStore;
use ProfileStore;
use CacheSessions;
use Sessions;
use MemoryCache;
use Arc;
use Duration;
const TTL: Duration = from_secs;
/// Sessions live in a bounded process-local cache, so request authentication
/// never queries the database. Capacity is the hard bound on live sessions.
type SessionProvider = ;
Why each piece exists:
BasicAuth<CredentialStore>verifies aPasswordCredentials({ login, password }). It looks the account up through the injectedCredentialStoreand verifies the hash. It does not speak HTTP Basic, and it issues no token. The dummy hash makes an unknown user cost the same as a known one, so timing does not reveal account existence.Sessions<...>is the opaque mechanism: it generates a random 256-bit key, stores only itsSHA-256digest, checks expiry on everyauthenticate, and implementsInvalidate.CacheSessionsis its store, andMemoryCachebacks that store.ProfileStoreanswers whether a subject may use a persona. Personas are identity-owned; they are not roles.Arc<T>forwards the auth traits, so the same providers can be shared with the browser stack and the REST integrations without duplicating state.
2. Implement the two browser ports
The login routes call exactly two provider trait methods. You own the policy; the integration owns the HTML.
// fragment: these impls are added to `Services` from step 1.
use PasswordCredentials;
use ;
The trait's contract: page must authenticate protected sessions and return only
owned/enabled profiles; submit must revoke on logout and rotate (issue new +
revoke old) on profile selection. Backend failures must be errors, never simulated
success.
3. Mount the routes
// fragment: `services: Services`, `csrf: SignedCsrf`.
use SignedCsrf;
use ;
use WebStack;
let csrf = new?;
// Keep a clone if you also build BrowserSecurity for protected handlers.
let mut config = default;
config.login.session_cookie = "app_session".into;
// config.login.secure_cookies stays true in production.
let api = login
.account // enables /logout and /profiles
.configuration
.register?; // mounts /login, /login/submit, and assets
let app = api.module?.finish;
.register(api)returns the sameRestApi, so you keep adding modules. The login module isauth.login.ui(10/min) and assets areauth.assets(limiter disabled); logout/profiles are 30/min. Passing the same builder twice fails — modules are named and duplicates are rejected..account(provider)enables only logout and profile selection..registration()and.password_recovery()are separate opt-ins..routes::<()>()is the state-free equivalent when you are not usingRestApi.
The remaining defaults are defined in crates/stacks/iet-stack-auth-htmx-axum/src/config.rs:
login.path = /login, session_cookie = __Host-fw_session,
csrf_cookie = fw_login_nonce, asset_prefix = /assets, secure_cookies = true,
and account paths /logout, /profiles, /register, /password/reset,
/password/reset/complete. Validation runs before routing and rejects conflicting
paths and unsafe cookie settings.
4. What happens on each request
- Login.
BrowserLogin::loginverifies the password and issues a session. The response sets the session cookie withHttpOnly; SameSite=Lax, and clears the anonymous CSRF nonce cookie. - Later requests. Your protected handlers must validate the cookie. The login
routes do not do this for you.
BrowserSecurity(browser-UI guide) or the REST integrations' extractors callSessions::authenticate, which is a cache lookup on the digest — the database is not touched on this path. - Profile selection. Re-reads the session, checks
ProfileStore::enabled, issues a new session, and revokes the old one. - Logout. Revokes the session and clears the cookie. A failed revocation is an error, not a success.
The same page also discusses CSRF. There are two bindings, and the integration
selects correctly: the anonymous login form binds its token to a random nonce
cookie, while protected flows (logout, profiles) bind the token to the session
secret. SignedCsrf issues a 54-character HMAC-SHA256 token with an 8-byte
expiry; it is reusable for ten minutes and the signing key rotates on restart.
That is CSRF protection, not replay prevention.
Why an opaque session in a process-local cache
This was a choice, and it has a cost.
- It makes logout and profile rotation authoritative, because
Invalidatedeletes the digest. - It keeps request authentication off the database, because only login and profile actions query the credential/profile stores. Overall the app still uses the database; it is only the per-request session check that does not.
- It ends sessions early on cache eviction or process restart, and — with this setup — a disabled identity or profile is enforced when a session is issued or selected, not on every later request. Collision coordination is local to clones of one store, not distributed.
If any of that is wrong for you, swap just the mechanism: iet-auth-session-jwt
(stateless HS256, optional denylist, no immediate logout when stateless),
iet-auth-session-timescale (durable, shareable), or Argon2Auth for Argon2id
instead of bcrypt. The ports stay the same, but the surrounding wiring — a
database-backed session store, an extra profile lookup — does change. For the full
feature inventory, see the technical notes in docs/authentication.md.
Expected outcomes and tests
framework_auth_stack.rs: each route gets its own rate budget and returns429at the boundary; assets bypass the limiter; conflicting paths and unsafe cookie settings are rejected before routing; a failed logout never clears the cookie, and a missing/invalid CSRF token is403(bad token) or503(verifier failure).framework_cached_sessions.rs: store failures propagate as the store's error; an expired record is rejected independently; two keys are independent and share revocation;revoke_alltouches only its namespace; concurrent inserts of the same digest yield exactly one winner and never replace the existing identity.framework_auth_rest.rs: the parallel JSON/bearer path (iet_auth_basic_axum,iet_auth_session_axum,iet_auth_profiles_axum) returns{ access_token, token_type: "Bearer" }, rotates on profile selection, and answers logout with204. Backend errors render a genericservice.errorbody.- Capability tests register each Timescale auth integration separately and assert the exact table set — registration never pulls in a sibling table.
The browser and cached-session tests use in-memory stores and need no database.
Common mistakes
- Assuming mounting login protects your app.
/loginis a route, not middleware. Validate the cookie in your own handlers (BrowserSecurity,BrowserIdentity, orProtectedForm; see the browser-UI guide). - Disabling
secure_cookieswhile keeping a__Host-cookie name. The validator rejects it. Use a plain name for local HTTP, or run local HTTPS. - Expecting
BasicAuthto speak HTTP Basic. It does not. - Expecting a disabled-identity check on every request. With the cached setup it happens at issue/select time.
- Sharing one
MemoryCachebetween sessions and unrelated data. Sessions get a dedicated, namespaced store. - Trusting forwarded headers for rate-limit identity. Per-IP limiters use
ConnectInfo; behind a proxy add an explicit trusted-proxy policy. - Treating signup/reset as present. They are opt-in and disabled in the starter.
Advanced choices
- Session mechanism: opaque+cache, JWT, or Timescale-backed (see above).
- Policy:
iet-authz::All<A, B>requires both;Any<A, B>triesBonly whenAreturnsForbidden. Failures and unauthenticated/internal errors propagate. - Password algorithm: bcrypt (default) or explicit Argon2id.
- Public flows:
.registration()/.password_recovery()with your own invitation, normalization, and delivery policy. - REST transports: the independent
iet-auth-*-axumintegrations over bearer tokens on the sameRestApibuilder.
Limitations
- The starter's auth is a local-only demonstration: a seeded identity, HTTP cookies, and public demo credentials. Do not deploy it as production auth.
- The cached session setup is not coordinated across processes or instances.
SignedCsrftokens are reusable within their TTL, and standard Alpine (used by the login assets) is not a strict-CSP-compatible build.
Where to go next
- Store the credentials and profiles: Add persistence.
- Protect a mutation with the session cookie: Build an HTMX page.
- Permission and ownership checks: Authorization.