CORSMiddleware
Cross-Origin Resource Sharing for API and htmx requests:
from chirp.middleware import CORSMiddleware, CORSConfig
cors = CORSMiddleware(CORSConfig(
allow_origins=["https://example.com"],
allow_methods=["GET", "POST", "DELETE"],
allow_headers=["HX-Request", "HX-Target"],
allow_credentials=True,
max_age=3600,
))
app.add_middleware(cors)
Handles preflight OPTIONSrequests automatically. Supports multiple origins, exposed headers, and credentials.
StaticFiles
Serve static assets (CSS, JS, images) from a directory:
from chirp.middleware import StaticFiles
app.add_middleware(StaticFiles(
directory="static",
prefix="/static",
))
Features:
- Path traversal protection -- uses
is_relative_to()to prevent directory escape - Index resolution -- serves
index.htmlfor directory paths - Trailing-slash redirects -- redirects
/dirto/dir/when a directory exists - Custom 404 -- optionally serve a custom 404 page
- Root prefix -- use
prefix="/"for static site hosting
# Serve a full static site from the "public" directory
app.add_middleware(StaticFiles(
directory="public",
prefix="/",
fallback="404.html",
))
SessionMiddleware
Signed cookie sessions using itsdangerous:
from chirp.middleware.sessions import SessionConfig, SessionMiddleware
app.add_middleware(SessionMiddleware(SessionConfig(
secret_key="change-me-in-production",
)))
Note
Requires theitsdangerous package. A ConfigurationError is raised at startup if it's not installed or if secret_keyis empty.
Session data is JSON-serialized into a signed cookie with sliding expiration. Access the session dict viaget_session():
from chirp.middleware.sessions import get_session
@app.route("/count")
def count():
session = get_session()
session["visits"] = session.get("visits", 0) + 1
return f"Visits: {session['visits']}"
get_session() returns a plain dict[str, Any] backed by a ContextVar-- safe under free-threading.
Configuration options:
| Option | Default | Description |
|---|---|---|
secret_key |
(required) | Signing key for the cookie |
cookie_name |
"chirp_session" |
Cookie name |
max_age |
86400(24h) |
Sliding expiration in seconds |
httponly |
True |
Prevent JavaScript access |
samesite |
"lax" |
SameSite policy |
secure |
False |
HTTPS-only (setTruein production) |
idle_timeout_seconds |
None |
Optional idle timeout before session expires |
absolute_timeout_seconds |
None |
Optional absolute max lifetime for a session |
AuthMiddleware
Dual-mode authentication: session cookies (browsers) and bearer tokens (API clients). The authenticated user is stored in aContextVar, accessible via get_user()from any handler.
Setup
AuthMiddleware requires SessionMiddlewarefor session-based auth. Register sessions first:
from chirp.middleware.sessions import SessionConfig, SessionMiddleware
from chirp.middleware.auth import AuthConfig, AuthMiddleware
app.add_middleware(SessionMiddleware(SessionConfig(secret_key="...")))
app.add_middleware(AuthMiddleware(AuthConfig(
load_user=my_load_user, # async (id: str) -> User | None
verify_token=my_verify_token, # async (token: str) -> User | None
)))
You must provide at least one of load_user (session auth) or verify_token (token auth). A ConfigurationErroris raised if neither is set.
User Protocol
Your user model just needsid and is_authenticated:
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class User:
id: str
name: str
is_authenticated: bool = True
For @requires(), add a permissionsattribute:
@dataclass(frozen=True, slots=True)
class User:
id: str
name: str
is_authenticated: bool = True
permissions: frozenset[str] = frozenset()
Login and Logout
Use thelogin() and logout()helpers in your handlers:
from chirp import login, logout, Redirect
@app.route("/login", methods=["POST"])
async def do_login(request: Request):
form = await request.form()
user = await verify_credentials(form["username"], form["password"])
if user:
login(user)
return Redirect("/dashboard")
return Template("login.html", error="Invalid credentials")
@app.route("/logout", methods=["POST"])
def do_logout():
logout()
return Redirect("/")
Note
Bothlogin() and logout()regenerate the session automatically to prevent session fixation attacks. All previous session data is discarded -- the new session starts empty (with only the user ID set on login).
Route Protection
Use@login_required and @requires()to protect routes. Both work with sync and async handlers:
from chirp import login_required, requires, get_user
@app.route("/dashboard")
@login_required
def dashboard():
user = get_user()
return Template("dashboard.html", user=user)
@app.route("/admin")
@requires("admin")
def admin_panel():
return Template("admin.html")
For object-level authorization, pass a policy=callback:
def owner_policy(user, request):
return request.query.get("owner_id") == user.id
@app.route("/docs/{doc_id}")
@requires("editor", policy=owner_policy)
def edit_doc(doc_id: str):
...
Content-negotiated responses: browser requests redirect to login_url, API requests get 401/403 JSON errors.
Safe Redirects
When@login_required redirects to login_url, it appends a URL-encoded ?next= parameter. To safely honour this after login, use is_safe_url():
from chirp import is_safe_url, Redirect
@app.route("/login", methods=["POST"])
async def do_login(request: Request):
# ... verify credentials ...
login(user)
next_url = request.query.get("next", "/dashboard")
if not is_safe_url(next_url):
next_url = "/dashboard"
return Redirect(next_url)
is_safe_url(url) returns True only for relative paths on the same origin (starts with /, not //, no scheme). This prevents open redirect attacks where an attacker crafts a login link like /login?next=//evil.com.
Templates
AuthMiddleware auto-registers current_user()as a template global:
{% if current_user().is_authenticated %}
<a href="/profile">{{ current_user().name }}</a>
{% else %}
<a href="/login">Sign in</a>
{% endif %}
Password Hashing
Hash and verify passwords with argon2id (preferred) or scrypt (stdlib fallback):
from chirp.security.passwords import hash_password, verify_password
hashed = hash_password("user-password") # Store this
ok = verify_password("user-password", hashed) # Check on login
Note
For argon2id, install theauth extra: pip install bengal-chirp[auth]. Without it, scrypt (always available) is used as a fallback.
Configuration
| Option | Default | Description |
|---|---|---|
load_user |
None |
async (id: str) -> User | Nonefor session auth |
verify_token |
None |
async (token: str) -> User | Nonefor token auth |
login_url |
"/login" |
Redirect URL for unauthenticated browsers |
session_key |
"user_id" |
Session dict key for the user ID |
token_header |
"Authorization" |
Header for bearer tokens |
token_scheme |
"Bearer" |
Expected scheme prefix |
session_version |
None |
Optional callbackuser -> versionfor session invalidation |
session_version_key |
"_session_version" |
Session key used bysession_version |
exclude_paths |
frozenset() |
Paths that skip auth entirely |
Tip
See the kanban_shellexample for a complete working app with login, session-based auth, and protected routes.
CSRFMiddleware
CSRF protection for form submissions:
from chirp.middleware import CSRFMiddleware
app.add_middleware(CSRFMiddleware())
Validates a CSRF token on POST, PUT, PATCH, and DELETErequests. The token is available in templates:
<form method="post" action="/submit">
{{ csrf_field() }}
<!-- form fields -->
<button type="submit">Submit</button>
</form>
Note
RequiresSessionMiddleware to be active before CSRFMiddleware.
AuthRateLimitMiddleware
Auth-focused in-memory limiter for login/signup/reset endpoints:
from chirp.middleware import AuthRateLimitConfig, AuthRateLimitMiddleware
app.add_middleware(AuthRateLimitMiddleware(AuthRateLimitConfig(
requests=10,
window_seconds=60,
block_seconds=300,
paths=("/login", "/password-reset"),
)))
Returns 429 Too Many Requests with Retry-Afterwhen the threshold is exceeded.
SecurityHeadersMiddleware
Add security headers to HTML responses per HTML Living Standard recommendations:
from chirp.middleware import SecurityHeadersMiddleware
app.add_middleware(SecurityHeadersMiddleware())
Headers applied (only to text/htmlresponses):
- X-Frame-Options — prevents clickjacking (default:
DENY) - X-Content-Type-Options — prevents MIME sniffing (default:
nosniff) - Referrer-Policy — controls referrer leakage (default:
strict-origin-when-cross-origin) - Content-Security-Policy — script/style/resource policy (default: strict self-hosted baseline)
- Strict-Transport-Security — optional HSTS when configured
Skipped for JSON, SSE, static files, and other non-HTML content types.
Custom config::
from chirp.middleware.security_headers import (
SecurityHeadersConfig,
SecurityHeadersMiddleware,
)
app.add_middleware(SecurityHeadersMiddleware(SecurityHeadersConfig(
x_frame_options="SAMEORIGIN",
)))
AllowedHostsMiddleware
Validate theHostheader against a whitelist, rejecting requests with spoofed or unrecognized hosts:
from chirp.middleware.allowed_hosts import AllowedHostsMiddleware
app.add_middleware(AllowedHostsMiddleware(
("example.com", ".example.com"),
))
Wildcard patterns:
"*"— allow all hosts (default, suitable for development)".example.com"— matchesexample.comand any subdomain (e.g.api.example.com)
Returns400 Bad Request for unrecognized hosts. Pass debug=Trueto include the rejected host and allowed list in the error response (development only).
Warning
Always set explicit allowed hosts in production. The"*"default is for local development only.
CSPNonceMiddleware
Generate a per-request cryptographic nonce forContent-Security-Policy, allowing inline scripts without 'unsafe-inline':
from chirp.middleware.csp_nonce import CSPNonceMiddleware
app.add_middleware(CSPNonceMiddleware())
The nonce is available in templates via csp_nonce():
<script nonce="{{ csp_nonce() }}">
// Inline script allowed by CSP
</script>
You can also access the nonce in handlers:
from chirp.middleware.csp_nonce import get_csp_nonce
@app.route("/page")
def page():
nonce = get_csp_nonce()
return Template("page.html", nonce=nonce)
To customize the base CSP directive:
app.add_middleware(CSPNonceMiddleware(
base_csp="default-src 'self'; img-src 'self' https://cdn.example.com"
))
The middleware appends script-src 'self' 'nonce-<value>'to whatever base CSP you provide.
HTMLInject
Inject a snippet into every HTML response before</body>:
from chirp.middleware import HTMLInject
# Inject live-reload script in development
app.add_middleware(HTMLInject(
'<script src="/_dev/reload.js"></script>'
))
Useful for development tools, analytics scripts, or debug toolbars.
Next Steps
- Custom Middleware -- Write your own
- Overview -- How the middleware pipeline works
- Configuration -- AppConfig and secret_key