diff --git a/src/content/docs/changelog/index.mdx b/src/content/docs/changelog/index.mdx
index 180d8b52..01660e80 100644
--- a/src/content/docs/changelog/index.mdx
+++ b/src/content/docs/changelog/index.mdx
@@ -1,6 +1,6 @@
---
title: Overview
-lastUpdated: 2026-06-20
+lastUpdated: 2026-07-03
description: Release notes and version history for fullstackhero.
sidebar:
order: 1
@@ -11,6 +11,10 @@ seo:
Notable changes to the kit, newest first.
+## 2026-07-03
+
+- **Identity: auth e-mail links now resolve to the front-end the request came from, and `CorsOptions.AllowedOrigins` is their allow-list (fix).** The kit ships two SPAs (admin on `:5173`, dashboard on `:5174`), but the back-end had no way to build a user-facing link that targets the app a request actually came from. `forgot-password` built the reset link from a single configured `OriginOptions.OriginUrl` — the API URL in `appsettings.json`, empty in `appsettings.Production.json`, so the handler threw `"Origin URL is not configured."` — and register / self-register / resend-confirmation built the confirmation link from the raw request host and pointed it at the API's `confirm-email` route (which returns JSON) rather than a front-end page. A small `IOriginResolver` (Identity module, no `BuildingBlocks` change) now resolves this per request: **`FrontendOrigin()`** validates the request `Origin` header against `CorsOptions.AllowedOrigins` (normalising trailing slash and case, matching scheme + host + port exactly) and is used for links that land on a SPA, while **`ApiOrigin()`** keeps the previous configured-origin-then-request-host behaviour for API-served assets like avatar URLs. The confirmation e-mail now points at the SPA `/confirm-email` page (present in both apps, which then calls the API). **Action required for deployments:** populate `CorsOptions.AllowedOrigins` with your real SPA URLs — the resolver validates against it **independently of `CorsOptions.AllowAll`**, so an empty list makes `forgot-password`, `register`, `self-register`, and `resend-confirmation-email` reject the request rather than turn a forged `Origin` into a link inside an e-mail. `appsettings.json` now lists the dev origins (`http://localhost:5173`, `http://localhost:5174`); `appsettings.Production.json` intentionally leaves the list empty. This is the structural follow-up to the earlier reset-link string-format fix. See [Identity → origin resolution](/docs/modules/identity/) and [CORS & headers](/docs/security/cors-and-headers/).
+
## 2026-06-20
- **The `fsh` CLI and `dotnet new` template are now on NuGet as stable `10.0.0`.** The two distribution packages that 10.0.0 had been waiting on have shipped: `FullStackHero.CLI` (install with `dotnet tool install -g FullStackHero.CLI` — no more `--prerelease`) and `FullStackHero.NET.StarterKit` (`dotnet new install FullStackHero.NET.StarterKit`). Because `fsh new` scaffolds *from* that template, the one-command flow is now end-to-end: `dotnet tool install -g FullStackHero.CLI && fsh new MyApp` produces a fully renamed project — unique JWT signing key, generated Docker secrets, `npm install` run, initial commit on `main`. The [Install](/docs/getting-started/install/) and [CLI](/docs/cli/) pages now lead with the CLI as the recommended path; `git clone` and the GitHub template remain available for reading the source or zero-install runs. See the [10.0.0 release](https://github.com/fullstackhero/dotnet-starter-kit/releases/tag/10.0.0).
diff --git a/src/content/docs/modules/identity.mdx b/src/content/docs/modules/identity.mdx
index f2620fe7..744bb204 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-03
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
@@ -228,6 +228,19 @@ Yes, the role-permission routes really are `/{id}/permissions` directly under `/
Lockout is configured by `IdentityOptions` and defaults to 5 failed attempts → 15-minute lock. Outbox events are dispatched by the framework's `OutboxDispatcherHostedService` (on by default) — the module deliberately registers no dispatcher of its own, since a second one would race the same rows.
+### Where auth e-mail links point (origin resolution)
+
+Confirmation and password-reset e-mails link to a **front-end** page, not to the API. Because the kit ships two SPAs (admin on `:5173`, dashboard on `:5174`), each link has to target the app the request actually came from. A small `IOriginResolver` in the Identity module resolves this per request, with two distinct notions of origin:
+
+- **`FrontendOrigin()`** — reads the request's `Origin` header and validates it against `CorsOptions.AllowedOrigins`, normalising trailing slash and case and matching scheme + host + port exactly. Used for links that land on a SPA (reset-password and e-mail confirmation). It **throws** when the request carries no allow-listed origin rather than guessing a front-end.
+- **`ApiOrigin()`** — the configured `OriginOptions.OriginUrl`, or the request host when that is empty. Used for links and assets served by the API itself, such as avatar URLs and `RequestContextService`. `OriginUrl` is the API's public base — it is no longer overloaded as the reset-link base.
+
+The e-mail-confirmation link now points at the SPA `/confirm-email` page — present in both `clients/admin` and `clients/dashboard`, which then calls the API — instead of the API route directly.
+
+
+The allow-list check is a security boundary, not just a CORS convenience. `forgot-password` is anonymous, so a forged `Origin` header must never be turned into a link inside an e-mail. The resolver always validates against `CorsOptions.AllowedOrigins` — **independently of `CorsOptions.AllowAll`** — and rejects anything not on the list. A deployment that leaves `AllowedOrigins` empty makes `forgot-password`, `register`, `self-register`, and `resend-confirmation-email` **reject the request** (no allow-listed front-end origin), not merely fail browser CORS. Populate it with your real SPA URLs. See [CORS & headers](/docs/security/cors-and-headers/).
+
+
## How to extend
### Add a new permission
diff --git a/src/content/docs/security/cors-and-headers.mdx b/src/content/docs/security/cors-and-headers.mdx
index 2ec2f00b..bdc6fb9d 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-07-03
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
@@ -41,6 +41,10 @@ CORS in fullstackhero has two non-default conventions: **CORS middleware runs be
- Credentials are always allowed by the policy — there is no `AllowCredentials` config key.
- If `AllowAll` is false **and** `AllowedOrigins` is empty, CORS isn't mounted at all — cross-origin browser calls will simply fail. (`appsettings.Production.json` ships with an empty list precisely so you have to fill it in.)
+
+`AllowedOrigins` is no longer only the browser CORS allow-list. Identity's origin resolver validates the request `Origin` header against it — **independently of `AllowAll`** — to decide which front-end a password-reset or e-mail-confirmation link points at. Because `forgot-password` is anonymous, this is the security boundary that stops a forged `Origin` from being turned into a link inside an e-mail. A deployment with an empty `AllowedOrigins` makes `forgot-password`, `register`, `self-register`, and `resend-confirmation-email` **reject the request**, not just fail browser CORS. See [Identity → origin resolution](/docs/modules/identity/).
+
+
`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):
@@ -129,7 +133,7 @@ The cookie should be HttpOnly (no JS access — limits XSS impact), Secure (HTTP
## 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". It now also breaks auth e-mail links: `forgot-password` and `register` reject the request because the origin resolver finds no allow-listed front-end (see the callout above).
- **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.