Security¶
QuoinAPI ships several middleware layers that cover the most common
production security hardening steps out of the box. All of them are
configurable via QUOIN_* environment variables and safe to run in
development with their default values.
CORS Hardening¶
Cross-Origin Resource Sharing is controlled by CORSMiddleware,
configured in app/core/middlewares.py.
Configuration¶
| Variable | Default | Notes |
|---|---|---|
QUOIN_BACKEND_CORS_ORIGINS |
["http://localhost:3000", "http://localhost:8000"] |
Empty list disables CORS entirely |
QUOIN_BACKEND_CORS_ALLOW_METHODS |
["GET","POST","PUT","PATCH","DELETE","OPTIONS"] |
|
QUOIN_BACKEND_CORS_ALLOW_HEADERS |
["Authorization","Content-Type","X-Request-ID"] |
|
QUOIN_BACKEND_CORS_ALLOW_CREDENTIALS |
true |
See warning below |
Wildcard footgun protection¶
Browsers silently refuse credentialed CORS responses when the server
responds with Access-Control-Allow-Methods: * or
Access-Control-Allow-Headers: *. QuoinAPI detects this at startup and
raises a RuntimeError if you combine wildcards with
allow_credentials=True outside development:
RuntimeError: CORS misconfiguration: allow_credentials=True with wildcard
allow_methods/allow_headers is rejected outside development.
This is intentional — a silent browser refusal is harder to debug than a startup crash.
In development the guard is skipped so you can use loose settings during local work.
Production example¶
# .env.production
QUOIN_BACKEND_CORS_ORIGINS=["https://app.example.com"]
QUOIN_BACKEND_CORS_ALLOW_METHODS=["GET","POST","PUT","DELETE","OPTIONS"]
QUOIN_BACKEND_CORS_ALLOW_HEADERS=["Authorization","Content-Type"]
QUOIN_BACKEND_CORS_ALLOW_CREDENTIALS=true
Security Headers¶
SecurityHeadersMiddleware adds a standard set of defensive response
headers. It is enabled by default and runs on every HTTP response.
Toggle via QUOIN_SECURITY_HEADERS_ENABLED=false if your reverse proxy
(NGINX, Caddy, Cloudflare) manages headers instead.
Headers emitted¶
| Header | Default value | Purpose |
|---|---|---|
X-Content-Type-Options |
nosniff |
Prevents MIME-type sniffing |
X-Frame-Options |
DENY |
Blocks framing / clickjacking |
Referrer-Policy |
strict-origin-when-cross-origin |
Limits referrer leakage |
Permissions-Policy |
geolocation=(), camera=(), microphone=() |
Disables unused browser APIs |
Content-Security-Policy |
See below | Restricts resource loading |
Strict-Transport-Security |
max-age=31536000; includeSubDomains |
Forces HTTPS (browsers only honour over HTTPS) |
Content-Security-Policy¶
The default CSP accommodates the built-in homepage (Google Fonts, simpleicons CDN, and inline styles/scripts):
default-src 'self';
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' https://cdn.simpleicons.org https://fastapi.tiangolo.com;
script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net;
frame-ancestors 'none';
base-uri 'self'
The default covers two built-in UIs:
- Homepage — Google Fonts (
fonts.googleapis.com/fonts.gstatic.com) and tech-logo icons (cdn.simpleicons.org). - Swagger UI (
/docs) — FastAPI loads its UI assets and favicon fromcdn.jsdelivr.netandfastapi.tiangolo.com.
unsafe-inline
Both style-src and script-src include 'unsafe-inline' because
the homepage uses inline <style> and <script> blocks. Moving those
to static files would let you drop 'unsafe-inline' for a stricter
policy.
Override it for your own frontend:
QUOIN_SECURITY_CSP=default-src 'self'; img-src 'self' data:; \
frame-ancestors 'none'; base-uri 'self'
HSTS tuning¶
HSTS is emitted by default. Browsers only honour it over HTTPS — over
HTTP it is silently ignored. Set max-age=0 to suppress the header
entirely (e.g. behind a TLS-terminating proxy that sets it itself):
Enable the preload directive only once your domain is submitted to
the HSTS preload list — it is hard to reverse:
Request Size Limit¶
RequestSizeLimitMiddleware rejects requests whose Content-Length
exceeds the configured cap before the route handler reads the body. It
returns a 413 Content Too Large RFC 9457 Problem Details response:
{
"type": "urn:quoin:error:payload_too_large",
"title": "Content Too Large",
"status": 413,
"detail": "Request body exceeds 1048576 bytes",
"instance": "/api/v1/users"
}
| Variable | Default | Notes |
|---|---|---|
QUOIN_MAX_REQUEST_BODY_BYTES |
1048576 (1 MiB) |
<=0 disables the cap |
Tuning for file uploads¶
If a route accepts file uploads, raise the cap or disable it for that deployment:
Chunked transfers
The middleware only checks the advertised Content-Length.
Conforming HTTP clients always send it. The underlying uvicorn/h11
layer caps raw protocol buffers for the rare chunked case.
Middleware ordering¶
Middleware is registered in LIFO order via add_middleware, so the
execution order from outermost to innermost is:
TimeoutMiddleware ← outermost: wraps full lifecycle
RequestSizeLimitMiddleware ← reject oversize before downstream reads
RequestIDMiddleware
SecurityHeadersMiddleware
TrustedHostMiddleware
CORSMiddleware ← innermost
See Also¶
- Configuration reference — all
QUOIN_SECURITY_*variables - Dependency Scanning — Dependabot + secret scanning
- Deployment guide — production environment setup
app/core/middlewares.py— implementation