A Login That Is Correct by Default

The whole login → gated page → logout loop in one file, with secure-by-default sessions, CSRF, and password hashing — and app.check() catching the one wire you forgot

Page actions AI-ready formats and sharing
Open LLM text
Share with AI
Ask Claude Ask ChatGPT Ask Gemini Ask Copilot

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. 1

    Bring your own user, hash the password once

    Chirp does not own your user model. Any object withid and is_authenticated satisfies 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. 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(...) and AuthMiddlewareis 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. 3

    The login route — verify, then log in

    GET /login renders the form. POST /loginlooks up the user, verifies with verify_login, and on success calls login(user) and returns a FormAction. 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. 4

    The gated page and logout

    @login_required gates /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 loginverify_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, set RouteMeta.auth (or an AuthSpec) on a mounted filesystem page; the same auth_middleware / auth_specchecks enforce it. See the route directory.
  • Ergonomic accessorsrequest.user (never raises → AnonymousUser) and request.session (raises without SessionMiddleware, by design) are properties onRequest; current_user() and session()are the template globals.
  • Production posturechirp 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 inchirp.security.passkeys (opt-in viapip install chirp[passkeys] and AppConfig(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 and examples/standalone/passkeys_minimal/.