Security headers & CORS

The HTTP-level hardening fastapi-guard adds for free, and the one CORS configuration mistake everybody makes the first time.

6 min read · verified against [email protected]

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

HeaderSourceBlocks
Content-Security-Policycsp dictXSS, third-party script injection
Strict-Transport-Securityhsts dictSSL-stripping MITM
X-Content-Type-Optionshardcoded nosniffMIME-sniffing (text/* served as HTML)
X-XSS-Protectionhardcoded 1; mode=blockLegacy XSS heuristics (harmless safety net)
X-Frame-Optionsframe_optionsClickjacking via iframe embedding
Referrer-Policyreferrer_policyLeaking URLs with tokens via Referer
Permissions-Policypermissions_policyThird-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'"],
}
DirectiveWhat it does
default-srcFallback for anything not listed below
script-srcOnly same-origin JS; no inline, no eval
style-srcSame-origin + inline allowed (Tailwind/etc)
img-srcSame-origin + data: URIs (icons, base64)
connect-srcXHR / 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; includeSubDomains
  • max-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 when preload: 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

HeaderValueDefense
X-Content-Type-Optionshardcoded nosniffBrowser must respect declared Content-Type. Prevents .txt response from being interpreted as HTML.
X-XSS-Protectionhardcoded 1; mode=blockLegacy browser XSS heuristic. Modern browsers ignore it; harmless to keep for older clients.
X-Frame-Optionsfrom config (SAMEORIGIN)Refuse to be embedded in an iframe from another origin. SAMEORIGIN allows your own pages to frame each other.
Referrer-Policyfrom 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-Policyfrom 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:

  1. No separate configure_cors() call - earlier versions had SecurityMiddleware.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.
  2. The security pipeline runs against preflight requests - OPTIONS preflights 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,
FieldWhat
cors_allow_originsExact origin matches only. Browser checks Origin against this list.
cors_allow_methodsWhich HTTP verbs the browser may use. OPTIONS required for preflight.
cors_allow_headersWhich custom request headers the browser may send. * is safe here, unlike origins.
cors_allow_credentialsAllow 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=True

Both 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

SymptomLikely causeFix
Browser blocks your own frontend with a CORS error in productioncors_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 middlewareCSP 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 effectpreload: 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.