# A login on the notes app — shipped 2026-09-12 > A design note from the engineering record. The fifth phase of the > application layer's plan: the row the layer had left, and the decision > it retires. **Where this comes from.** Decision D15 said no sessions and no CSRF, and named the reason: `wyhash64` is a hash, not a MAC, so nothing the framework could sign was worth signing. The primitive landed ahead of the login — `m0_core.hmac`, HMAC-SHA256 against the seven RFC 4231 vectors — because the hold mount needed it first (SPEC G15, I21). This is the piece that was waiting for it, and the last `planned` row in section N. **The shape.** One user, from the environment. `M0_NOTES_KEY` signs, `M0_NOTES_PASSWORD` is the secret, `M0_NOTES_USER` names the identity and defaults to `notes`, `M0_NOTES_TTL` bounds a session, `M0_NOTES_KEY_PREV` carries a rotation, and `M0_NOTES_SECURE=1` states a fact the server cannot observe. Without the first two the app prints which one is missing and exits 78, the code the hold mount refuses with. A demo that quietly served everyone because a deployment forgot a variable would be worse than one that did not start, so the refusal is a gated claim and not a comment. The session is a cookie and nothing else: ``` v1.... ``` `kid` is the first eight hex characters of the key's SHA-256, `exp` is Unix seconds, `subject` is the identity, and `tag` is base64url of HMAC-SHA256 over everything before it. It is the grant's shape (`grant.mojo`, I21) with the channel and the session binding replaced by a subject, which is not a coincidence: the two are the same primitive, they now share a key ring (`find_key`), and having written the second one is what makes that visible. What keeps the two apart under one key is that a subject may not hold a `.`: a session's signed part has exactly three dots and a grant's four, so neither tag verifies as the other, and the unit test presents each format to the other verifier. Verification refuses in a fixed order — malformed, unknown key, bad signature, expired — and the signature is checked BEFORE the expiry on purpose: an expired cookie nobody signed must not come back as merely expired, because "merely expired" is a thing an application might decide to forgive. Every field is walked as bytes; the cookie is request-derived and SPEC G14 is why. **CSRF costs no state.** The token is `HMAC(key, "m0csrf1." || tag)`: a MAC over the session's own tag, under the same key. It needs nothing remembered, it dies with the session, and a token minted for one cookie verifies under no other — the unit test asserts exactly that against two sessions. The prefix is domain separation: a CSRF token is a MAC over a message no session cookie's signed part can be, so neither value can ever be replayed as the other. **The guard is an early return**, which is all D3 leaves. A private view opens with `_session(req, store)` and returns `_refuse(req, session)` when it is not ok; a write adds `_csrf_refusal(form(req), session, path)`. There is no middleware, no decorator and no per-route flag saying which views are private, on purpose: a table that carried it would be a second place to forget, and the line that reads the state is where the question belongs. `_csrf_refusal` fails closed on a verdict that is not ok. A refused session carries an empty token and two empty strings compare equal, so without that line a write view that forgot its session guard would have accepted `csrf=` from anyone — nothing did forget, but the smoke sends that exact body to an unauthenticated DELETE and logout now, and the sabotage suite drops `delete`'s guard to see it caught. Every answer the session decided carries `Cache-Control: no-store`, the redirect and the 401 included: `Vary` names the fragment headers and not the cookie, and a shared cache in front would otherwise hand the anonymous redirect to a signed-in user or a rendered token to anyone. A navigation with no session gets 303 to the login page; a swap gets 401 carrying the login form as the same fragment the list occupies, because a redirect a swap followed would put the login page inside the element the list was in with no way back. Both answers are chosen by the fragment headers, so both carry `Vary` naming all four — which is what `vary_on_fragment_headers` is now exported for. htmx 2.0.4 does not swap a 401 by default, so a session that expires mid-interaction shows nothing until the page is reloaded; making it swap is `htmx.config.responseHandling`, a vocabulary-specific setting the app deliberately does not spell. ## What the round found **htmx 2.0.4 puts a DELETE's fields in the query string.** Its `methodsThatUseUrlParams` ships as `["get","delete"]`, so the obvious shape — a hidden `csrf` field in the form the delete button sits in — would have sent the token in the URL, where it lands in the access log, the `Referer` and the browser history. The app narrows the setting to `get` in one `` in the shell, and the server reads the token from the body and from nowhere else. The smoke asserts both halves: the meta is in the page, and a DELETE carrying a correct token in the query string is answered 403 with the note intact. That second assertion is what keeps the meta from being decorative. This is the one library setting the app makes, and it is in the shell rather than on an element because it is about the transport and not about a fragment — beside the `