CORS in fullstackhero has two non-default conventions: CORS middleware runs before HTTPS redirect (preflight requests can’t follow redirects), and credentialed CORS uses SetIsOriginAllowed rather than AllowAnyOrigin (the latter silently breaks SignalR). Security headers (CSP, HSTS, X-Frame-Options, Referrer-Policy) ship with sensible defaults and a few configurable knobs.
How the kit wires CORS
AddHeroCors binds CorsOptions from configuration and registers a single global policy (FSHCorsPolicy):
{ "CorsOptions": { "AllowAll": false, "AllowedOrigins": [ "https://app.example.com", "https://admin.example.com" ], "AllowedHeaders": [ "content-type", "authorization", "tenant", "x-fsh-app", "idempotency-key", "x-requested-with", "x-signalr-user-agent" ], "AllowedMethods": [ "GET", "POST", "PUT", "PATCH", "DELETE" ] }}AllowAll: true→SetIsOriginAllowed(_ => true)+ any header + any method +AllowCredentials(). Dev only.AllowAll: false→WithOrigins/WithHeaders/WithMethodsfrom the three lists +AllowCredentials(). Startup validation fails if any of the three lists is empty whileAllowAllis false.- The shipped header and method lists are exactly what the two React apps send:
tenanton every call,x-fsh-appon login,idempotency-keyon chat send,x-requested-with/x-signalr-user-agenton the SignalR negotiate, andPATCHfor partial updates. If you overrideAllowedHeadersorAllowedMethods, keep all of them - drop one and the browser rejects the preflight, so login, realtime orPATCHcalls fail under restricted CORS (dev hides this becauseappsettings.Development.jsonsetsAllowAll: true). Add your own custom headers on top. - Credentials are always allowed by the policy - there is no
AllowCredentialsconfig key. - If
AllowAllis false andAllowedOriginsis empty, CORS isn’t mounted at all - cross-origin browser calls will simply fail. (appsettings.Production.jsonships with an empty list precisely so you have to fill it in.)
UseHeroPlatform mounts the middleware before HTTPS redirect, because OPTIONS preflight requests cannot follow HTTP→HTTPS redirects per the Fetch spec - the redirect breaks the preflight and the actual request never goes out.
Pipeline order (relevant slice):
1. UseExceptionHandler2. UseForwardedHeaders ← before anything reads the client IP or scheme3. UseResponseCompression4. UseCors ← before HTTPS redirect5. UseHttpsRedirection6. Security headers7. ...Front-end origin for auth e-mail links
Links that land on a front-end SPA - the password-reset and e-mail-confirmation e-mails - are not built from the CORS list. They resolve through a dedicated FrontendOptions, kept separate from CORS on purpose: the CORS allowlist governs which browsers may call the API, while this list governs which origins may appear inside an outbound link. The two often overlap but carry different duties, and coupling them breaks same-origin / reverse-proxy topologies (SPA + API on one domain need no CORS entries, yet the browser still sends Origin on the POST).
"FrontendOptions": { "AllowedOrigins": [ "http://localhost:5173", "http://localhost:5174" ], "DefaultOrigin": "http://localhost:5174" // the tenant SPA }- Self-service flows (
forgot-password,self-register) build the link from the requestOriginheader, validated againstAllowedOriginsand returned as the canonical list entry - so with more than one SPA each user gets a link back to the app they started from. Because forgot-password is anonymous this is a security boundary: once the list is non-empty, a forged or unlistedOriginis rejected with400rather than turned into a link. A request with noOriginheader (curl, mobile, server-to-server) falls back toDefaultOrigininstead of failing. The Scalar try-it UI is not in that group - it fetches from the browser, so it sends the API’s own origin; add that origin toAllowedOriginsif you want to exercise these two endpoints from the docs UI. - With
AllowedOriginsempty there is nothing to validate against, so the header is discarded and the link usesDefaultOrigin. That keeps the single-SPA and reverse-proxy setups working onDefaultOriginalone - browsers attachOriginto these POSTs even same-origin, so matching an empty list would otherwise reject every legitimate reset. The client’s value is never echoed either way. List your origins as soon as you serve more than one front-end, or every user lands on the same app. - Operator-driven flows (
register,resend-confirmation-email) targetDefaultOrigin- the recipient’s app - not the calling operator’s origin, so a tenant user provisioned from the admin console gets a link into the tenant app, not the console. - Matching is component-wise (scheme + host + port, port exact).
appsettings.Production.jsonships both settings empty. InProductionthe API refuses to start untilDefaultOriginis set to an absolutehttp(s)URL (Missing required configuration 'FrontendOptions:DefaultOrigin' in Production…), the same fail-fast it applies to the database connection string, the Redis connection and the JWT signing key. Outside Production the host still boots and logs one startupErrornamingFrontendOptions:DefaultOrigin; link resolution has no fallback - it returnsDefaultOriginor throws - so confirm-email, resend-confirmation, forgot-password and reset-password answer500until it is set. (The DbMigrator never sends links and is unaffected.) Failing loudly is the deliberate choice: the request host is whatever the caller puts in theHostheader, so a password-reset link derived from it delivers a working token to a domain the attacker picked, and the API’s own origin returns404for the SPA pages these links target. Configure both (see the production checklist) before you deploy. DefaultOriginis a single global, not per-tenant or custom-domain aware, so operator-drivenregister/resend-confirmation-emailpoint every tenant’s link at that one SPA. That fits the kit’s single-dashboard model; a deployment with per-tenant custom domains would need to resolve the recipient tenant’s own origin instead.
(OriginOptions:OriginUrl plays no part in building these links. Its role is the API’s own public base for back-end-served assets such as avatar URLs, exposed via IRequestContext.Origin.)
Reverse proxy & forwarded headers
The kit runs behind a reverse proxy in production (Cloudflare / cloudflared → Caddy / Nginx → app). Without UseForwardedHeaders, Connection.RemoteIpAddress is the proxy’s IP and Request.Scheme is the internal http, which breaks two things: the IP-partitioned rate limiters (auth policy + global IP limiter) collapse into one shared bucket - losing per-origin brute-force protection - and audit / UserSession records log the proxy IP for every request. UseHeroPlatform mounts UseForwardedHeaders first (right after the exception handler), so X-Forwarded-For / X-Forwarded-Proto are applied before rate limiting, auth, HTTPS redirect, and audit read the client.
Blindly trusting X-Forwarded-For is itself a hole - any client that can reach the app could forge its own IP, poisoning audit trails and evading the rate limiter. So trust is bound to the ingress you actually run, via TrustedProxyOptions:
{ "TrustedProxyOptions": { "KnownProxies": [ "10.0.0.5" ], // individual upstream proxy IPs "KnownNetworks": [ "10.0.0.0/8" ], // or trusted upstream CIDRs "ForwardLimit": 2 // ingress hop count (cloudflared → Caddy → app = 2) }}- Forwarded headers are honoured only when the immediate upstream is one of the configured proxies/networks; from any other source they’re ignored and the connection IP/scheme stand.
- Only
X-Forwarded-ForandX-Forwarded-Protoare in the flag list.X-Forwarded-Hostis deliberately left out: rewritingRequest.Hostfrom a header is a host-header injection primitive for anything that builds an absolute URL from the request. The trade-off is thatRequest.Hostkeeps the internal host behind a proxy. Auth e-mail links are not affected either way: they resolve throughFrontendOptions(above), never the request host. ForwardLimitmust match the real number of proxy hops, and must be at least1. The framework default of1reads only the rightmost hop, which in a multi-hop ingress yields the nearest proxy’s IP (or an attacker-injected value) instead of the real client. Anything below1is rejected at startup for the same reason a malformed proxy entry is:0would leave forwarded headers unprocessed with no error at all, and a negative value would fail every request, including requests that carry no forwarded headers.- Secure by default: with
KnownProxiesandKnownNetworksboth empty (asappsettings.json/appsettings.Production.jsonship them), the framework default - trust loopback only - stands, so forwarded headers from a real proxy are ignored until you configure the ingress. Set them as part of your deploy. - A malformed entry fails the host build with a message naming the offending setting and value (for example
TrustedProxyOptions:KnownNetworks contains "10.0.0.0/999", which is not a valid CIDR network), rather than an opaque parse error. A typo can’t silently degrade into “trust nobody”.
Why not AllowAnyOrigin for SignalR
CORS spec says: when a response has Access-Control-Allow-Credentials: true, the Access-Control-Allow-Origin must be an explicit origin, not *. SignalR’s negotiate request is credentialed (it carries Cookie or the JWT via accessTokenFactory’s query-param fallback). With AllowAnyOrigin(), the server emits Allow-Origin: *, which violates the spec - the browser silently refuses to use the response, and SignalR’s HubConnection fails to start with a confusing CORS error.
SetIsOriginAllowed(origin => true) echoes the actual origin back in Allow-Origin, satisfying the spec, while accepting every origin in practice (which is what dev wants).
Security headers
SecurityHeadersMiddleware emits the following on every response (except excluded paths):
| Header | Value | Purpose |
|---|---|---|
X-Content-Type-Options | nosniff | Prevent MIME-type sniffing |
X-Frame-Options | DENY | Prevent clickjacking via iframe |
Referrer-Policy | strict-origin-when-cross-origin | Limit referrer leakage |
X-XSS-Protection | 0 | Explicitly disable the legacy XSS auditor (modern guidance - CSP does this job) |
Strict-Transport-Security | max-age=31536000; includeSubDomains (HTTPS requests only) | Enforce HTTPS |
Content-Security-Policy | composed default, see below | Restrict what scripts, frames, images can load |
The CSP default (set only if nothing upstream already set one):
default-src 'self'; img-src 'self' data: https:;script-src 'self' https: {ScriptSources};style-src 'self' 'unsafe-inline' {StyleSources};object-src 'none'; frame-ancestors 'none'; base-uri 'self';Tune via SecurityHeadersOptions - the CSP isn’t free-form config; you get these knobs:
{ "SecurityHeadersOptions": { "Enabled": true, "ExcludedPaths": [ "/scalar", "/openapi" ], // docs UI manages its own scripts/styles "AllowInlineStyles": true, // drops 'unsafe-inline' from style-src when false "ScriptSources": [], // extra origins appended to script-src "StyleSources": [] // extra origins appended to style-src }}If you embed third-party scripts (analytics, customer-support widgets), add the origin to ScriptSources. For a different policy shape entirely (e.g. a connect-src restriction), set the Content-Security-Policy header yourself earlier in the pipeline - the middleware respects an existing header. Test with browser dev tools open - CSP violations log to the console.
Sub-resource integrity
CSP doesn’t enforce that third-party scripts haven’t been tampered with. For the few external scripts you’d ship (analytics, payment SDK), add integrity attributes:
<script src="https://cdn.example.com/widget.js" integrity="sha384-XYZ..." crossorigin="anonymous"></script>The browser verifies the hash before executing.
Cookies (if you swap JWT for cookie auth)
The kit defaults to JWT bearer (cookies not used for auth). If you fork to cookie auth, the standard hardening applies:
services.ConfigureApplicationCookie(o =>{ o.Cookie.HttpOnly = true; o.Cookie.SameSite = SameSiteMode.Strict; // or Lax for cross-site form-post flows o.Cookie.SecurePolicy = CookieSecurePolicy.Always; o.Cookie.Name = "fsh.auth";});The cookie should be HttpOnly (no JS access - limits XSS impact), Secure (HTTPS only), and SameSite=Strict for the strongest CSRF defence.
Common mistakes
- Setting
AllowAll = truein production. CORS exists to give browsers a sanity check on cross-origin calls. Opening to the world removes the check (it doesn’t directly compromise auth - auth still gates the request - but it removes the browser-enforced “is this site allowed to call you?” layer). - Forgetting to fill
AllowedOriginsin production. WithAllowAll: falseand no origins, CORS isn’t mounted - your React apps on other origins will get blocked by the browser. The symptom is “works in Postman, fails in the browser”. - Missing HSTS. Without HSTS, an attacker on the network can downgrade to HTTP for the first request. The kit emits it on HTTPS responses automatically; verify your proxy doesn’t strip it.
- CSP that breaks the UI. If a third-party widget breaks after tightening CSP, look at the browser console - CSP violations are logged. Add the needed origins to
ScriptSources/StyleSources, don’t disable the middleware.
Related
- Production checklist - header + CORS hardening items.
- Web building block -
AddHeroPlatform+UseHeroPlatformregistration. - Realtime - SignalR + credentialed CORS.