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 principal

You want a user to type a password once, and then have every later request know who they are. The flow has four moves:

  1. Check the password against a stored hash.
  2. Issue an opaque session cookie and set it on the response.
  3. On later requests, look the cookie up and recover the Principal.
  4. 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 compositions iet-auth-login-htmx-axum and iet-stack-auth-htmx-axum. Add iet-stack-web-axum (an alias for RestApi) 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 when secure_cookies is false.

The minimal path

1. Build the providers

This one struct is the whole assembly. Each line names a real constructor.

rust
// fragment: `database: TimescaleDatabase`, `bcrypt: BcryptConfig`, and
// `session_cache_capacity: usize` come from your startup code.
use iet_auth::{Authenticate, Invalidate, PasswordCredentials, ProfileStore as _,
              Secret, SessionRecord, TokenIssuer};
use iet_auth_basic::BasicAuth;
use iet_auth_basic_timescale::CredentialStore;
use iet_auth_profiles_timescale::ProfileStore;
use iet_auth_session_cache::CacheSessions;
use iet_auth_session_opaque::Sessions;
use iet_cache_memory::MemoryCache;
use std::sync::Arc;
use std::time::Duration;

const TTL: Duration = Duration::from_secs(3600);

/// 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 = Sessions<CacheSessions<MemoryCache<SessionRecord>>>;

#[derive(Clone)]
pub struct Services {
    pub auth: Arc<BasicAuth<CredentialStore>>,
    pub sessions: Arc<SessionProvider>,
    profiles: ProfileStore,
    pub database: iet_db_timescale::TimescaleDatabase,
}

impl Services {
    pub async fn new(
        database: iet_db_timescale::TimescaleDatabase,
        bcrypt: iet_auth_basic::config::BcryptConfig,
        session_cache_capacity: usize,
    ) -> iet_core::AppResult<Self> {
        // Password check: bcrypt verifier plus a same-cost dummy hash, built once.
        let auth = BasicAuth::configured(CredentialStore::new(database.clone()), bcrypt).await?;
        let profiles = ProfileStore::new(database.clone());
        // Cookie issue/lookup/revoke over a dedicated, namespace-scoped store.
        let sessions = Sessions::new(
            CacheSessions::new(
                MemoryCache::<SessionRecord>::new(session_cache_capacity)?
                    .analytics("app_sessions", "session"),
                "app".into(),
            )?,
            iet_auth_session_opaque::config::SessionConfig { ttl_seconds: TTL.as_secs() },
        )?;
        Ok(Self { auth: Arc::new(auth), sessions: Arc::new(sessions), profiles, database })
    }
}

Why each piece exists:

  • BasicAuth<CredentialStore> verifies a PasswordCredentials ({ login, password }). It looks the account up through the injected CredentialStore and 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 its SHA-256 digest, checks expiry on every authenticate, and implements Invalidate. CacheSessions is its store, and MemoryCache backs that store.
  • ProfileStore answers 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.

rust
// fragment: these impls are added to `Services` from step 1.
use iet_auth::PasswordCredentials;
use iet_auth_login_htmx_axum::{
    BrowserLogin, BrowserSession,
    flows::{BrowserFlow, Command, Flow, Outcome, ProfileChoice},
};

impl BrowserLogin for Services {
    /// Move 1 + 2: verify the password, then issue the cookie value.
    async fn login(&self, credentials: &PasswordCredentials) -> iet_core::AppResult<BrowserSession> {
        let identity = self.auth.authenticate(credentials).await?;
        Ok(BrowserSession {
            token: self.sessions.issue(&identity).await?,
            ttl: TTL,
        })
    }
}

impl BrowserFlow for Services {
    /// Backs the /profiles page: which personas may this session pick?
    async fn page(
        &self,
        _flow: Flow,
        session: Option<&Secret<String>>,
    ) -> iet_core::AppResult<Vec<ProfileChoice>> {
        let session = session.ok_or_else(invalid)?;
        let identity = self.sessions.authenticate(session).await?;
        Ok(self
            .profiles
            .list_enabled(&identity.subject)
            .await?
            .into_iter()
            .map(|id| ProfileChoice { label: id.clone(), id })
            .collect())
    }

    /// Move 4 (and persona switching), driven by the /logout and /profiles forms.
    async fn submit(&self, command: Command) -> iet_core::AppResult<Outcome> {
        match command {
            Command::Logout { session } => {
                self.sessions.authenticate(&session).await?;
                self.sessions.invalidate(&session).await?;
                Ok(Outcome::Done)
            }
            Command::SelectProfile { session, profile } => {
                let mut identity = self.sessions.authenticate(&session).await?;
                if !self.profiles.enabled(&identity.subject, &profile).await? {
                    return Err(invalid());
                }
                identity.profile = Some(profile);
                let token = self.sessions.issue(&identity).await?;
                // Rotate: if revoking the old session fails, drop the new one too.
                if let Err(error) = self.sessions.invalidate(&session).await {
                    self.sessions.invalidate(&token).await?;
                    return Err(error);
                }
                Ok(Outcome::Session(BrowserSession { token, ttl: TTL }))
            }
            _ => Err(invalid()), // registration/recovery are not enabled here
        }
    }
}

fn invalid() -> iet_core::AppError {
    iet_core::AppError::new(
        iet_core::ErrorKind::Unauthenticated,
        "app.invalid",
        "Invalid request",
    )
}

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

rust
// fragment: `services: Services`, `csrf: SignedCsrf`.
use iet_auth_login_htmx_axum::SignedCsrf;
use iet_stack_auth_htmx_axum::{AuthStack, StackConfig};
use iet_stack_web_axum::WebStack;

let csrf = SignedCsrf::new()?;
// Keep a clone if you also build BrowserSecurity for protected handlers.

let mut config = StackConfig::default();
config.login.session_cookie = "app_session".into();
// config.login.secure_cookies stays true in production.

let api = AuthStack::login(services.clone(), csrf)
    .account(services)            // enables /logout and /profiles
    .configuration(config)
    .register(WebStack::new())?;  // mounts /login, /login/submit, and assets

let app = api.module("notes", note_adapter::routes())?.finish(AppState { /* ... */ });
  • .register(api) returns the same RestApi, so you keep adding modules. The login module is auth.login.ui (10/min) and assets are auth.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 using RestApi.

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::login verifies the password and issues a session. The response sets the session cookie with HttpOnly; 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 call Sessions::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 Invalidate deletes 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 returns 429 at 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 is 403 (bad token) or 503 (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_all touches 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 with 204. Backend errors render a generic service.error body.
  • Capability tests register each Timescale auth integration separately and assert the exact table set — registration never pulls in a sibling table.
sh
cargo test -p example-htmx-starter --test framework_auth_stack \
  --test framework_cached_sessions --test framework_auth_rest

The browser and cached-session tests use in-memory stores and need no database.

Common mistakes

  • Assuming mounting login protects your app. /login is a route, not middleware. Validate the cookie in your own handlers (BrowserSecurity, BrowserIdentity, or ProtectedForm; see the browser-UI guide).
  • Disabling secure_cookies while keeping a __Host- cookie name. The validator rejects it. Use a plain name for local HTTP, or run local HTTPS.
  • Expecting BasicAuth to 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 MemoryCache between 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> tries B only when A returns Forbidden. 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-*-axum integrations over bearer tokens on the same RestApi builder.

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.
  • SignedCsrf tokens are reusable within their TTL, and standard Alpine (used by the login assets) is not a strict-CSP-compatible build.

Where to go next

IET Framework documentation — built with the IET Framework.