Skip to main content

Drop-in, token-free CSRF protection for any ASGI app, using Fetch Metadata. No dependencies.

Project description

asgi-cross-origin-protection

Cross-origin request protection ASGI middleware. It rejects cross-site state-changing requests (CSRF defense) by inspecting Fetch Metadata headers, with an Origin fallback. It needs no CSRF tokens or session state. The defaults are safe for most apps without configuration.

Pure ASGI with no dependencies: works with Starlette, FastAPI, Litestar, Quart, Django-ASGI, or any other ASGI app.

Install

uv add asgi-cross-origin-protection

Usage

Wrap your app. For most apps this is all you need:

from asgi_cross_origin_protection import CrossOriginProtection

app = CrossOriginProtection(app)

With Starlette/FastAPI's add_middleware:

from fastapi import FastAPI
from asgi_cross_origin_protection import CrossOriginProtection

app = FastAPI()
app.add_middleware(CrossOriginProtection)

The default policy rejects cross-site requests that change state, while allowing same-origin requests, non-browser clients (mobile apps, CLIs, server-to-server), and inbound links. A cross-site attacker cannot forge the Sec-Fetch-Site header or strip Origin from a browser request, so the CSRF vector is still closed.

When to change the defaults

You only need to touch configuration if one of these applies:

If your app… Set
trusts specific partner origins allowed_origins=("https://partner.example",)
has paths that must skip the check (health probes, webhooks) exempt_paths=("/healthz",)
should return something other than the default 403 JSON deny_app=... (see below)
app = CrossOriginProtection(
    app,
    allowed_origins=("https://app.example.com",),
    exempt_paths=("/healthz",),
)

allowed_origins entries must be bare origins (scheme://host[:port]); an entry with a path, query, fragment, or missing scheme/host raises a ValueError at construction. exempt_paths entries must be absolute (start with /) and match on path-segment boundaries, so /healthz exempts /healthz and /healthz/live but not /healthz-internal; a non-absolute or empty entry raises a ValueError. An exemption applies to every method (in practice only state-changing ones, since safe methods are always allowed regardless).

Custom rejection response

deny_app is any ASGI app. Starlette/FastAPI Response instances are themselves ASGI apps, so you can pass one directly:

from starlette.responses import PlainTextResponse

app = CrossOriginProtection(
    app,
    deny_app=PlainTextResponse("forbidden", status_code=403),
)

How it decides

A request is evaluated in this order; the first conclusive signal wins:

  1. allowed_origins: an Origin in this set is allowed regardless of the signals below, so a trusted partner's cross-site request still passes.
  2. Fetch Metadata: only Sec-Fetch-Site of same-origin or none is allowed; same-site, cross-site, and any unrecognized value are rejected. A present Sec-Fetch-Site is conclusive — the Origin step below is skipped.
  3. Origin header: compared against the request's own host. The comparison is scheme-blind (the request's scheme is unreliable behind a TLS-terminating proxy; relies on HSTS, as Go does). Origin: null is rejected.
  4. Neither header present (or an empty Origin): allowed unless allow_unverifiable_requests is cleared.

Safe methods (GET/HEAD/OPTIONS) are always allowed; rejection applies to state-changing methods.

Hardening

allow_unverifiable_requests (default True) governs requests that carry neither Sec-Fetch-Site nor Origin, so their origin cannot be checked. These are typically non-browser clients (mobile apps, CLIs, server-to-server). They are allowed by default because a browser CSRF attempt always carries one of those headers. Set it to False only if your app serves browsers exclusively and you want to reject everything else:

app = CrossOriginProtection(app, allow_unverifiable_requests=False)

Cross-origin isolation headers

COOP/COEP/CORP isolation headers are a separate, optional middleware. Most apps do not need them. Reach for CrossOriginIsolation when you specifically want cross-origin isolation, for example to enable crossOriginIsolated and APIs like SharedArrayBuffer:

from asgi_cross_origin_protection.isolation import CrossOriginIsolation

app = CrossOriginIsolation(app)

Each policy is added only when the wrapped app did not already set that header. Pass None for a policy to leave its header alone. Defaults: COOP same-origin, COEP require-corp, CORP same-site.

require-corp is a breaking default: once a document carries it, every cross-origin subresource it loads (CDN scripts, images, fonts) must itself send Cross-Origin-Resource-Policy or CORS headers, or the browser blocks it. That is inherent to cross-origin isolation — pass embedder_policy=None for COOP and CORP without it.

Compose both middlewares when you want protection and isolation together.

Development

make dev     # sync dependencies and install the prek git hook
make lint    # run all checks via prek (ruff, ty, zizmor)
make test    # pytest (100% coverage gate)

The same prek hooks run automatically on every commit; make lint runs them across all files on demand.

Influences

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

asgi_cross_origin_protection-0.1.1.tar.gz (9.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

asgi_cross_origin_protection-0.1.1-py3-none-any.whl (12.3 kB view details)

Uploaded Python 3

File details

Details for the file asgi_cross_origin_protection-0.1.1.tar.gz.

File metadata

File hashes

Hashes for asgi_cross_origin_protection-0.1.1.tar.gz
Algorithm Hash digest
SHA256 346eb6f34b0525b2cf4af7eda402a8ae2d8adcb923c0efc75b53d17ce8dc87f9
MD5 dc6207d1098ef0a9bb545dc72e8f7343
BLAKE2b-256 81971bec9024a50661ebaba9fbfe89dd7f4c2dd04ad0f08b202dd944ed1f1449

See more details on using hashes here.

Provenance

The following attestation bundles were made for asgi_cross_origin_protection-0.1.1.tar.gz:

Publisher: publish.yml on thomasdesr/asgi-cross-origin-protection

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file asgi_cross_origin_protection-0.1.1-py3-none-any.whl.

File metadata

File hashes

Hashes for asgi_cross_origin_protection-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 36837ef65ad46bc0abd9676d9fdc0f417647047d35bf070a7e464f0813e9647a
MD5 d13c2b80324e6dd4f0f5ad100fa51f14
BLAKE2b-256 5c5d08c081df401b1a92b3195b998448c0cbf33696eb6757ac9f6b7460e85002

See more details on using hashes here.

Provenance

The following attestation bundles were made for asgi_cross_origin_protection-0.1.1-py3-none-any.whl:

Publisher: publish.yml on thomasdesr/asgi-cross-origin-protection

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page