Security headers & CORS
The HTTP-level hardening fastapi-guard adds for free, and the one CORS configuration mistake everybody makes the first time.
The security_headers map produces a fixed set of response headers on every request. Some are configurable, some are hardcoded. Each closes a specific class of attack.
Seven response headers
| Header | Source | Blocks |
|---|---|---|
| Content-Security-Policy | csp dict | XSS, third-party script injection |
| Strict-Transport-Security | hsts dict | SSL-stripping MITM |
| X-Content-Type-Options | hardcoded nosniff | MIME-sniffing (text/* served as HTML) |
| X-XSS-Protection | hardcoded 1; mode=block | Legacy XSS heuristics (harmless safety net) |
| X-Frame-Options | frame_options | Clickjacking via iframe embedding |
| Referrer-Policy | referrer_policy | Leaking URLs with tokens via Referer |
| Permissions-Policy | permissions_policy | Third-party iframes grabbing camera/mic |
CSP - the XSS perimeter
Built from your csp dict. Directives are emitted exactly as configured. No automatic nonce or hash injection for inline scripts.
csp={
"default-src": ["'self'"],
"script-src": ["'self'"],
"style-src": ["'self'", "'unsafe-inline'"],
"img-src": ["'self'", "data:"],
"connect-src": ["'self'"],
}| Directive | What it does |
|---|---|
default-src | Fallback for anything not listed below |
script-src | Only same-origin JS; no inline, no eval |
style-src | Same-origin + inline allowed (Tailwind/etc) |
img-src | Same-origin + data: URIs (icons, base64) |
connect-src | XHR / fetch / WebSocket targets |
HSTS - the preload gotcha
HSTS tells browsers "always use HTTPS for this host, for the next year." Defends against SSL-stripping MITM. Three directives, one of which has a non-obvious enrollment step.
Strict-Transport-Security: max-age=31536000; includeSubDomainsmax-age=31536000- one year (365 days). How long the browser remembers "HTTPS only."includeSubDomains- the policy extends to every subdomain. Powerful, and irreversible for clients that already cached the policy.preload- two-step opt-in. Only emitted whenpreload: True. Bakes your domain into the browser's HSTS list, but only after you submit to hstspreload.org.
preload: False omits the directive entirely (per HSTS spec; preload=False is meaningless).
The other five - smaller headers, real protections
| Header | Value | Defense |
|---|---|---|
X-Content-Type-Options | hardcoded nosniff | Browser must respect declared Content-Type. Prevents .txt response from being interpreted as HTML. |
X-XSS-Protection | hardcoded 1; mode=block | Legacy browser XSS heuristic. Modern browsers ignore it; harmless to keep for older clients. |
X-Frame-Options | from config (SAMEORIGIN) | Refuse to be embedded in an iframe from another origin. SAMEORIGIN allows your own pages to frame each other. |
Referrer-Policy | from config (strict-origin-when-cross-origin) | Cross-origin requests get only the origin (not the path or query) in the Referer. Same-origin gets the full URL. |
Permissions-Policy | from config (geolocation=(), microphone=(), camera=()) | Empty allow-lists for these capabilities. Third-party iframes can't ask the browser for them on your page. |
None individually saves you. Together with CSP and HSTS, they raise the cost of every browser-side attack class. Setting them at middleware level guarantees every response gets them. No per-route forgetting.
CORS - handled inside SecurityMiddleware
As of fastapi-guard 6.0.0, CORS is handled directly by SecurityMiddleware via guard_core.cors_handler. No external Starlette CORSMiddleware is involved. Just app.add_middleware(SecurityMiddleware, config=config) and CORS activates automatically based on your cors_* SecurityConfig fields.
Two implications:
- No separate
configure_cors()call - earlier versions hadSecurityMiddleware.configure_cors(app, config)as a second wiring step. That method was removed in 6.0.0. If you're upgrading from <6.0.0, delete the call. - The security pipeline runs against preflight requests -
OPTIONSpreflights now go through IP filtering, rate limiting, and detection before getting a CORS response. Previously, the external Starlette CORSMiddleware short-circuited preflights ahead of the security pipeline, so banned IPs and rate-limited clients could preflight freely. v6.0.0+ closes that gap.
The four CORS knobs still control which cross-origin requests succeed and what they can carry.
enable_cors=True,
cors_allow_origins=["http://localhost:3000"],
cors_allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
cors_allow_headers=["*"],
cors_allow_credentials=True,| Field | What |
|---|---|
cors_allow_origins | Exact origin matches only. Browser checks Origin against this list. |
cors_allow_methods | Which HTTP verbs the browser may use. OPTIONS required for preflight. |
cors_allow_headers | Which custom request headers the browser may send. * is safe here, unlike origins. |
cors_allow_credentials | Allow cookies / auth headers on cross-origin requests. This is the field that interacts dangerously with origins. |
The gotcha - credentials=True + origins=["*"]
The CORS spec forbids combining a wildcard origin with credentialed requests. fastapi-guard does NOT runtime-assert against this. You'll get no clear error. The browser just rejects every response, and you'll spend an afternoon debugging it.
Do: list explicit origins with credentials.
cors_allow_origins=[
"https://app.example.com",
"https://staging.example.com",
],
cors_allow_credentials=TrueBoth work together. Credentials flow on cross-origin requests from the listed origins only.
Diagnosing it: browser DevTools → Network → look for CORS error: cannot use wildcard in Access-Control-Allow-Origin when credentials flag is true.
Wiring
security_headers={
"csp": {
"default-src": ["'self'"],
"script-src": ["'self'"],
"style-src": ["'self'", "'unsafe-inline'"],
"img-src": ["'self'", "data:"],
"connect-src": ["'self'"],
},
"hsts": {
"max_age": 31536000,
"include_subdomains": True,
"preload": False,
},
"frame_options": "SAMEORIGIN",
"referrer_policy": "strict-origin-when-cross-origin",
"permissions_policy": "geolocation=(), microphone=(), camera=()",
},
enable_cors=True,
cors_allow_origins=["http://localhost:3000"],
cors_allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
cors_allow_headers=["*"],
cors_allow_credentials=True,Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Browser blocks your own frontend with a CORS error in production | cors_allow_origins still lists only localhost. Origins are exact-match, http and https count as different origins; ports must match. | Add your production origin (full scheme + host + port). List staging too if it talks to the same API. |
Inline <script> tags broken after enabling middleware | CSP is doing its job. script-src 'self' refuses inline JS. Same applies to <style>, eval(), similar dynamic-execution paths. | Move inline JS to external files (preferred), or generate a per-request nonce and add 'nonce-{value}' to script-src. Middleware won't do this automatically. |
| HSTS preload not taking effect | preload: True in config, but the header alone doesn't enroll you in the browser's preload list. Enrollment requires manual submission AND meeting Google's HSTS preload criteria. | Set preload: True, deploy, then submit to hstspreload.org. Inclusion takes weeks. Removing yourself later is slow. Confirm you want this before submitting. |