Overview
In JS-land, "add login" means you assemble it yourself: a session store, CSRF
tokens wired through every form, cookie flags you have to remember (Secure,
HttpOnly, SameSite), a password hash you hope is the right algorithm, and
redirect logic for the unauthenticated case. Miss one and nothing tells you —
the cookie ships withoutSecureover HTTPS, the enumeration timing leak is
invisible, the gate 500s only when a real user hits it. Chirp's promise is the
opposite: the secure stack is in the box, the defaults are already correct
(Securecookies in production, session regeneration on login, an
enumeration-safe verify), and [[docs/quality/contracts-debugging/categories|app.check()]]
catches the wiring mistakes at startup — before a user finds them for you.
This walkthrough is the whole loop — login → gated page → logout — in one
copy-pasteable file. Every step has a one-line why this is safe so you can see
what the framework is doing for you. Then we prove it: the realapp.check()
output for the correct app, and the real error you get when you forget the one
wire that matters.
Prerequisites: Python 3.14+,pip install bengal-chirp, and
pip install "bengal-chirp[auth]"for argon2id password hashing (scrypt is the
stdlib fallback). Set a secret key:export CHIRP_SECRET_KEY=$(python -c "import secrets; print(secrets.token_hex(32))").
The golden path
- 1
Bring your own user, hash the password once
Chirp does not own your user model. Any object with
idandis_authenticatedsatisfies the user protocol — a dataclass, an ORM row, whatever you already have. The one rule: never store plaintext. Hash at write time withhash_password, and store the PHC string.import os from dataclasses import dataclass from pathlib import Path from chirp import ( App, AppConfig, AuthConfig, FormAction, Request, Template, ValidationError, current_user, login, login_required, logout, secure_stack, ) from chirp.security import hash_password, verify_login TEMPLATES_DIR = Path(__file__).parent / "templates" @dataclass(frozen=True, slots=True) class User: """Any object with `id` + `is_authenticated` satisfies chirp's User protocol.""" id: str name: str password_hash: str is_authenticated: bool = True # Password is hashed once at startup — never store plaintext. USERS: dict[str, User] = { "ada": User(id="ada", name="Ada Lovelace", password_hash=hash_password("correct horse")), } async def load_user(user_id: str) -> User | None: """AuthMiddleware calls this each request to rehydrate the session user.""" return USERS.get(user_id) - 2
Wire the secure-by-default stack
secure_stack(app.config)returns the canonical[SessionMiddleware, CSRFMiddleware, SecurityHeadersMiddleware]list — already in contract-passing order. Passauth=AuthConfig(...)andAuthMiddlewareis placed for you — right after sessions (it reads the session) and before CSRF — so the whole stack is one loop.config = AppConfig( template_dir=TEMPLATES_DIR, secret_key=os.environ.get("CHIRP_SECRET_KEY", "dev-only-not-for-production"), ) app = App(config=config) # secure_stack wires the whole stack in the correct order: # SessionMiddleware -> AuthMiddleware -> CSRFMiddleware -> SecurityHeadersMiddleware. for mw in secure_stack(app.config, auth=AuthConfig(load_user=load_user)): app.add_middleware(mw) - 3
The login route — verify, then log in
GET /loginrenders the form.POST /loginlooks up the user, verifies withverify_login, and on success callslogin(user)and returns aFormAction. A bad guess re-renders the form at 422 with aValidationError.@app.route("/login") def login_form(): """Render the login form (csrf_field() injects the hidden token).""" return Template("login.html") @app.route("/login", methods=["POST"]) async def do_login(request: Request): form = await request.form() username = form.get("username", "") password = form.get("password", "") user = USERS.get(username) # verify_login runs a decoy hash for an unknown user so the # "no such user" and "wrong password" paths take comparable time. if not verify_login(password, user.password_hash if user else None): return ValidationError( "login.html", "form", error="Invalid username or password.", username=username, ) login(user) # regenerates the session (fixation defence) + sets the user id # htmx gets HX-Redirect; a plain POST gets a 303 to /dashboard. return FormAction("/dashboard") - 4
The gated page and logout
@login_requiredgates/dashboard. An anonymous browser is content-negotiated to a 302 →/login?next=/dashboard.logout()regenerates the session, clearing all auth state.@app.route("/dashboard") @login_required def dashboard(): """Gated: anonymous browsers are 302'd to /login?next=/dashboard.""" return Template("dashboard.html", user=current_user()) @app.route("/logout", methods=["POST"]) def do_logout(): logout() # regenerates the session, clearing all auth state return FormAction("/login") if __name__ == "__main__": app.run()
The templates
Three small Kida templates. The only security-critical line is{{ csrf_field() }}
inside each<form> — it emits the hidden _csrf_tokeninput bound to the
session, whichCSRFMiddlewarevalidates on every POST.
{# templates/base.html #}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{% block title %}Auth{% endblock %}</title>
</head>
<body>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>
{# templates/login.html #}
{% extends "base.html" %}
{% block title %}Sign in{% endblock %}
{% block content %}
<h1>Sign in</h1>
{% block form %}
<form id="form" method="post" action="/login">
{{ csrf_field() }}
{% if error is defined %}<p role="alert">{{ error }}</p>{% endif %}
<label for="username">Username</label>
<input id="username" name="username" value="{{ username | default('') }}" required>
<label for="password">Password</label>
<input id="password" name="password" type="password" required>
<button type="submit">Sign in</button>
</form>
{% endblock %}
{% endblock %}
{# templates/dashboard.html #}
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<h1>Welcome, {{ user.name }}</h1>
<p>You are signed in as <code>{{ user.id }}</code>.</p>
<form method="post" action="/logout">
{{ csrf_field() }}
<button type="submit">Sign out</button>
</form>
{% endblock %}
Run it:
CHIRP_SECRET_KEY=... python app.py
Prove it
Two ways to know this works: the loop behaves (exercised below), and — the part
that makes it lovable —app.check()verifies the whole posture at startup.
The loop, end to end
Driven with [[docs/quality/testing/_index|TestClient]] (carrying the session
cookie and scraping the CSRF token from each form), the observed flow:
anon GET /dashboard → 302 Location='/login?next=%2Fdashboard'
GET /login → 200 csrf_field present; session cookie issued
POST /login (wrong) → 422 shows the error; user stays anonymous
then GET /dashboard → 302 still gated
POST /login (correct) → 303 HX-Redirect='/dashboard'; FRESH session cookie set
authed GET /dashboard → 200 renders the user name
POST /logout → 303 HX-Redirect='/login'
then GET /dashboard → 302 session cleared, gated again
The fresh session cookie after correct login is the session-fixation defence
made visible:login()discarded the pre-auth session. Wrong creds 422 and the
user stays anonymous. Logout clears the session, and/dashboard302s again.
app.check()on the correct app
A satisfied security check emits nothing — nosecurity_stack,
csrf_session, cookie_secure, or auth_middlewarefinding. That silence is
the pass signal. Here is the real output:
── chirp check ──────────────────────────────────────────────────
4 routes · 239 templates · 2 targets · 99.4ms elapsed
Routing
· Route '/dashboard' is not referenced from any template.
route /dashboard
HTMX
▲ Mutating htmx request has no explicit hx-target and may inherit a broad container target. This can replace large UI regions with partial responses. Consider Action() (204), hx-swap="none", or an explicit local hx-target.
in login.html
Inherited broad target(s): #main (chirp/layouts/boost.html), #main (chirpui/app_layout.html)
▲ Mutating htmx request has no explicit hx-target and may inherit a broad container target. This can replace large UI regions with partial responses. Consider Action() (204), hx-swap="none", or an explicit local hx-target.
in dashboard.html
Inherited broad target(s): #main (chirp/layouts/boost.html), #main (chirpui/app_layout.html)
Forms
· <form action="/login" method="post"> targets route '/login' which accepts POST but has no FormContract. Consider adding @contract(form=FormContract(...)) for validation and type safety.
in login.html
route /login
· <form action="/logout" method="post"> targets route '/logout' which accepts POST but has no FormContract. Consider adding @contract(form=FormContract(...)) for validation and type safety.
in dashboard.html
route /logout
✓ No errors · 2 warnings
─────────────────────────────────────────────────────────────────
Exit code 0. None of these findings are security-related: an INFO orphan-route
note, two WARNING htmx broad-target advisories that originate from Chirp's
built-in layout templates picked up in the template scan, and two INFO
form-contract upsells. A production app can silence them with explicit
hx-target and @contract(form=...); the auth posture is already clean.
Forget the one wire that matters
Now removeAuthMiddleware from the stack and keep the gated @login_required
/dashboard. Run the production-posture preflight (app.check(deploy=True), or
chirp check --deploy). Chirp catches it at startup:
✗ Route '/dashboard' declares auth (RouteMeta.auth or @login_required/@requires) but AuthMiddleware is not registered while env='production'. The auth gate calls get_user(), which raises LookupError -> a 500 at request time without AuthMiddleware. Register AuthMiddleware after SessionMiddleware in the stack.
route /dashboard
This is the safety net. Without it, the missing middleware surfaces as a 500 the
first time a user hits the gate. With it, you find out at startup — the framework
read the static@login_required marker, saw no AuthMiddleware, and told you
exactly what to add and where. The framework is the expert; you do not have to
remember the wire.
Going further
The golden path is correct by default. Here is where each piece extends:
- Rehash on login —
verify_and_upgrade(password, hash)verifies and returns a freshly computed hash when the stored one is below current cost params, so passwords transparently upgrade as users sign in (never on a wrong guess). See Auth Hardening. - Declarative gating — instead of the
@login_requireddecorator, setRouteMeta.auth(or anAuthSpec) on a mounted filesystem page; the sameauth_middleware/auth_specchecks enforce it. See the route directory. - Ergonomic accessors —
request.user(never raises →AnonymousUser) andrequest.session(raises withoutSessionMiddleware, by design) are properties onRequest;current_user()andsession()are the template globals. - Production posture —
chirp check --deployre-runs the env-aware rules withenv="production"against a throwaway config view, so you find every production-only failure before you ship. See Production Deployment. - Passkeys — WebAuthn / passkey support ships in
chirp.security.passkeys(opt-in viapip install chirp[passkeys]andAppConfig(passkeys=True)). The framework owns the ceremony verbs and session-bound challenge lifecycle; your app owns the credential row — same BYO doctrine as theUserprotocol. See Passkeys walkthrough andexamples/standalone/passkeys_minimal/.