From eb128bf14b250466e2e06f428c62faa43f32f192 Mon Sep 17 00:00:00 2001
From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com>
Date: Thu, 2 Jul 2026 02:57:17 -0300
Subject: [PATCH 1/5] docs(identity): per-request front-end origin for auth
e-mail links
Documents that CorsOptions.AllowedOrigins now doubles as the allowlist the
Identity module validates the request Origin against to build password-reset
and e-mail-confirmation links (per PR fullstackhero/dotnet-starter-kit#1323):
- security/cors-and-headers: new section + common-mistake note
- security/production-checklist: AllowedOrigins gates the auth e-mail flows
- modules/identity: callout on where reset/confirmation links point
- changelog: 2026-07-02 entry
---
src/content/docs/modules/identity.mdx | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/src/content/docs/modules/identity.mdx b/src/content/docs/modules/identity.mdx
index fef3d19d..39da7bc4 100644
--- a/src/content/docs/modules/identity.mdx
+++ b/src/content/docs/modules/identity.mdx
@@ -1,6 +1,6 @@
---
title: Identity module
-lastUpdated: 2026-06-11
+lastUpdated: 2026-07-02
description: JWT bearer + refresh tokens, ASP.NET Identity with roles + permissions, user groups, operator impersonation, two-factor TOTP, sessions, and password-policy enforcement.
sidebar:
label: Identity
@@ -149,6 +149,10 @@ endpoints.MapPost("/users", handler)
All 51 endpoints are under `/api/v1/identity/`. The rate-limited `auth` policy covers `POST /token/issue`, `POST /token/refresh`, `GET /confirm-email`, `POST /users/{id}/resend-confirmation-email`, `POST /forgot-password`, `POST /reset-password`, and `POST /self-register`. Full table:
+
+The `forgot-password` and registration e-mails link back to the **front-end that made the request**: the Identity module resolves the link's base URL from the request `Origin` header, validated against `CorsOptions.AllowedOrigins`. With more than one SPA each user gets a link to the app they started from, and a forged or unlisted origin is rejected rather than e-mailed. The confirmation link lands on the SPA `/confirm-email` page (which then calls `GET /confirm-email`), not the API route directly. List your SPA origins in `AllowedOrigins` for these flows to work — see [CORS & headers](/docs/security/cors-and-headers/).
+
+
| Verb | Route | What it does |
|---|---|---|
| POST | `/token/issue` | Login |
From c77efba758a9c8e047aaf4d1f56f8b8cb326f119 Mon Sep 17 00:00:00 2001
From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com>
Date: Sat, 4 Jul 2026 22:21:01 -0300
Subject: [PATCH 2/5] docs(identity): dedicated FrontendOptions for auth e-mail
link origins
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Update the origin docs to the reworked #1323 model: link resolution moved off
CorsOptions onto a dedicated FrontendOptions (AllowedOrigins + DefaultOrigin).
- cors-and-headers: rewrite the front-end-origin section — decoupled from CORS,
self-service vs operator-driven flows, no-header fallback to DefaultOrigin,
forged origin -> 400, startup validation, component-wise matching.
- modules/identity: callout reflects FrontendOptions + recipient-app targeting
for register/resend.
- production-checklist: FrontendOptions is a separate required setting whose
startup validation fails the boot until configured.
- changelog: describe the FrontendOptions model and the updated deploy action.
---
src/content/docs/changelog/index.mdx | 6 +++++-
src/content/docs/modules/identity.mdx | 2 +-
.../docs/security/cors-and-headers.mdx | 19 ++++++++++++++++++-
.../docs/security/production-checklist.mdx | 2 ++
4 files changed, 26 insertions(+), 3 deletions(-)
diff --git a/src/content/docs/changelog/index.mdx b/src/content/docs/changelog/index.mdx
index 411e58d4..7a37e15f 100644
--- a/src/content/docs/changelog/index.mdx
+++ b/src/content/docs/changelog/index.mdx
@@ -1,6 +1,6 @@
---
title: Overview
-lastUpdated: 2026-07-13
+lastUpdated: 2026-08-10
description: Release notes and version history for fullstackhero.
sidebar:
order: 1
@@ -11,6 +11,10 @@ seo:
Notable changes to the kit, newest first.
+## 2026-08-10
+
+- **Identity: password-reset and e-mail-confirmation links now resolve the correct front-end per request.** The reset link was built from a single configured `OriginOptions.OriginUrl` - which points at the API and ships empty in production, so `forgot-password` threw `Origin URL is not configured` - and the confirmation link was built from the request host and pointed straight at the API's `GET /confirm-email` route. Neither could target the right SPA when the kit serves more than one front-end (the admin console and the tenant dashboard on different origins). Link resolution now goes through a dedicated **`FrontendOptions`** (`AllowedOrigins` + `DefaultOrigin`), kept separate from CORS. **Self-service** flows (`forgot-password`, `self-register`) build the link from the request `Origin` header, validated against `FrontendOptions:AllowedOrigins` and returned as the canonical entry - so each user gets a link back to the app they started from; because forgot-password is anonymous a forged or unlisted `Origin` is rejected with **`400`**, and a request with no `Origin` (curl, Scalar, mobile, server-to-server) falls back to `DefaultOrigin`. **Operator-driven** flows (`register`, `resend-confirmation-email`) target `DefaultOrigin` - the recipient's app - so a tenant user provisioned from the admin console gets a link into the tenant app, not the console. The confirmation e-mail now lands on the SPA `/confirm-email` page (which then calls the API) instead of the raw API route. **Action for deployments:** set `FrontendOptions:DefaultOrigin` to your tenant SPA, and list every SPA origin in `FrontendOptions:AllowedOrigins`. Both ship empty in production; **the host still starts without them** and logs a startup `Warning`, falling back to the API's own origin (`OriginOptions:OriginUrl`) for these links - serviceable, but not where your users expect to land. `CorsOptions:AllowedOrigins` and `OriginOptions:OriginUrl` keep their own roles (browser CORS; the API's public base for avatar URLs). See [#1323](https://github.com/fullstackhero/dotnet-starter-kit/pull/1323).
+
## 2026-07-13
- **Dashboard: tenants can now edit their own branding from Settings.** A new **Settings → Branding** tab lets a tenant admin holding `Tenants.UpdateTheme` customise their **light and dark palettes** and **brand asset URLs** (logo, dark-mode logo, favicon) with a live preview - mirroring the operator's existing tenant-branding card, but self-service and with no `tenant:` header, since the theme endpoints are already scoped to the current tenant. The tab renders only for holders of that permission; a direct-URL visit without it hits the API's `403`, surfaced as an error band. Editing is draft-based - a **Reset to defaults** action and per-palette reset are available, and unsaved edits are preserved while you work (a co-admin's concurrent change appears on a manual refresh rather than overwriting your form).
diff --git a/src/content/docs/modules/identity.mdx b/src/content/docs/modules/identity.mdx
index 39da7bc4..7118b804 100644
--- a/src/content/docs/modules/identity.mdx
+++ b/src/content/docs/modules/identity.mdx
@@ -150,7 +150,7 @@ endpoints.MapPost("/users", handler)
All 51 endpoints are under `/api/v1/identity/`. The rate-limited `auth` policy covers `POST /token/issue`, `POST /token/refresh`, `GET /confirm-email`, `POST /users/{id}/resend-confirmation-email`, `POST /forgot-password`, `POST /reset-password`, and `POST /self-register`. Full table:
-The `forgot-password` and registration e-mails link back to the **front-end that made the request**: the Identity module resolves the link's base URL from the request `Origin` header, validated against `CorsOptions.AllowedOrigins`. With more than one SPA each user gets a link to the app they started from, and a forged or unlisted origin is rejected rather than e-mailed. The confirmation link lands on the SPA `/confirm-email` page (which then calls `GET /confirm-email`), not the API route directly. List your SPA origins in `AllowedOrigins` for these flows to work — see [CORS & headers](/docs/security/cors-and-headers/).
+Auth e-mail links resolve through `FrontendOptions` (a dedicated config, separate from CORS). **Self-service** flows (`forgot-password`, `self-register`) link back to the front-end that made the request - the base URL comes from the request `Origin` header, validated against `FrontendOptions:AllowedOrigins` - so with more than one SPA each user gets a link to the app they started from; a forged or unlisted origin is rejected with `400`, and a request with no `Origin` (non-browser callers) falls back to `FrontendOptions:DefaultOrigin`. **Operator-driven** flows (`register`, `resend-confirmation-email`) target `DefaultOrigin` (the recipient's app), so a tenant user provisioned from the admin console gets a link into the tenant app, not the console. The confirmation link lands on the SPA `/confirm-email` page (which then calls `GET /confirm-email`), not the API route directly. Left unset, the host still boots and these links fall back to the API's own origin, which is rarely what you want - configure it, see [CORS & headers](/docs/security/cors-and-headers/).
| Verb | Route | What it does |
diff --git a/src/content/docs/security/cors-and-headers.mdx b/src/content/docs/security/cors-and-headers.mdx
index 808d74ca..088a6480 100644
--- a/src/content/docs/security/cors-and-headers.mdx
+++ b/src/content/docs/security/cors-and-headers.mdx
@@ -126,10 +126,27 @@ services.ConfigureApplicationCookie(o =>
The cookie should be HttpOnly (no JS access - limits XSS impact), Secure (HTTPS only), and SameSite=Strict for the strongest CSRF defence.
+## 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).
+
+```jsonc
+ "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 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.
+- **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.
+- 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.
+
+(`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`.)
+
## Common mistakes
- **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).
-- **Forgetting to fill `AllowedOrigins` in production.** With `AllowAll: false` and 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".
+- **Forgetting to fill `AllowedOrigins` in production.** With `AllowAll: false` and 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". (Auth e-mail links are a *separate* concern - they use `FrontendOptions`, see above - which ships empty in production too, and silently falls back to the API origin until you set it.)
- **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.
diff --git a/src/content/docs/security/production-checklist.mdx b/src/content/docs/security/production-checklist.mdx
index d0cedb32..fff47e8b 100644
--- a/src/content/docs/security/production-checklist.mdx
+++ b/src/content/docs/security/production-checklist.mdx
@@ -59,6 +59,8 @@ Adjust for your industry. Healthcare (HIPAA) and finance (PCI-DSS) tend to requi
`CorsOptions:AllowAll = true` (and the `SetIsOriginAllowed(_ => true)` policy it enables) is **dev only**. Production needs the explicit lists - and note that `appsettings.Production.json` ships `AllowedOrigins` empty, which means **no CORS middleware mounts at all** until you fill it in; your front-ends on other origins will be blocked by the browser. See [CORS & security headers](/docs/security/cors-and-headers/).
+Separately, set **`FrontendOptions`** (`AllowedOrigins` + `DefaultOrigin`) - the allowlist and fallback the Identity module uses to build password-reset and e-mail-confirmation links. It ships empty in production too. The host still boots without it, logging a startup `Warning` and falling back to the API's own origin (`OriginOptions:OriginUrl`), so auth e-mails keep arriving but their links point at the API instead of your SPA. `DefaultOrigin` is the tenant SPA: it's the fallback for non-browser callers and the target for operator-driven register/resend links.
+
```jsonc
{
"CorsOptions": {
From 763f7ab53f0510226ac8285eecfcfdd8d87b3a71 Mon Sep 17 00:00:00 2001
From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com>
Date: Mon, 13 Jul 2026 13:32:50 -0300
Subject: [PATCH 3/5] docs(security): note DefaultOrigin is a single global,
not per-tenant
Address the MEDIUM review point on #1323: operator-driven register/resend
point every tenant's e-mail link at the one DefaultOrigin SPA. Fits the
kit's single-dashboard model; a per-tenant-custom-domain deployment would
resolve the recipient tenant's own origin instead.
---
src/content/docs/changelog/index.mdx | 2 +-
src/content/docs/modules/identity.mdx | 4 +-
.../docs/security/cors-and-headers.mdx | 37 ++++++++++---------
.../docs/security/production-checklist.mdx | 4 +-
4 files changed, 24 insertions(+), 23 deletions(-)
diff --git a/src/content/docs/changelog/index.mdx b/src/content/docs/changelog/index.mdx
index 7a37e15f..66a96cc2 100644
--- a/src/content/docs/changelog/index.mdx
+++ b/src/content/docs/changelog/index.mdx
@@ -13,7 +13,7 @@ Notable changes to the kit, newest first.
## 2026-08-10
-- **Identity: password-reset and e-mail-confirmation links now resolve the correct front-end per request.** The reset link was built from a single configured `OriginOptions.OriginUrl` - which points at the API and ships empty in production, so `forgot-password` threw `Origin URL is not configured` - and the confirmation link was built from the request host and pointed straight at the API's `GET /confirm-email` route. Neither could target the right SPA when the kit serves more than one front-end (the admin console and the tenant dashboard on different origins). Link resolution now goes through a dedicated **`FrontendOptions`** (`AllowedOrigins` + `DefaultOrigin`), kept separate from CORS. **Self-service** flows (`forgot-password`, `self-register`) build the link from the request `Origin` header, validated against `FrontendOptions:AllowedOrigins` and returned as the canonical entry - so each user gets a link back to the app they started from; because forgot-password is anonymous a forged or unlisted `Origin` is rejected with **`400`**, and a request with no `Origin` (curl, Scalar, mobile, server-to-server) falls back to `DefaultOrigin`. **Operator-driven** flows (`register`, `resend-confirmation-email`) target `DefaultOrigin` - the recipient's app - so a tenant user provisioned from the admin console gets a link into the tenant app, not the console. The confirmation e-mail now lands on the SPA `/confirm-email` page (which then calls the API) instead of the raw API route. **Action for deployments:** set `FrontendOptions:DefaultOrigin` to your tenant SPA, and list every SPA origin in `FrontendOptions:AllowedOrigins`. Both ship empty in production; **the host still starts without them** and logs a startup `Warning`, falling back to the API's own origin (`OriginOptions:OriginUrl`) for these links - serviceable, but not where your users expect to land. `CorsOptions:AllowedOrigins` and `OriginOptions:OriginUrl` keep their own roles (browser CORS; the API's public base for avatar URLs). See [#1323](https://github.com/fullstackhero/dotnet-starter-kit/pull/1323).
+- **Identity: password-reset and e-mail-confirmation links now resolve the correct front-end per request.** The reset link was built from a single configured `OriginOptions.OriginUrl` - which points at the API and ships empty in production, so `forgot-password` threw `Origin URL is not configured` - and the confirmation link was built from the request host and pointed straight at the API's `GET /confirm-email` route. Neither could target the right SPA when the kit serves more than one front-end (the admin console and the tenant dashboard on different origins). Link resolution now goes through a dedicated **`FrontendOptions`** (`AllowedOrigins` + `DefaultOrigin`), kept separate from CORS. **Self-service** flows (`forgot-password`, `self-register`) build the link from the request `Origin` header, validated against `FrontendOptions:AllowedOrigins` and returned as the canonical entry - so each user gets a link back to the app they started from; because forgot-password is anonymous a forged or unlisted `Origin` is rejected with **`400`**, and a request with no `Origin` (curl, Scalar, mobile, server-to-server) falls back to `DefaultOrigin`. **Operator-driven** flows (`register`, `resend-confirmation-email`) target `DefaultOrigin` - the recipient's app - so a tenant user provisioned from the admin console gets a link into the tenant app, not the console. The confirmation e-mail now lands on the SPA `/confirm-email` page (which then calls the API) instead of the raw API route. **Action for deployments:** set `FrontendOptions:DefaultOrigin` to your tenant SPA, and list every SPA origin in `FrontendOptions:AllowedOrigins`. Both ship empty in production; **the host still starts without them** and logs a startup `Warning`, falling back to the API's own origin for these links - `OriginOptions:OriginUrl`, or the request host when that is empty too - which is serviceable, and where confirmation links pointed before, but not where your users expect to land. `CorsOptions:AllowedOrigins` and `OriginOptions:OriginUrl` keep their own roles (browser CORS; the API's public base for avatar URLs). See [#1323](https://github.com/fullstackhero/dotnet-starter-kit/pull/1323).
## 2026-07-13
diff --git a/src/content/docs/modules/identity.mdx b/src/content/docs/modules/identity.mdx
index 7118b804..e26591a2 100644
--- a/src/content/docs/modules/identity.mdx
+++ b/src/content/docs/modules/identity.mdx
@@ -1,6 +1,6 @@
---
title: Identity module
-lastUpdated: 2026-07-02
+lastUpdated: 2026-08-10
description: JWT bearer + refresh tokens, ASP.NET Identity with roles + permissions, user groups, operator impersonation, two-factor TOTP, sessions, and password-policy enforcement.
sidebar:
label: Identity
@@ -150,7 +150,7 @@ endpoints.MapPost("/users", handler)
All 51 endpoints are under `/api/v1/identity/`. The rate-limited `auth` policy covers `POST /token/issue`, `POST /token/refresh`, `GET /confirm-email`, `POST /users/{id}/resend-confirmation-email`, `POST /forgot-password`, `POST /reset-password`, and `POST /self-register`. Full table:
-Auth e-mail links resolve through `FrontendOptions` (a dedicated config, separate from CORS). **Self-service** flows (`forgot-password`, `self-register`) link back to the front-end that made the request - the base URL comes from the request `Origin` header, validated against `FrontendOptions:AllowedOrigins` - so with more than one SPA each user gets a link to the app they started from; a forged or unlisted origin is rejected with `400`, and a request with no `Origin` (non-browser callers) falls back to `FrontendOptions:DefaultOrigin`. **Operator-driven** flows (`register`, `resend-confirmation-email`) target `DefaultOrigin` (the recipient's app), so a tenant user provisioned from the admin console gets a link into the tenant app, not the console. The confirmation link lands on the SPA `/confirm-email` page (which then calls `GET /confirm-email`), not the API route directly. Left unset, the host still boots and these links fall back to the API's own origin, which is rarely what you want - configure it, see [CORS & headers](/docs/security/cors-and-headers/).
+Auth e-mail links resolve through `FrontendOptions` (a dedicated config, separate from CORS). **Self-service** flows (`forgot-password`, `self-register`) link back to the front-end that made the request - the base URL comes from the request `Origin` header, validated against `FrontendOptions:AllowedOrigins` - so with more than one SPA each user gets a link to the app they started from; a forged or unlisted origin is rejected with `400`, and a request with no `Origin` (non-browser callers) falls back to `FrontendOptions:DefaultOrigin`. **Operator-driven** flows (`register`, `resend-confirmation-email`) target `DefaultOrigin` (the recipient's app), so a tenant user provisioned from the admin console gets a link into the tenant app, not the console. The confirmation link lands on the SPA `/confirm-email` page (which then calls `GET /confirm-email`), not the API route directly. Left unset, the host still boots and these links fall back to the API's own origin (`OriginOptions:OriginUrl`, or the request host), which is rarely what you want - configure it, see [CORS & headers](/docs/security/cors-and-headers/).
| Verb | Route | What it does |
diff --git a/src/content/docs/security/cors-and-headers.mdx b/src/content/docs/security/cors-and-headers.mdx
index 088a6480..e1bbf2a4 100644
--- a/src/content/docs/security/cors-and-headers.mdx
+++ b/src/content/docs/security/cors-and-headers.mdx
@@ -1,6 +1,6 @@
---
title: CORS & security headers
-lastUpdated: 2026-06-11
+lastUpdated: 2026-08-10
description: CORS-before-HTTPS-redirect ordering, the SignalR-credentialed-CORS gotcha, and the production security headers the kit emits by default.
sidebar:
label: CORS & headers
@@ -54,6 +54,24 @@ Pipeline order (relevant slice):
6. ...
```
+## 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).
+
+```jsonc
+ "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 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.
+- **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.
+- 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.
+- `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.
+
+(`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`.)
+
## 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.
@@ -126,23 +144,6 @@ services.ConfigureApplicationCookie(o =>
The cookie should be HttpOnly (no JS access - limits XSS impact), Secure (HTTPS only), and SameSite=Strict for the strongest CSRF defence.
-## 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).
-
-```jsonc
- "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 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.
-- **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.
-- 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.
-
-(`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`.)
-
## Common mistakes
- **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).
diff --git a/src/content/docs/security/production-checklist.mdx b/src/content/docs/security/production-checklist.mdx
index fff47e8b..c4856bdf 100644
--- a/src/content/docs/security/production-checklist.mdx
+++ b/src/content/docs/security/production-checklist.mdx
@@ -1,6 +1,6 @@
---
title: Production security checklist
-lastUpdated: 2026-06-11
+lastUpdated: 2026-08-10
description: Ten configuration items you must check before shipping fullstackhero to production. Skip none.
sidebar:
label: Production checklist
@@ -59,7 +59,7 @@ Adjust for your industry. Healthcare (HIPAA) and finance (PCI-DSS) tend to requi
`CorsOptions:AllowAll = true` (and the `SetIsOriginAllowed(_ => true)` policy it enables) is **dev only**. Production needs the explicit lists - and note that `appsettings.Production.json` ships `AllowedOrigins` empty, which means **no CORS middleware mounts at all** until you fill it in; your front-ends on other origins will be blocked by the browser. See [CORS & security headers](/docs/security/cors-and-headers/).
-Separately, set **`FrontendOptions`** (`AllowedOrigins` + `DefaultOrigin`) - the allowlist and fallback the Identity module uses to build password-reset and e-mail-confirmation links. It ships empty in production too. The host still boots without it, logging a startup `Warning` and falling back to the API's own origin (`OriginOptions:OriginUrl`), so auth e-mails keep arriving but their links point at the API instead of your SPA. `DefaultOrigin` is the tenant SPA: it's the fallback for non-browser callers and the target for operator-driven register/resend links.
+Separately, set **`FrontendOptions`** (`AllowedOrigins` + `DefaultOrigin`) - the allowlist and fallback the Identity module uses to build password-reset and e-mail-confirmation links. It ships empty in production too. The host still boots without it, logging a startup `Warning` and falling back to the API's own origin (`OriginOptions:OriginUrl`, or the request host when that is empty as well), so auth e-mails keep arriving but their links point at the API instead of your SPA. `DefaultOrigin` is the tenant SPA: it's the fallback for non-browser callers and the target for operator-driven register/resend links.
```jsonc
{
From 1e3f386f4602cb4445bbc6345daa250d92035a03 Mon Sep 17 00:00:00 2001
From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com>
Date: Mon, 10 Aug 2026 01:34:47 -0300
Subject: [PATCH 4/5] docs(security): document the empty-allowlist path for
auth e-mail links
An empty FrontendOptions:AllowedOrigins resolves through DefaultOrigin rather
than rejecting the request, so the single-SPA and reverse-proxy setups work on
the default alone.
---
src/content/docs/changelog/index.mdx | 2 +-
src/content/docs/modules/identity.mdx | 2 +-
src/content/docs/security/cors-and-headers.mdx | 3 ++-
3 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/src/content/docs/changelog/index.mdx b/src/content/docs/changelog/index.mdx
index 66a96cc2..01f65aea 100644
--- a/src/content/docs/changelog/index.mdx
+++ b/src/content/docs/changelog/index.mdx
@@ -13,7 +13,7 @@ Notable changes to the kit, newest first.
## 2026-08-10
-- **Identity: password-reset and e-mail-confirmation links now resolve the correct front-end per request.** The reset link was built from a single configured `OriginOptions.OriginUrl` - which points at the API and ships empty in production, so `forgot-password` threw `Origin URL is not configured` - and the confirmation link was built from the request host and pointed straight at the API's `GET /confirm-email` route. Neither could target the right SPA when the kit serves more than one front-end (the admin console and the tenant dashboard on different origins). Link resolution now goes through a dedicated **`FrontendOptions`** (`AllowedOrigins` + `DefaultOrigin`), kept separate from CORS. **Self-service** flows (`forgot-password`, `self-register`) build the link from the request `Origin` header, validated against `FrontendOptions:AllowedOrigins` and returned as the canonical entry - so each user gets a link back to the app they started from; because forgot-password is anonymous a forged or unlisted `Origin` is rejected with **`400`**, and a request with no `Origin` (curl, Scalar, mobile, server-to-server) falls back to `DefaultOrigin`. **Operator-driven** flows (`register`, `resend-confirmation-email`) target `DefaultOrigin` - the recipient's app - so a tenant user provisioned from the admin console gets a link into the tenant app, not the console. The confirmation e-mail now lands on the SPA `/confirm-email` page (which then calls the API) instead of the raw API route. **Action for deployments:** set `FrontendOptions:DefaultOrigin` to your tenant SPA, and list every SPA origin in `FrontendOptions:AllowedOrigins`. Both ship empty in production; **the host still starts without them** and logs a startup `Warning`, falling back to the API's own origin for these links - `OriginOptions:OriginUrl`, or the request host when that is empty too - which is serviceable, and where confirmation links pointed before, but not where your users expect to land. `CorsOptions:AllowedOrigins` and `OriginOptions:OriginUrl` keep their own roles (browser CORS; the API's public base for avatar URLs). See [#1323](https://github.com/fullstackhero/dotnet-starter-kit/pull/1323).
+- **Identity: password-reset and e-mail-confirmation links now resolve the correct front-end per request.** The reset link was built from a single configured `OriginOptions.OriginUrl` - which points at the API and ships empty in production, so `forgot-password` threw `Origin URL is not configured` - and the confirmation link was built from the request host and pointed straight at the API's `GET /confirm-email` route. Neither could target the right SPA when the kit serves more than one front-end (the admin console and the tenant dashboard on different origins). Link resolution now goes through a dedicated **`FrontendOptions`** (`AllowedOrigins` + `DefaultOrigin`), kept separate from CORS. **Self-service** flows (`forgot-password`, `self-register`) build the link from the request `Origin` header, validated against `FrontendOptions:AllowedOrigins` and returned as the canonical entry - so each user gets a link back to the app they started from; because forgot-password is anonymous a forged or unlisted `Origin` is rejected with **`400`** once the list is non-empty, and a request with no `Origin` (curl, Scalar, mobile, server-to-server) falls back to `DefaultOrigin` - as does every request while the list is empty, since there is then nothing to validate against. **Operator-driven** flows (`register`, `resend-confirmation-email`) target `DefaultOrigin` - the recipient's app - so a tenant user provisioned from the admin console gets a link into the tenant app, not the console. The confirmation e-mail now lands on the SPA `/confirm-email` page (which then calls the API) instead of the raw API route. **Action for deployments:** set `FrontendOptions:DefaultOrigin` to your tenant SPA, and list every SPA origin in `FrontendOptions:AllowedOrigins`. Both ship empty in production; **the host still starts without them** and logs a startup `Warning`, falling back to the API's own origin for these links - `OriginOptions:OriginUrl`, or the request host when that is empty too - which is serviceable, and where confirmation links pointed before, but not where your users expect to land. `CorsOptions:AllowedOrigins` and `OriginOptions:OriginUrl` keep their own roles (browser CORS; the API's public base for avatar URLs). See [#1323](https://github.com/fullstackhero/dotnet-starter-kit/pull/1323).
## 2026-07-13
diff --git a/src/content/docs/modules/identity.mdx b/src/content/docs/modules/identity.mdx
index e26591a2..6e5b50b7 100644
--- a/src/content/docs/modules/identity.mdx
+++ b/src/content/docs/modules/identity.mdx
@@ -150,7 +150,7 @@ endpoints.MapPost("/users", handler)
All 51 endpoints are under `/api/v1/identity/`. The rate-limited `auth` policy covers `POST /token/issue`, `POST /token/refresh`, `GET /confirm-email`, `POST /users/{id}/resend-confirmation-email`, `POST /forgot-password`, `POST /reset-password`, and `POST /self-register`. Full table:
-Auth e-mail links resolve through `FrontendOptions` (a dedicated config, separate from CORS). **Self-service** flows (`forgot-password`, `self-register`) link back to the front-end that made the request - the base URL comes from the request `Origin` header, validated against `FrontendOptions:AllowedOrigins` - so with more than one SPA each user gets a link to the app they started from; a forged or unlisted origin is rejected with `400`, and a request with no `Origin` (non-browser callers) falls back to `FrontendOptions:DefaultOrigin`. **Operator-driven** flows (`register`, `resend-confirmation-email`) target `DefaultOrigin` (the recipient's app), so a tenant user provisioned from the admin console gets a link into the tenant app, not the console. The confirmation link lands on the SPA `/confirm-email` page (which then calls `GET /confirm-email`), not the API route directly. Left unset, the host still boots and these links fall back to the API's own origin (`OriginOptions:OriginUrl`, or the request host), which is rarely what you want - configure it, see [CORS & headers](/docs/security/cors-and-headers/).
+Auth e-mail links resolve through `FrontendOptions` (a dedicated config, separate from CORS). **Self-service** flows (`forgot-password`, `self-register`) link back to the front-end that made the request - the base URL comes from the request `Origin` header, validated against `FrontendOptions:AllowedOrigins` - so with more than one SPA each user gets a link to the app they started from; a forged or unlisted origin is rejected with `400`, and a request with no `Origin` (non-browser callers) falls back to `FrontendOptions:DefaultOrigin` - as does every request when the allowlist is empty, since there is then nothing to validate against. **Operator-driven** flows (`register`, `resend-confirmation-email`) target `DefaultOrigin` (the recipient's app), so a tenant user provisioned from the admin console gets a link into the tenant app, not the console. The confirmation link lands on the SPA `/confirm-email` page (which then calls `GET /confirm-email`), not the API route directly. Left unset, the host still boots and these links fall back to the API's own origin (`OriginOptions:OriginUrl`, or the request host), which is rarely what you want - configure it, see [CORS & headers](/docs/security/cors-and-headers/).
| Verb | Route | What it does |
diff --git a/src/content/docs/security/cors-and-headers.mdx b/src/content/docs/security/cors-and-headers.mdx
index e1bbf2a4..61a08bee 100644
--- a/src/content/docs/security/cors-and-headers.mdx
+++ b/src/content/docs/security/cors-and-headers.mdx
@@ -65,7 +65,8 @@ Links that land on a front-end SPA - the password-reset and e-mail-confirmation
}
```
-- **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.
+- **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: once the list is non-empty, 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.
+- With `AllowedOrigins` **empty** there is nothing to validate against, so the header is discarded and the link uses `DefaultOrigin`. That keeps the single-SPA and reverse-proxy setups working on `DefaultOrigin` alone - browsers attach `Origin` to 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`) 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.
- 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.
- `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.
From 45a5c0a026d6dfdcaeb7349d27a41107aec5ab89 Mon Sep 17 00:00:00 2001
From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com>
Date: Mon, 10 Aug 2026 14:32:29 -0300
Subject: [PATCH 5/5] docs(security): the Scalar try-it UI does send an Origin
header
It fetches from the browser rather than through a proxy, so it does not belong
in the list of callers that fall back to DefaultOrigin.
---
src/content/docs/changelog/index.mdx | 2 +-
src/content/docs/security/cors-and-headers.mdx | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/content/docs/changelog/index.mdx b/src/content/docs/changelog/index.mdx
index 01f65aea..9be97bba 100644
--- a/src/content/docs/changelog/index.mdx
+++ b/src/content/docs/changelog/index.mdx
@@ -13,7 +13,7 @@ Notable changes to the kit, newest first.
## 2026-08-10
-- **Identity: password-reset and e-mail-confirmation links now resolve the correct front-end per request.** The reset link was built from a single configured `OriginOptions.OriginUrl` - which points at the API and ships empty in production, so `forgot-password` threw `Origin URL is not configured` - and the confirmation link was built from the request host and pointed straight at the API's `GET /confirm-email` route. Neither could target the right SPA when the kit serves more than one front-end (the admin console and the tenant dashboard on different origins). Link resolution now goes through a dedicated **`FrontendOptions`** (`AllowedOrigins` + `DefaultOrigin`), kept separate from CORS. **Self-service** flows (`forgot-password`, `self-register`) build the link from the request `Origin` header, validated against `FrontendOptions:AllowedOrigins` and returned as the canonical entry - so each user gets a link back to the app they started from; because forgot-password is anonymous a forged or unlisted `Origin` is rejected with **`400`** once the list is non-empty, and a request with no `Origin` (curl, Scalar, mobile, server-to-server) falls back to `DefaultOrigin` - as does every request while the list is empty, since there is then nothing to validate against. **Operator-driven** flows (`register`, `resend-confirmation-email`) target `DefaultOrigin` - the recipient's app - so a tenant user provisioned from the admin console gets a link into the tenant app, not the console. The confirmation e-mail now lands on the SPA `/confirm-email` page (which then calls the API) instead of the raw API route. **Action for deployments:** set `FrontendOptions:DefaultOrigin` to your tenant SPA, and list every SPA origin in `FrontendOptions:AllowedOrigins`. Both ship empty in production; **the host still starts without them** and logs a startup `Warning`, falling back to the API's own origin for these links - `OriginOptions:OriginUrl`, or the request host when that is empty too - which is serviceable, and where confirmation links pointed before, but not where your users expect to land. `CorsOptions:AllowedOrigins` and `OriginOptions:OriginUrl` keep their own roles (browser CORS; the API's public base for avatar URLs). See [#1323](https://github.com/fullstackhero/dotnet-starter-kit/pull/1323).
+- **Identity: password-reset and e-mail-confirmation links now resolve the correct front-end per request.** The reset link was built from a single configured `OriginOptions.OriginUrl` - which points at the API and ships empty in production, so `forgot-password` threw `Origin URL is not configured` - and the confirmation link was built from the request host and pointed straight at the API's `GET /confirm-email` route. Neither could target the right SPA when the kit serves more than one front-end (the admin console and the tenant dashboard on different origins). Link resolution now goes through a dedicated **`FrontendOptions`** (`AllowedOrigins` + `DefaultOrigin`), kept separate from CORS. **Self-service** flows (`forgot-password`, `self-register`) build the link from the request `Origin` header, validated against `FrontendOptions:AllowedOrigins` and returned as the canonical entry - so each user gets a link back to the app they started from; because forgot-password is anonymous a forged or unlisted `Origin` is rejected with **`400`** once the list is non-empty, and a request with no `Origin` (curl, mobile, server-to-server) falls back to `DefaultOrigin` - as does every request while the list is empty, since there is then nothing to validate against. **Operator-driven** flows (`register`, `resend-confirmation-email`) target `DefaultOrigin` - the recipient's app - so a tenant user provisioned from the admin console gets a link into the tenant app, not the console. The confirmation e-mail now lands on the SPA `/confirm-email` page (which then calls the API) instead of the raw API route. **Action for deployments:** set `FrontendOptions:DefaultOrigin` to your tenant SPA, and list every SPA origin in `FrontendOptions:AllowedOrigins`. Both ship empty in production; **the host still starts without them** and logs a startup `Warning`, falling back to the API's own origin for these links - `OriginOptions:OriginUrl`, or the request host when that is empty too - which is serviceable, and where confirmation links pointed before, but not where your users expect to land. `CorsOptions:AllowedOrigins` and `OriginOptions:OriginUrl` keep their own roles (browser CORS; the API's public base for avatar URLs). See [#1323](https://github.com/fullstackhero/dotnet-starter-kit/pull/1323).
## 2026-07-13
diff --git a/src/content/docs/security/cors-and-headers.mdx b/src/content/docs/security/cors-and-headers.mdx
index 61a08bee..eff2011a 100644
--- a/src/content/docs/security/cors-and-headers.mdx
+++ b/src/content/docs/security/cors-and-headers.mdx
@@ -65,7 +65,7 @@ Links that land on a front-end SPA - the password-reset and e-mail-confirmation
}
```
-- **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: once the list is non-empty, 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.
+- **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: once the list is non-empty, a **forged or unlisted `Origin` is rejected with `400`** rather than turned into a link. A request with **no** `Origin` header (curl, mobile, server-to-server) falls back to `DefaultOrigin` instead 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 to `AllowedOrigins` if you want to exercise these two endpoints from the docs UI.
- With `AllowedOrigins` **empty** there is nothing to validate against, so the header is discarded and the link uses `DefaultOrigin`. That keeps the single-SPA and reverse-proxy setups working on `DefaultOrigin` alone - browsers attach `Origin` to 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`) 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.
- 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.