Every form on a public site is an invitation. This one accepts messages, so I built it the way I would build a client project: everything that matters happens on the server.
One process, no public API
The site, the admin dashboard, and the data all live in a single Rust binary. Pages are rendered as HTML on the server, so there is no client-side data fetching to secure and no API surface exposed to the internet.
browser ──(same-origin, httpOnly cookie)──► Rust server ──► SQLite
There is no CORS configuration to get wrong because there is nothing cross-origin to talk to.
Sessions JavaScript cannot read
Sign-in produces a random token stored in an httpOnly, SameSite=Strict cookie. The client bundle never sees it. Mutations also need a CSRF value echoed from a second cookie, checked in constant time against the session on the server.
// Double submit, compared without an early exit.
let ok = bool::from(session.csrf.as_bytes().ct_eq(supplied.as_bytes()));
Passwords are hashed with argon2 on first boot and the plaintext is never stored. Login attempts are rate limited more tightly than the contact form, because guessing a password is a more expensive failure than one extra message.
Rendering untrusted input
Post bodies are markdown written by an authenticated owner, but they still go through pulldown-cmark and then ammonia. Nothing to gain from trusting your own database, everything to lose if an import path ever changes.
- Raw HTML in markdown is stripped.
- Script URLs never survive sanitisation.
- Every rendered page carries a restrictive content security policy.
What this buys
- No keys in the browser.
- No public API to probe.
- Injected content cannot execute scripts.
- One deployable file: copy it next to a SQLite database and run it.
The pattern is not novel; it is just rarely finished. Most tutorials stop at “and then we fetch the data”. The work is in the parts that fail quietly — constant-time compares, sanitised output, and cookies the client cannot touch.