Auth Hardening

The wiring checklist for exposing an app with logins or user data to the internet

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

Overview

This is the checklist you run before exposing an app with logins or user data to the internet. Chirp ships the secure-by-default building blocks — sessions, CSRF, rate limiting, security headers, password hashing — but you wire and configure them for production yourself. This page is that wiring.

Minimal hardened setup

Copy this stack, then read the per-item rationale below. The registration order matters:SessionMiddlewareruns first so a session exists, then AuthRateLimitMiddleware and CSRFMiddleware(which depends on the session), thenSecurityHeadersMiddleware.

import os

from chirp.middleware.auth_rate_limit import AuthRateLimitConfig, AuthRateLimitMiddleware
from chirp.middleware.csrf import CSRFMiddleware
from chirp.middleware.security_headers import (
    SecurityHeadersConfig,
    SecurityHeadersMiddleware,
)
from chirp.middleware.sessions import SessionConfig, SessionMiddleware

secret = os.environ["CHIRP_SECRET_KEY"]

app.add_middleware(
    SessionMiddleware(
        SessionConfig(
            secret_key=secret,
            secure=True,
            httponly=True,
            samesite="lax",
            idle_timeout_seconds=1800,
            absolute_timeout_seconds=86400,
        )
    )
)
app.add_middleware(
    AuthRateLimitMiddleware(AuthRateLimitConfig(paths=("/login", "/password-reset")))
)
app.add_middleware(CSRFMiddleware())
app.add_middleware(
    SecurityHeadersMiddleware(
        SecurityHeadersConfig(
            content_security_policy="default-src 'self'; frame-ancestors 'none'; object-src 'none'",
            strict_transport_security="max-age=63072000; includeSubDomains",
        )
    )
)

What each item does

Each row maps a hardening area to the field you set in the stack above.

Area What to set Symbol / value
Session cookies Sign cookies (HMAC-SHA-256 by default), mark them secure and HTTP-only, and bound their lifetime.secure defaults to "auto" (Secure in production/staging via AppConfig.env, off in local dev); the explicit secure=Truebelow is belt-and-suspenders. SessionConfig(secure=True, httponly=True, samesite="lax", signer_digest="sha256", idle_timeout_seconds=..., absolute_timeout_seconds=...)
Session invalidation Invalidate stale sessions after a password change or account event AuthConfig(session_version=...)
Bearer-token revocation Reject revoked bearer tokens (per-jti revoke + per-user iat <= revoked_at cutoff); the bearer-path analogue of session_version. Fails open on a store outage. AuthConfig(token_revocation_store=..., token_claims=...)
CSRF Validate unsafe requests; emit a token in every mutating form CSRFMiddleware() + {{ csrf_field() }}
Authorization Gate routes; add an ownership check; return 403 without leaking policy detail @login_required, @requires("role", policy=...)
Abuse protection Rate-limit auth endpoints; add lockout/backoff on repeated failures AuthRateLimitMiddleware(...), LoginLockout(...)
Browser headers Strict Content-Security-Policy; HSTS over HTTPS; keep the safe defaults SecurityHeadersConfig(content_security_policy=..., strict_transport_security=...)
Password hashing Use argon2 in production; verify logins with the enumeration-safe primitive and upgrade stale hashes on login pip install bengal-chirp[auth]; verify_login(...), verify_and_upgrade(...)
Audit Register a sink and alert on auth/CSRF/authz event spikes set_security_event_sink(...)

The authorization decorators and lockout helpers live inchirp.security, not chirp.middleware:

from chirp.security import LockoutConfig, LoginLockout, login_required, requires

Password hashing and login verification

chirp.securityships the credential primitives. They live one import away:

from chirp.security import (
    hash_password,
    verify_password,
    verify_login,
    verify_and_upgrade,
    needs_rehash,
)

Hashing algorithm. hash_password uses argon2id when chirp[auth] (argon2-cffi) is installed and falls back to stdlib scrypt otherwise. Both produce PHC-format strings, andverify_passwordauto-detects the algorithm from the prefix — so a hash survives a later default change. argon2id is the recommended production algorithm; thepassword_extracontract WARNs (in staging/production) when a login surface ships withoutargon2-cffi. Install it withpip install chirp[auth]. The argon2id cost factors are pinned in chirp.security.passwords to RFC 9106 §4's memory-constrained option (t=3, m=64 MiB, p=4) — auditable and stable across the dependency's defaults.

Timing-safe login (kill the enumeration oracle). The naive login check if user and verify_password(password, user.password_hash)leaks a user-enumeration timing oracle: when the username does not exist, no hash is computed, so the request returns measurably faster than a wrong password for a real account. An attacker times the difference to enumerate valid usernames. verify_login closes this — pass Nonefor an unknown user and it still runs a full verify against a process-wide decoy hash before returningFalse, so the unknown-user and wrong-password paths take comparable time:

user = USERS.get(username)
if verify_login(password, user.password_hash if user else None):
    login(user)
    return Redirect("/dashboard")
return Template("login.html", error="Invalid username or password")

Always call verify_login (do not short-circuit on user is None), or the decoy never runs. The decoy is computed once under a lock; subsequent logins read the published value. The timing equivalence is approximate, not byte-constant: in a mixed-algorithm corpus (legacy scrypt hashes while argon2 is the default) the decoy's cost differs from a stored scrypt verify. Re-deriving stale hashes (next) converges the corpus toward one algorithm over time.

Rehash-on-login (opportunistic upgrade).verify_and_upgradeverifies the password and, when correct and the stored hash is below the current cost parameters, returns a freshly computed replacement. It never re-derives a hash for a wrong guess, so a failed login can never trigger a database write:

ok, new_hash = verify_and_upgrade(password, user.password_hash)
if not ok:
    return reject()
if new_hash is not None:
    user.password_hash = new_hash  # persist the upgrade

needs_rehash(phc_hash, *, upgrade_algorithm=False)reports parameter staleness on its own (argon2check_needs_rehash; scrypt n/rbelow the current pinned cost). The algorithm-upgrade clause — flagging a scrypt hash stale merely because argon2 is now installed — is gated behindupgrade_algorithmand off by default, so installing theauthextra does not trigger a fleet-wide rehash-write storm. Opt in during a controlled migration window.

policy= is a keyword parameter of @requires(...), not a separate API. It takes a callback that receives the user and request and returns a bool for object-level ownership checks.

Audit events to alert on

Register a sink withset_security_event_sink(...), then alert on spikes in these event names:

Event Fires when
auth.token.invalid A bearer token fails verification
csrf.reject.missing / csrf.reject.invalid A mutating request has no CSRF token or a bad one
authz.permission.denied A@requires(role)check fails
authz.policy.denied A@requires(..., policy=...)ownership check fails

Account-recovery flows

Chirp does not ship password-reset or email-verification flows. If you are wondering where they are, expand the boundary statement below.