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_sessionin this guide;__Host-fw_sessionby default). - A
Notetype and aNotesport. 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 alistoperation, which you must add: the notes tutorial exposes only create and get. See Add persistence for durable owner-scoped queries. - Crates:
axum0.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), andserde. htmx.jsserved 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.
// fragment: `CreateNote` and `Note` are the tutorial's domain types.
use CsrfForm;
use ;
use Deserialize;
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.
// fragment: `auth: Arc<MyAuth>` where MyAuth: Authenticate<Secret<String>>;
// `csrf: SignedCsrf`.
use Secret;
use ;
use Arc;
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.
// fragment: `Notes` is the tutorial's port, `Note` its output, and `Notes`
// methods take `&CallContext` and return `AppResult<...>`.
use ;
use BrowserIdentity;
use CallContext;
use ;
async
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.
// fragment: `N: Notes`, `AppState<N>: BrowserState`.
use State;
use HeaderMap;
use Response;
use ProtectedForm;
use CallContext;
use HtmlError;
async
Two rules the compile-checked shape enforces:
ProtectedFormmust be the last extractor because it consumes the body.HeaderMapis a parts extractor and can come before it.- Do not pair it with
BrowserIdentityon 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.
// fragment: `Markup`, `html` from iet-ui; `text!` is exported by iet-ui;
// `csrf_field` from iet_ui::components; `Principal` from iet-core.
use Principal;
use csrf_field;
use ;
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:
// fragment: `N: Notes`, `AppState<N>: BrowserState`.
use ;
Mount with api.module("notes", routes())? before finish(state).
The lifecycle, end to end
GET /notes, noHX-Request→layout(dashboard(…))as a full document, with a fresh session-bound CSRF token in the form.GET /notes,HX-Request: true→ just the#notesfragment (useful for in-page search or paging),Vary: HX-Request,Cache-Control: no-store.POST /notes(application/x-www-form-urlencoded,csrf+text) →ProtectedFormauthenticates, reads a 16 KiB-bounded body, verifies the token against the session, validates the form, then your service validates again.- Success →
iet_http_htmx_axum::redirectreturns204+HX-Redirectfor HTMX or a303+Locationfor a native submit. Either way the browser navigates to/notesand re-renders the updated page, so the form and list stay intact. Failure → a generic status (422validation,403bad token,401no session,503backend), never the submitted value. - Client-side, an
htmx:responseErrorlistener 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/HtmlFragmentsettext/html; charset=utf-8;hx-redirectreturns204withHX-Redirectandno-store; bundled assets are immutable-cached;HtmxRequestpreserves and extends an existingVary.crates/features/iet-http-htmx-axum/src/lib.rsunit tests: HTML types set the content type, invalid redirects never emit aLocationheader, andsafe_local_pathrejects//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 is503, and a failed logout never clears the cookie.
The companion throwaway check for this guide compiled the state and both handlers against the current crates.
Common mistakes
- Putting
ProtectedFormbefore another body extractor, or next toBrowserIdentity. Body-consuming extractors go last and do not stack. - Treating
HX-Request/HX-Targetas 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. UseProtectedForm(orBrowserSecurity::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
NoteFormseparate so the domain type does not grow acsrffield. - Passing untrusted strings through
Markup::from_rendered. Usetext!; it escapes.
Optional: styling and assets
None of the above needs CSS. When you want the compiled stylesheet and local HTMX:
- Add a
build.rsthat callsiet_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 onPATHor atFW_TAILWIND_BIN. Output isOUT_DIR/ui.css. - Serve it with
include_str!(concat!(env!("OUT_DIR"), "/ui.css"))and atext/css; charset=utf-8header. Serve HTMX fromiet_http_htmx_axum::assets::HTMX_JS. - Or mount
BundledUiAssets::bundled_router()and useBundledUiAssets::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
askamafeature forAskamaPage/AskamaFragment; escaping stays on and render failures become a generic 500. - Response helpers.
HtmlResponse::page()/fragment()andredirect(&headers, path)choose page vs. fragment and native vs.HX-Redirect, validating the path withsafe_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.
ProtectedFormprotects the mutation but implies no domain authorization; your service and repository still enforce ownership.
Where to go next
- Create the session it authenticates: Compose authentication.
- Persist notes and scope them to their owner: Add TimescaleDB persistence.