Build an HTMX page with a CSRF-protected mutation

Use this when: you want HTML pages and forms instead of a JSON-only API.

Before you begin: working browser authentication. This guide adds a page, a form input, and a protected POST handler.

flowchart LR
    get["GET /notes"] --> page["Render page + CSRF field"]
    page --> post["POST form"]
    post --> check["Verify session + CSRF + input"]
    check --> save["Call your service"]
    save --> redirect["Redirect to updated page"]

You have a notes list and a form that adds one. Submitting the form should add the note and redirect back to an updated page (HTMX follows the redirect and re-renders the full page), while a browser without JavaScript still works.

This keeps the Note and CreateNote shapes from the notes application: a note has id and text, and the contract requires non-blank text. The browser adds one transport concern — a CSRF field — in a separate form DTO, so the domain contract stays free of transport data.

Snippet convention

Snippets are fragments. // fragment: lines say what is assumed. Note, Notes, MyAuth, and AppState are yours. The state and handler shapes below were compile-checked in a throwaway crate against this checkout.

Prerequisites

  • A signed-in session cookie: Compose authentication. The cookie name here must match your login configuration (app_session in this guide; __Host-fw_session by default).
  • A Note type and a Notes port. The tutorial's public memory store can demonstrate shared notes locally, but private per-user notes require an owner-aware service and store even when kept in memory. This page also assumes a list operation, which you must add: the notes tutorial exposes only create and get. See Add persistence for durable owner-scoped queries.
  • Crates: axum 0.8, iet-core, iet-auth, iet-auth-login-htmx-axum (BrowserSecurity, BrowserState, BrowserIdentity, ProtectedForm, CsrfForm), iet-http-htmx-axum (HtmlPage, HtmlFragment, HtmxRequest), iet-ui (html!, text!, components::csrf_field), and serde.
  • htmx.js served from somewhere (the framework embeds a pinned copy). CSS is optional; the form works without it.

The minimal path

1. A transport DTO, next to the contract type

CreateNote is the contract input from the tutorial. NoteForm is the browser form: the same text plus the CSRF token. Keeping them separate avoids teaching the domain type to carry a transport field.

rust
// fragment: `CreateNote` and `Note` are the tutorial's domain types.
use iet_auth_login_htmx_axum::CsrfForm;
use iet_core::{AppResult, Validate};
use serde::Deserialize;

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NoteForm {
    pub csrf: String,
    pub text: String,
}

impl Validate for NoteForm {
    fn validate(&self) -> AppResult<()> {
        // Reuse the contract rule; do not invent a second, different rule.
        CreateNote { text: self.text.clone() }.validate()
    }
}

impl CsrfForm for NoteForm {
    fn csrf_token(&self) -> &str {
        &self.csrf
    }
}

deny_unknown_fields rejects extra form fields, so a client cannot smuggle an owner or subject into the DTO. The service still calls operation::check, so CreateNote::validate runs again at the domain boundary.

2. Give the state a browser-security capability

BrowserSecurity authenticates the session cookie, issues session-bound CSRF tokens, and runs the authenticate → body → CSRF → validate pipeline. Build it once from the same login config, then project it through BrowserState.

rust
// fragment: `auth: Arc<MyAuth>` where MyAuth: Authenticate<Secret<String>>;
// `csrf: SignedCsrf`.
use iet_auth::Secret;
use iet_auth_login_htmx_axum::{BrowserSecurity, BrowserState, SignedCsrf};
use std::sync::Arc;

#[derive(Clone)]
pub struct AppState<N> {
    notes: N,
    security: BrowserSecurity<Arc<MyAuth>, SignedCsrf>,
}

impl<N> AppState<N> {
    pub fn new(notes: N, auth: Arc<MyAuth>, csrf: SignedCsrf) -> iet_core::AppResult<Self> {
        Ok(Self { notes, security: BrowserSecurity::new(auth, csrf, "app_session")? })
    }
}

impl<N: Clone + Send + Sync + 'static> BrowserState for AppState<N> {
    type Auth = Arc<MyAuth>;
    type Csrf = SignedCsrf;
    fn browser_security(&self) -> &BrowserSecurity<Self::Auth, Self::Csrf> {
        &self.security
    }
}

If you already have a LoginConfig, use BrowserSecurity::from_login_config(auth, csrf, &config) instead of repeating the cookie name. The cookie name must match the login routes, or every protected request is 401.

3. The read handler: authenticate, issue a token, render

BrowserIdentity is a request-parts extractor that authenticates once and issues one session-bound CSRF token. Use it for GET pages and fragments.

rust
// fragment: `Notes` is the tutorial's port, `Note` its output, and `Notes`
// methods take `&CallContext` and return `AppResult<...>`.
use axum::{extract::State, http::HeaderMap, response::{IntoResponse, Response}};
use iet_auth_login_htmx_axum::BrowserIdentity;
use iet_core::CallContext;
use iet_http_htmx_axum::{HtmlError, HtmlFragment, HtmlPage, HtmxRequest};

async fn index<N>(
    State(state): State<AppState<N>>,
    headers: HeaderMap,
    identity: BrowserIdentity,
) -> Result<Response, HtmlError>
where
    N: Notes,
    AppState<N>: BrowserState,
{
    let context = CallContext {
        principal: Some(identity.principal.clone()),
        ..Default::default()
    };
    let notes = state.notes.list(&context).await.map_err(HtmlError::from)?;
    let fragment = dashboard(&identity.principal, &identity.csrf_token, &notes);

    let mut response = if HtmxRequest::new(&headers).is_htmx() {
        HtmlFragment(fragment.into_string()).into_response() // swap target
    } else {
        HtmlPage(layout(fragment).into_string()).into_response() // full document
    };
    HtmxRequest::new(&headers).vary(&mut response);
    response
        .headers_mut()
        .insert(axum::http::header::CACHE_CONTROL, "no-store".parse().unwrap());
    Ok(response)
}

HtmxRequest reads HX-Request and friends as presentation metadata only — never as identity or authorization. Always add Vary: HX-Request when the body differs by that header.

4. The mutation handler: ProtectedForm

ProtectedForm<T> is the standalone protected-mutation extractor. It authenticates from the cookie, reads a bounded body, verifies the session-bound CSRF token, then Validates the DTO, and hands you the principal plus the input.

rust
// fragment: `N: Notes`, `AppState<N>: BrowserState`.
use axum::extract::State;
use axum::http::HeaderMap;
use axum::response::Response;
use iet_auth_login_htmx_axum::ProtectedForm;
use iet_core::CallContext;
use iet_http_htmx_axum::HtmlError;

async fn create<N>(
    State(state): State<AppState<N>>,
    headers: HeaderMap,
    ProtectedForm { principal, input }: ProtectedForm<NoteForm>,
) -> Result<Response, Response>
where
    N: Notes,
    AppState<N>: BrowserState,
{
    let context = CallContext { principal: Some(principal), ..Default::default() };
    state
        .notes
        .create(&context, CreateNote { text: input.text })
        .await
        .map_err(|error| HtmlError(error).into_response())?;
    // Reload GET /notes: HX-Redirect for HTMX, 303 otherwise.
    iet_http_htmx_axum::redirect(&headers, "/notes")
        .map_err(|error| HtmlError(error).into_response())
}

Two rules the compile-checked shape enforces:

  • ProtectedForm must be the last extractor because it consumes the body. HeaderMap is a parts extractor and can come before it.
  • Do not pair it with BrowserIdentity on the same handler: authentication is already included.

The success path redirects to /notes instead of returning a Saved. fragment, because the fragment would replace the whole #notes panel and drop the form, list, and stable id. iet_http_htmx_axum::redirect selects the right shape from the headers: 204 + HX-Redirect for an HTMX request, a 303 + Location for a native submit. HX-Redirect performs a full navigation, so the follow-up GET /notes renders the complete updated page. This simple post/redirect/get flow deliberately does not attempt an in-place panel swap on successful mutation.

Authentication is not ownership. ProtectedForm proves the request carries a valid session and guarantees the principal is trustworthy. It does not scope storage. The tutorial's MemoryNotes is public and ignores CallContext; with that implementation the note belongs to no one. Only an owner-aware Notes implementation — for example the repository from Add TimescaleDB persistence, which filters every read and mutation by the principal's subject — makes “this note belongs to the signed-in user” true. Never read an owner or subject from the form.

5. Render the page and form

html! parses HTML at compile time and text! escapes interpolations. csrf_field emits the hidden input NoteForm expects.

rust
// fragment: `Markup`, `html` from iet-ui; `text!` is exported by iet-ui;
// `csrf_field` from iet_ui::components; `Principal` from iet-core.
use iet_core::Principal;
use iet_ui::components::csrf_field;
use iet_ui::{html, Markup};

fn layout(content: Markup) -> Markup {
    html! { <!DOCTYPE html><html lang="en"><head><meta charset="utf-8" />
        <title>Notes</title><script defer src="/htmx.js"></script></head>
        <body><main>{content}</main></body></html> }
}

fn dashboard(user: &Principal, csrf: &str, notes: &[Note]) -> Markup {
    html! { <div id="notes" hx-swap="outerHTML">
        <h1>Notes for {text!("{}", user.subject)}</h1>
        <form method="post" action="/notes"
              hx-post="/notes" hx-target="#notes" hx-swap="outerHTML">
            {csrf_field(csrf)}
            <label>Note <textarea name="text" required></textarea></label>
            <button>Add note</button>
        </form>
        <ul>{notes.iter().map(|note| html!{ <li>{text!("{}", note.text)}</li> })}</ul>
    </div> }
}

The native action/method makes the form work without HTMX; the hx-* attributes upgrade it. Register the routes on the same RestApi as the auth stack so they inherit that module's policy:

rust
// fragment: `N: Notes`, `AppState<N>: BrowserState`.
use axum::{routing::{get, post}, Router};

pub fn routes<N>() -> Router<AppState<N>>
where
    N: Notes,
    AppState<N>: BrowserState,
{
    Router::new()
        .route("/notes", get(index::<N>).post(create::<N>))
        .route("/notes/new", post(create::<N>))
}

Mount with api.module("notes", routes())? before finish(state).

The lifecycle, end to end

  1. GET /notes, no HX-Request → layout(dashboard(…)) as a full document, with a fresh session-bound CSRF token in the form.
  2. GET /notes, HX-Request: true → just the #notes fragment (useful for in-page search or paging), Vary: HX-Request, Cache-Control: no-store.
  3. POST /notes (application/x-www-form-urlencoded, csrf + text) → ProtectedForm authenticates, reads a 16 KiB-bounded body, verifies the token against the session, validates the form, then your service validates again.
  4. Success → iet_http_htmx_axum::redirect returns 204 + HX-Redirect for HTMX or a 303 + Location for a native submit. Either way the browser navigates to /notes and re-renders the updated page, so the form and list stay intact. Failure → a generic status (422 validation, 403 bad token, 401 no session, 503 backend), never the submitted value.
  5. Client-side, an htmx:responseError listener can show a retry message without assuming why the request failed.

Expected outcomes and tests

  • examples/htmx-starter/servers/web/tests/framework_http_composition.rs: a REST module and an HTMX router coexist on one listener; HtmlPage/HtmlFragment set text/html; charset=utf-8; hx-redirect returns 204 with HX-Redirect and no-store; bundled assets are immutable-cached; HtmxRequest preserves and extends an existing Vary.
  • crates/features/iet-http-htmx-axum/src/lib.rs unit tests: HTML types set the content type, invalid redirects never emit a Location header, and safe_local_path rejects //host, backslashes, control bytes, and ...
  • The login integration's logout test shows the same protected-form behavior at the auth boundary: a bad CSRF token is 403, a verifier failure is 503, and a failed logout never clears the cookie.
sh
cargo check -p <your-adapter-crate>

The companion throwaway check for this guide compiled the state and both handlers against the current crates.

Common mistakes

  • Putting ProtectedForm before another body extractor, or next to BrowserIdentity. Body-consuming extractors go last and do not stack.
  • Treating HX-Request/HX-Target as authorization. They are hints; policy lives in the service and repository.
  • Using ValidatedForm<T> for a protected mutation. It bounds and validates, but it does not verify CSRF. Use ProtectedForm (or BrowserSecurity::form) when a session cookie is involved.
  • Forgetting Vary: HX-Request. A cache may serve a fragment where a page was expected, or the reverse.
  • Returning a full document to an HTMX swap. Return the fragment the target expects.
  • Repurposing the contract input as the form DTO. Keep NoteForm separate so the domain type does not grow a csrf field.
  • Passing untrusted strings through Markup::from_rendered. Use text!; it escapes.

Optional: styling and assets

None of the above needs CSS. When you want the compiled stylesheet and local HTMX:

  • Add a build.rs that calls iet_ui_build::compile("assets/app.css", &["src", "../../../../crates/features/iet-ui/src"]). The input CSS must start with @import "tailwindcss" source(none);, and the standalone Tailwind v4.1.11 compiler must be on PATH or at FW_TAILWIND_BIN. Output is OUT_DIR/ui.css.
  • Serve it with include_str!(concat!(env!("OUT_DIR"), "/ui.css")) and a text/css; charset=utf-8 header. Serve HTMX from iet_http_htmx_axum::assets::HTMX_JS.
  • Or mount BundledUiAssets::bundled_router() and use BundledUiAssets::bundled_tags(); this serves the pinned HTMX / Alpine / Tailwind browser build with immutable caching. The Tailwind browser build is for development; compile your own CSS for production.

Advanced choices

  • Manual bounded parsing. If you must inspect a non-DTO field before domain validation, call iet_http_htmx_axum::decode_form(request, limit) and sequence CSRF verification yourself. The starter's task mutation does this; treat it as an escape hatch.
  • Template engines. Enable the askama feature for AskamaPage / AskamaFragment; escaping stays on and render failures become a generic 500.
  • Response helpers. HtmlResponse::page()/fragment() and redirect(&headers, path) choose page vs. fragment and native vs. HX-Redirect, validating the path with safe_local_path.

Limitations

  • html! renders to one string buffer; it is not a streaming or reactive client framework.
  • Standard Alpine is not a strict-CSP-compatible build, and serving assets locally does not by itself make the page CSP-safe.
  • ProtectedForm protects the mutation but implies no domain authorization; your service and repository still enforce ownership.

Where to go next

IET Framework documentation — built with the IET Framework.