|
1 | 1 | --- |
2 | 2 | title: CORS & security headers |
3 | | -lastUpdated: 2026-06-11 |
| 3 | +lastUpdated: 2026-08-10 |
4 | 4 | description: CORS-before-HTTPS-redirect ordering, the SignalR-credentialed-CORS gotcha, and the production security headers the kit emits by default. |
5 | 5 | sidebar: |
6 | 6 | label: CORS & headers |
@@ -54,6 +54,24 @@ Pipeline order (relevant slice): |
54 | 54 | 6. ... |
55 | 55 | ``` |
56 | 56 |
|
| 57 | +## Front-end origin for auth e-mail links |
| 58 | + |
| 59 | +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). |
| 60 | + |
| 61 | +```jsonc |
| 62 | + "FrontendOptions": { |
| 63 | + "AllowedOrigins": [ "http://localhost:5173", "http://localhost:5174" ], |
| 64 | + "DefaultOrigin": "http://localhost:5174" // the tenant SPA |
| 65 | + } |
| 66 | +``` |
| 67 | + |
| 68 | +- **Self-service flows** (`forgot-password`, `self-register`) build the link from the request `Origin` header, validated against `AllowedOrigins` and 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: a **forged or unlisted `Origin` is rejected with `400`** rather than turned into a link. A request with **no** `Origin` header (curl, the Scalar try-it UI, mobile, server-to-server) falls back to `DefaultOrigin` instead of failing. |
| 69 | +- **Operator-driven flows** (`register`, `resend-confirmation-email`) target `DefaultOrigin` - 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. |
| 70 | +- Matching is component-wise (scheme + host + port, port exact). `appsettings.Production.json` ships both settings empty. The host **still starts** without them: it logs one startup `Warning` naming `FrontendOptions:DefaultOrigin`, and link resolution falls back to the API's own origin - `OriginOptions:OriginUrl`, or the request's own host when that is empty too. E-mails keep going out, but their links point at the API rather than your SPA (which is where confirmation links pointed before this resolver existed). A background job, having no request to derive a host from, fails instead. Configure both (see the [production checklist](/docs/security/production-checklist/)) to send users where they expect to land. |
| 71 | +- `DefaultOrigin` is a **single global**, not per-tenant or custom-domain aware, so operator-driven `register` / `resend-confirmation-email` point 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. |
| 72 | + |
| 73 | +(`OriginOptions:OriginUrl` has a second, unrelated role: it's the API's own public base for back-end-served assets such as avatar URLs, exposed via `IRequestContext.Origin`.) |
| 74 | + |
57 | 75 | ## Why not AllowAnyOrigin for SignalR |
58 | 76 |
|
59 | 77 | 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. |
@@ -126,23 +144,6 @@ services.ConfigureApplicationCookie(o => |
126 | 144 |
|
127 | 145 | The cookie should be HttpOnly (no JS access - limits XSS impact), Secure (HTTPS only), and SameSite=Strict for the strongest CSRF defence. |
128 | 146 |
|
129 | | -## Front-end origin for auth e-mail links |
130 | | - |
131 | | -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). |
132 | | - |
133 | | -```jsonc |
134 | | - "FrontendOptions": { |
135 | | - "AllowedOrigins": [ "http://localhost:5173", "http://localhost:5174" ], |
136 | | - "DefaultOrigin": "http://localhost:5174" // the tenant SPA |
137 | | - } |
138 | | -``` |
139 | | - |
140 | | -- **Self-service flows** (`forgot-password`, `self-register`) build the link from the request `Origin` header, validated against `AllowedOrigins` and 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: a **forged or unlisted `Origin` is rejected with `400`** rather than turned into a link. A request with **no** `Origin` header (curl, the Scalar try-it UI, mobile, server-to-server) falls back to `DefaultOrigin` instead of failing. |
141 | | -- **Operator-driven flows** (`register`, `resend-confirmation-email`) target `DefaultOrigin` - 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. |
142 | | -- Matching is component-wise (scheme + host + port, port exact). `appsettings.Production.json` ships both settings empty. The host **still starts** without them: it logs one startup `Warning` naming `FrontendOptions:DefaultOrigin`, and link resolution falls back to the API's own origin (`OriginOptions:OriginUrl`), so e-mails keep going out but point at the API rather than your SPA. Configure them (see the [production checklist](/docs/security/production-checklist/)) to send users where they expect to land. |
143 | | - |
144 | | -(`OriginOptions:OriginUrl` has a second, unrelated role: it's the API's own public base for back-end-served assets such as avatar URLs, exposed via `IRequestContext.Origin`.) |
145 | | - |
146 | 147 | ## Common mistakes |
147 | 148 |
|
148 | 149 | - **Setting `AllowAll = true` in 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). |
|
0 commit comments