Built-in Middleware

The middleware Chirp ships — CORS, static files, sessions, auth, CSRF, security headers, rate limiting, and host validation — plus the secure-by-default stack and the order to wire it.

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

Overview

Middleware wraps every request before it reaches your handler and every response on the way out — for cross-origin rules, static files, sessions, authentication, CSRF, security headers, and host validation. You add each one with app.add_middleware(...), and order matters: sessions must come before CSRF, and before auth.

Most apps need only a few. The one rule worth memorizing: every app with a mutating route (aPOST/PUT/PATCH/DELETEhandler, or a page that ships _actions.py) should wire the secure-by-default stack — SessionMiddlewareCSRFMiddlewareSecurityHeadersMiddleware. Chirp's app.check()flags a mutating route that's missing it.

This page is the catalog. Skim the index, then jump to the row you need.

The secure-by-default stack

chirp newscaffolds this wiring for you. To add it by hand, register the three in this order:

  1. 1

    SessionMiddleware

    Signed cookie sessions. CSRF and auth both depend on it, so it goes first.

    app.add_middleware(SessionMiddleware(SessionConfig(secret_key=SECRET_KEY)))
    
  2. 2

    CSRFMiddleware

    Validates a token on every mutating request. Requires the session to already be active.

    app.add_middleware(CSRFMiddleware())
    
  3. 3

    SecurityHeadersMiddleware

    Adds clickjacking, MIME-sniffing, referrer, and CSP headers to HTML responses.

    app.add_middleware(SecurityHeadersMiddleware())
    

AddAuthMiddleware after SessionMiddleware(and before your protected routes) if the app has logins. ReadSECRET_KEYfrom the environment — see secret_key and AppConfig.

Middleware at a glance

Middleware What it does Requires Import
CORS Cross-origin rules for API / htmx requests chirp.middleware
StaticFiles Serve CSS / JS / images from a directory chirp.middleware
Session Signed cookie sessions itsdangerous chirp.middleware.sessions
Auth Session + bearer-token authentication SessionMiddleware chirp.middleware.auth
CSRF Token validation on mutating requests SessionMiddleware chirp.middleware
SecurityHeaders Security headers on HTML responses chirp.middleware
AuthRateLimit Rate-limit login / signup / reset endpoints chirp.middleware
Audit Opt-in per-request who/what/when/status trail (off by default) chirp.middleware
AllowedHosts Reject spoofedHostheaders chirp.middleware.allowed_hosts
CSPNonce Per-request nonce for inline scripts chirp.middleware.csp_nonce
HTMLInject Inject a snippet before</body> chirp.middleware

Alpine.js and htmx scripts are injected by flags, not middleware you wire — see Script injection at the end.

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. Useprefix="/static"for a sub-path, orprefix="/"to host a full static site at the root:

from chirp.middleware import StaticFiles

# Serve /static/* from the "static" directory
app.add_middleware(StaticFiles(
    directory="static",
    prefix="/static",
))
from chirp.middleware import StaticFiles

# Serve a whole site from the root with a custom 404
app.add_middleware(StaticFiles(
    directory="public",
    prefix="/",
    not_found_page="404.html",
))

Both modes share the same hardening:

  • Path-traversal protection — usesis_relative_to()to prevent directory escape
  • Index resolution — servesindex.htmlfor directory paths
  • Trailing-slash redirects — redirects/dir to /dir/when a directory exists
  • Custom 404 — passnot_found_pageto serve a custom not-found page

SessionMiddleware

Signed cookie sessions usingitsdangerous:

from chirp.middleware.sessions import SessionConfig, SessionMiddleware

app.add_middleware(SessionMiddleware(SessionConfig(
    secret_key="change-me-in-production",
    secure=True,  # HTTPS-only cookie; set in production
)))

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.

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). AConfigurationErroris raised if neither is set.

Your user model just needsid and is_authenticated (add a permissions attribute for@requires()):

from dataclasses import dataclass

@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, Redirect, Template

@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")
from chirp import logout, Redirect

@app.route("/logout", methods=["POST"])
def do_logout():
    logout()
    return Redirect("/")

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):
    ...

Responses are content-negotiated: 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 it after login, gate it throughis_safe_url():

from chirp import is_safe_url, Redirect

@app.route("/login", methods=["POST"])
async def do_login(request: Request):
    # ... verify credentials, then 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 Trueonly for relative paths on the same origin (starts with/, not //, no scheme). This blocks open-redirect attacks where an attacker crafts a 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 %}

CSRFMiddleware

CSRF protection for form submissions. Validates a token onPOST, PUT, PATCH, andDELETErequests:

from chirp.middleware import CSRFMiddleware

app.add_middleware(CSRFMiddleware())

Emit the token with csrf_field()in any form:

<form method="post" action="/submit">
  {{ csrf_field() }}
  <!-- form fields -->
  <button type="submit">Submit</button>
</form>

Register SessionMiddleware before CSRFMiddleware— see the stack above.

SecurityHeadersMiddleware

Add security headers to HTML responses per HTML Living Standard recommendations:

from chirp.middleware import SecurityHeadersMiddleware

app.add_middleware(SecurityHeadersMiddleware())

Headers are applied only to text/htmlresponses (skipped for JSON, SSE, and other non-HTML content types):

  • X-Frame-Options — prevents clickjacking (defaultDENY)
  • X-Content-Type-Options — prevents MIME sniffing (defaultnosniff)
  • Referrer-Policy — controls referrer leakage (defaultstrict-origin-when-cross-origin)
  • Content-Security-Policy — script/style/resource policy (default: strict self-hosted baseline)
  • Strict-Transport-Security — optional HSTS when configured
from chirp.middleware.security_headers import (
    SecurityHeadersConfig,
    SecurityHeadersMiddleware,
)

app.add_middleware(SecurityHeadersMiddleware(SecurityHeadersConfig(
    x_frame_options="SAMEORIGIN",
)))

AuthRateLimitMiddleware

A keyed in-memory limiter. The defaults target the common auth endpoints (login/signup/reset) keyed by trusted client IP, but withkey_fnplus open path targeting it limits any route or group — per-user, per-resource, or per-tenant.

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. By default it keys requests byrequest.trusted_client_ip— the trusted-proxy-corrected client IP, never a raw client-supplied X-Forwarded-For(which is spoofable). To key on a trusted, server-set identity header instead (e.g. an authenticated API-key header), set key_header; it is consumed verbatim, never comma-split.

Targeting arbitrary routes with a custom key

Setpaths=() to limit every matching-method route, and supply key_fn to compute the bucket key per request. Return a non-emptystrto key on it, orNoneto skip rate-limiting that request (an explicit per-request opt-out):

app.add_middleware(AuthRateLimitMiddleware(AuthRateLimitConfig(
    requests=30,
    window_seconds=60,
    paths=(),  # every POST route
    # Key per authenticated user. Skip anonymous requests here (None) so the
    # default trusted-IP limiter on the auth endpoints handles them instead.
    key_fn=lambda req: f"user:{req.user.id}" if req.user.is_authenticated else None,
)))

HTML 429 for htmx form-action POSTs

By default the over-limit body is plain text. Seterror_template(and optionallyerror_block) to render an HTML 429for htmx requests — the middleware renders the block (or whole template, withretry_afterin context) to the response itself:

app.add_middleware(AuthRateLimitMiddleware(AuthRateLimitConfig(
    paths=(),
    error_template="rate_limit.html",
    error_block="too_many",   # block in rate_limit.html; rendered for htmx POSTs
)))

Non-htmx and unconfigured over-limit responses keep the plain Too Many Requestsbody. A configured block that does not exist is fail-loud (BlockNotFoundError) — point at a block that exists.

Pluggable backends

The in-memory backend is per-worker. For a shared limit across workers, pass a backend implementing theRateLimitBackendprotocol (both importable from chirp.middleware). A Redis sliding-window backend ships behind the redis extra:

from chirp.middleware import AuthRateLimitConfig, redis_rate_limit_backend

config = AuthRateLimitConfig(
    backend=redis_rate_limit_backend("redis://localhost:6379/0"),
)

AuditMiddleware

Emit a per-request who/what/when/status audit trail through the same security-event sink as login/CSRF events, under anhttp.requestnamespace — so audit and auth telemetry stay one pipeline:

from chirp.middleware.audit import AuditConfig, AuditMiddleware

app.add_middleware(AuditMiddleware(AuditConfig(level="metadata")))

It is opt-in and off by default (level="none"). Verbosity is tiered:

  • "none"— disabled (default).
  • "metadata" — method, path, status_code, source_ip, user_agent, user_idonly.
  • "request"— metadata plus a byte-capped, redacted request-body snapshot.
  • "request_response" — reserved; behaves like "request"for body capture.

Onlyaudited_methodsare trailed — by default the canonical MUTATING_METHODS (POST/PUT/PATCH/DELETE). Events are delivered to the sink set byAppConfig(audit_sink=...) ("log"structured-logs them) or set_security_event_sink(...). The new fields ride in event.details (status_code, source_ip, user_agent, and at level="request"+ a body).

from chirp.middleware.audit import AuditConfig, AuditMiddleware

app.add_middleware(AuditMiddleware(AuditConfig(
    level="request",
    max_body_bytes=2048,
    redact_keys=("password", "token", "secret", "csrf_token", "ssn"),
    redact_patterns=(r"\d{16}",),  # mask anything that looks like a card number
)))

Wire it as the outermost leg of the secure-by-default stack:

from chirp.middleware.audit import AuditConfig
from chirp.middleware.stack import secure_stack

for mw in secure_stack(app.config, audit=AuditConfig(level="metadata")):
    app.add_middleware(mw)

AuditMiddleware is appended after SecurityHeadersMiddlewareso it wraps the whole chain and observes the final status code (including a CSRF rejection's 403).

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"),
))
  • "*"— allow all hosts (default; development only)
  • ".example.com" — matches example.com and 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).

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())

Emit the nonce in templates via csp_nonce():

<script nonce="{{ csp_nonce() }}">
    // Inline script allowed by CSP
</script>

HTMLInject

Inject a snippet into every HTML response before</body>:

from chirp.middleware import HTMLInject

# Inject a live-reload script in development
app.add_middleware(HTMLInject(
    '<script src="/_dev/reload.js"></script>'
))

Useful for development tools, analytics scripts, or debug toolbars. HTMLInject does not run on streaming bodies.

Script injection

Alpine.js and htmx scripts are injected by config flags, not by middleware you wire yourself. Set one flag and Chirp owns the script tag (CDN URL, CSP nonce, and deduplication) for you.

  • Alpine.js — setAppConfig(alpine=True) (also enabled by use_chirp_ui(app)). Chirp injects the Alpine CDN bundle before</body>on buffered and streaming HTML, and registers thealpine_json_configtemplate global. Details: Alpine.js injection.
  • htmx — setAppConfig(htmx=True). Opt-in, default off. Chirp injects the htmx core script (explicit jsDelivr/dist/htmx.min.jspath, per-request CSP nonce), dedups ondata-chirp="htmx", and mirrors the Alpine injector. use_chirp_ui(app)does not auto-enable it because the chirp-ui layouts already ship their own htmx tag.