Skip to content

Middleware

Nori uses ASGI middleware to process every HTTP request and response. Middleware runs before your controller code and after it — a pipeline that adds security headers, manages sessions, validates CSRF tokens, and traces requests across services.


Middleware Stack

Middleware is registered in asgi.py as an ordered list. Starlette wraps them so the first in the list runs first on the way in and last on the way out:

Request ──→ RequestIdMiddleware
         SecurityHeadersMiddleware
           CORSMiddleware (if enabled)
            SessionMiddleware
             CsrfMiddleware
           Your Controller
             CsrfMiddleware
            SessionMiddleware
           CORSMiddleware (if enabled)
         SecurityHeadersMiddleware
          RequestIdMiddleware
Response ←──┘

Registration

# asgi.py
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware
from core.auth.csrf import CsrfMiddleware
from core.http.request_id import RequestIdMiddleware
from core.http.security_headers import SecurityHeadersMiddleware

middleware = [
    Middleware(RequestIdMiddleware),
    Middleware(SecurityHeadersMiddleware),
    Middleware(SessionMiddleware, secret_key=settings.SECRET_KEY, https_only=not settings.DEBUG),
    Middleware(CsrfMiddleware),
]

if settings.CORS_ORIGINS:
    # Insert at index 2 so SecurityHeaders wraps CORS — preflight responses
    # must still receive security headers.
    middleware.insert(2, Middleware(CORSMiddleware,
        allow_origins=settings.CORS_ORIGINS,
        allow_methods=settings.CORS_ALLOW_METHODS,
        allow_headers=settings.CORS_ALLOW_HEADERS,
        allow_credentials=settings.CORS_ALLOW_CREDENTIALS,
    ))

Why This Order

Middleware order is not arbitrary. Each position is deliberate:

  • RequestId first: every log line — including middleware errors — gets a trace ID.
  • SecurityHeaders early: all responses get security headers, even on middleware failures.
  • CORS before Session: preflight OPTIONS requests must be handled before session cookie processing.
  • Session before CSRF: session is populated before CSRF runs, so auth decorators downstream can read session data after CSRF validation passes. (CSRF itself is now stateless and does not read the session.)
  • CSRF last: processes the request body after all other middleware have had their turn.

Built-in Middleware

RequestIdMiddleware

Module: core.http.request_id

Assigns a unique UUID to each HTTP request for end-to-end tracing. The ID is available in your controller, in log output, and in the response headers.

Parameter Type Default Description
header_name str 'x-request-id' Header name to read/write
trust_incoming bool True Accept X-Request-ID from the client instead of generating a new one

Behavior:

  1. If trust_incoming=True and the request includes an X-Request-ID header, that value is reused (useful for tracing across a reverse proxy or microservices).
  2. Otherwise, a new uuid4 is generated.
  3. The ID is stored in request.state.request_id and added to the response as X-Request-ID.

Access in controllers:

async def show(self, request: Request):
    request_id = request.state.request_id
    log.info("Processing request %s", request_id)

Disable incoming trust (e.g., if clients should not control the trace ID):

Middleware(RequestIdMiddleware, trust_incoming=False),

SecurityHeadersMiddleware

Module: core.http.security_headers

Injects security headers on every HTTP response. Headers are pre-encoded at startup for zero per-request overhead.

Parameter Type Default Description
headers dict See below Custom headers that override the defaults
hsts bool True Enable Strict-Transport-Security
hsts_max_age int 31536000 (1 year) HSTS max-age in seconds
csp str \| None None Content-Security-Policy value (not sent if None)

Default headers:

Header Value Protects Against
X-Content-Type-Options nosniff MIME-sniffing attacks
X-Frame-Options DENY Clickjacking
X-XSS-Protection 1; mode=block Reflected XSS (legacy browsers)
Referrer-Policy strict-origin-when-cross-origin Information leakage via referrer
Permissions-Policy camera=(), microphone=(), geolocation=() Unauthorized device access
Strict-Transport-Security max-age=31536000; includeSubDomains Protocol downgrade attacks

Add a Content Security Policy:

Middleware(SecurityHeadersMiddleware,
    csp="default-src 'self'; script-src 'self' https://cdn.example.com",
),

Disable HSTS (for development without HTTPS):

Middleware(SecurityHeadersMiddleware, hsts=False),

Override a default header:

Middleware(SecurityHeadersMiddleware,
    headers={'X-Frame-Options': 'SAMEORIGIN'},
),

SessionMiddleware

Module: starlette.middleware.sessions (Starlette built-in)

Creates and validates signed session cookies using SECRET_KEY. Populates request.session as a dict-like object.

Parameter Type Default Description
secret_key str settings.SECRET_KEY Key for signing session cookies
https_only bool not settings.DEBUG Only send cookie over HTTPS

Sessions are required by authentication decorators (@login_required, @role_required), flash messages, and OAuth state. CSRF is stateless as of v2.0.0 and no longer depends on the session. In production (DEBUG=False), the cookie is marked Secure so it is never sent over plain HTTP.


CsrfMiddleware

Module: core.auth.csrf

Validates CSRF tokens on all state-changing HTTP methods (POST, PUT, DELETE, PATCH) using an OWASP signed double-submit cookie (v2.0.0). The middleware is stateless — it does not read or write the session for CSRF purposes. Full details in Security.

Parameter Type Default Description
exempt_paths set \| None None Paths to skip CSRF validation

Key behaviors:

  • Cookie issuance: on every request without a valid CSRF cookie, a fresh {nonce}.{sig} cookie is issued via a send-wrapper that appends Set-Cookie to the live response. The cookie is per visitor and never cached (INV-030).
  • Validation (two checks):
  • Cookie integrity — recompute HMAC-SHA256(SECRET_KEY, nonce) over the cookie's nonce and constant-time-compare to the cookie's sig. Forged cookies die here.
  • Double-submit match — constant-time-compare the (unmasked) submitted value to the full cookie value. Both raw (shim/header path) and BREACH-masked (server-rendered form field) submissions are accepted.
  • Token submission paths: X-CSRF-Token header is checked first (body not read); then _csrf_token in a urlencoded form body; JSON and multipart requests must use the header.
  • Safe methods: GET, HEAD, OPTIONS, TRACE are always exempt.
  • Body size limit: urlencoded form bodies are buffered up to CSRF_FORM_MAX_BODY_SIZE (default 10 MB); exceeded bodies return 413. Multipart bodies are never buffered — use X-CSRF-Token header for file-upload requests.
  • Constant-time comparison: both HMAC verification and double-submit match use hmac.compare_digest.

Exempt specific paths (e.g., a webhook endpoint):

Middleware(CsrfMiddleware, exempt_paths={'/webhooks/stripe', '/webhooks/github'}),

Template helpers (registered as Jinja2 globals):

<!-- Full hidden input (BREACH-masked per render) -->
{{ csrf_field(request)|safe }}

<!-- Raw cookie value for AJAX X-CSRF-Token header -->
{{ csrf_token(request) }}

The JS shim (static/js/csrf.js, included by base.html) automatically sets X-CSRF-Token on every fetch/XHR and overwrites stale _csrf_token fields on cached pages before submit. Sites not extending base.html must include the shim on any page served from cache_response that submits forms.


CORSMiddleware

Module: starlette.middleware.cors (Starlette built-in)

Only activated if CORS_ORIGINS is set in .env. If omitted or empty, all cross-origin requests are denied (same-site policy).

# .env
CORS_ORIGINS=http://localhost:3000,https://app.example.com
Setting Default
CORS_ORIGINS (empty — CORS disabled)
CORS_ALLOW_METHODS GET, POST, PUT, PATCH, DELETE, OPTIONS
CORS_ALLOW_HEADERS Content-Type, Authorization, X-CSRF-Token
CORS_ALLOW_CREDENTIALS True

Rate Limiting (@throttle)

Rate limiting in Nori is not a stack middleware — it is a per-endpoint decorator. This gives you fine-grained control: different limits on different endpoints instead of a blanket rule.

from core.http.throttle import throttle

class AuthController:
    @throttle('5/minute')
    async def login(self, request: Request):
        ...

    @throttle('100/hour')
    async def api_data(self, request: Request):
        ...

Full documentation: Security — Rate Limiting.


Writing Custom Middleware

Nori uses raw ASGI middleware. No base class required — just a class with __init__ and __call__:

class TimingMiddleware:
    """Adds a Server-Timing header to every response."""

    def __init__(self, app):
        self.app = app

    async def __call__(self, scope, receive, send):
        if scope['type'] != 'http':
            return await self.app(scope, receive, send)

        import time
        start = time.perf_counter()

        async def send_with_timing(message):
            if message['type'] == 'http.response.start':
                elapsed = time.perf_counter() - start
                headers = list(message.get('headers', []))
                headers.append((
                    b'server-timing',
                    f'total;dur={elapsed * 1000:.1f}'.encode('latin1'),
                ))
                message = {**message, 'headers': headers}
            await send(message)

        await self.app(scope, receive, send_with_timing)

Registering Custom Middleware

Add it to the middleware list in asgi.py:

from core.http.timing import TimingMiddleware

middleware = [
    Middleware(RequestIdMiddleware),
    Middleware(TimingMiddleware),          # ← add here
    Middleware(SecurityHeadersMiddleware),
    Middleware(SessionMiddleware, secret_key=settings.SECRET_KEY, https_only=not settings.DEBUG),
    Middleware(CsrfMiddleware),
]

ASGI Middleware Pattern

Every ASGI middleware follows the same structure:

  1. __init__(self, app, ...) — receives the next app in the chain and any configuration parameters.
  2. async __call__(self, scope, receive, send) — called for every connection (HTTP, WebSocket, lifespan).
  3. Guard on scope type — always check scope['type'] != 'http' and pass through non-HTTP scopes unchanged.
  4. Wrap send or receive — to modify the response or request, wrap the send or receive callables.
  5. Call self.app(scope, receive, send) — forward to the next middleware or the application.

Tips

  • Pre-encode headers in __init__ instead of encoding on every request (see SecurityHeadersMiddleware for the pattern).
  • Non-HTTP scopes: always pass WebSocket and lifespan scopes through untouched unless you specifically need to handle them.
  • Order matters: place your middleware at the right position in the stack. If it needs session data, it must come after SessionMiddleware.