# Scaffolding Yarp Proxy Project

> Scaffolds a new ASP.NET Core project with YARP reverse proxy alongside an existing .NET Framework MVC or WebAPI project for incremental side-by-side migration. Use when a migration task requires creating a new Core project that proxies to the old Framework app, when the side-by-side migration approach is selected, or when scaffold/YARP/proxy setup is needed. Also handles authentication interop between the two apps (shared cookie or remote authentication) so users stay signed in across both. Also triggers for "create new Core project", "set up YARP proxy", "side-by-side project setup", "share login between old and new app", "user appears signed out after migration".

- Skill: `microsoft/scaffolding-yarp-proxy-project` (Agent Skill, multi-file: 15 files)
- Install (CLI): `npx skillmds@latest add microsoft/scaffolding-yarp-proxy-project`
- Raw SKILL.md: https://api.skillmd.com/api/skills/microsoft/scaffolding-yarp-proxy-project/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: Microsoft (https://skillmd.com/u/microsoft)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/microsoft/scaffolding-yarp-proxy-project

---


# Scaffold ASP.NET Core Project with YARP Proxy

Creates a new ASP.NET Core web project alongside an existing .NET Framework
MVC or WebAPI project. The new project is configured with a YARP reverse proxy
that routes unhandled requests to the old project, enabling incremental
controller-by-controller migration.

> **Scope — .NET Framework → Core only.** This scaffold exists for *side-by-side incremental
> migration*: it adds `Microsoft.AspNetCore.SystemWebAdapters.CoreServices` and the
> `_MigrateToProjectGuid` link so a new Core app can front a **still-running .NET Framework**
> app. Do **not** run it for a Core-to-Core version upgrade (e.g. `net8.0` → `net10.0`) — there
> is no `System.Web` to adapt and no second app to strangle, so it would add meaningless
> dependencies and a bogus migration marker. Retarget the TFM in place instead. The
> **Production hardening** below is generic ASP.NET Core guidance that applies to any app behind
> a proxy; only the **Framework-side companion** is Framework-specific.
>
> Equally, do **not** run it for an in-place .NET Framework retarget (e.g. `net472` → `net48`).
> That upgrade produces no second app, and the proxy host itself must be ASP.NET Core — YARP and
> `SystemWebAdapters.CoreServices` have no .NET Framework target. In this scaffold the Framework
> app is the proxy's *backend* (`-OldAppUrl`), never its host.

## REQUIRED: Read This File Completely

This file contains **2 steps** and **10 sub-steps** for manual scaffolding. You MUST read all sections before starting:

| Step | Section | What It Covers |
|------|---------|----------------|
| 1 | Choose the scaffolding path | VS uses the tool; every other host uses the script |
| 2 | Scaffold Using Script + Templates | Primary path — script + template files |
| 2.1 | Gather Parameters | Paths, TFM, URLs, package versions, auth interop switches |
| 2.2 | Run the Script | Script copies templates, adds to solution, links projects |
| 2.3 | If Script Fails | Manual fallback — copy templates, replace placeholders |
| - | Authentication interop | Keeping users signed in across both apps while they run side by side |
| - | Production hardening | **Required** — forwarded headers, TLS, `UseAuthentication`, Framework-side companion |
| - | Template Files Reference | What each template contains, and the marker-kind matrix |
| - | Success Criteria | Final checklist |

**Do not stop reading after Step 1.** Step 1 only selects the host-appropriate mechanism;
the parameters, the marker rules, and the hardening all live in Step 2 and below.

## Prerequisites

Before using this skill, you need:
- Path to the **old .NET Framework web project** (.csproj)
- Path to the **solution file** (.sln or .slnx) containing it
- **Target framework** for the new project (e.g., `net10.0`)
- **Project type**: MVC or WebAPI
- **New project name** (default: `{OldProjectName}.Core`)

## Step 1: Choose the scaffolding path for this host

Which mechanism is available depends on **where you are running**, and the split is
structural rather than a fallback:

- **Visual Studio.** The `scaffold_yarp_proxy_web_project` tool is available. It handles
  the mechanical work automatically:

  ```
  scaffold_yarp_proxy_web_project(
    solutionPath="{solution_path}",
    projectPath="{old_project_path}",
    targetFramework="{tfm}",
    targetProjectName="{new_name}",
    projectType="{MVC|WebAPI}",
    authInterop="{none|sharedcookie|remoteauth}",
    sharedKeyRingProvider="{filesystem|azureblob}"
  )
  ```

  `authInterop` selects which authentication interop path to pre-wire, and defaults to
  `none`. Ask the user which they need before calling — see **Authentication interop**
  below for how to choose. `sharedKeyRingProvider` applies only to `sharedcookie`, and is
  rejected with any other mode; it selects where the shared Data Protection key ring lives
  and therefore which `SharedDP:*` keys are emitted. Two limits are specific to this path:

  - **`sharedcookie` and `remoteauth` require `targetFramework` net10.0 or later.** The
    tool rejects the combination up front rather than scaffolding a project with the
    parameter silently dropped. Below net10.0, scaffold with `none` and wire the interop by
    hand per **Authentication interop**.
  - **The tool writes the configuration keys blank.** It has no way to ask for the cookie
    name, key ring location, certificate thumbprint, or API key, so it emits the keys in
    `appsettings.json` for the user to fill in. Every blank value fails loudly rather than
    appearing to work, but *when* it fails differs by mode: `sharedcookie` values are read
    eagerly while the host is built, so a blank one throws at startup; `remoteauth` values sit
    inside a deferred options callback, so a blank one throws on the first request the proxy
    authenticates and the host starts clean until then. Tell the user which keys they must fill
    in — for `sharedcookie` that set depends on `sharedKeyRingProvider`.
    `scaffold-project.ps1` takes the values directly and is the better path when you have
    them.

- **Everywhere else (CLI, Copilot Chat outside VS).** The tool is not registered in this
  host, so **go to Step 2** and use `scaffold-project.ps1`. Do not probe for the tool
  first: it depends on Visual Studio services that only exist inside the VS process, so
  calling it here cannot succeed. If it is somehow reachable, its failure message names the
  host as the likely cause and points back here.

`scaffold-project.ps1` is the more capable path — it takes the interop values directly, it
validates them, and it is not limited to net10.0.

## Step 2: Scaffold Using Script + Templates

This skill includes template files and a PowerShell script that handles the mechanical work.
The LLM handles the parts that need judgment (finding the old app URL, resolving package versions).

### 2.1 Gather Parameters

**Every parameter in the table below is mandatory.** The new project will not work correctly
with the old project unless every value is accurate. Do not use defaults without verifying
them. (The authentication interop parameters described after the table are optional as a
group — but once you turn one on, all of its companions are required.)

Before running the script, determine these values:

| Parameter | How to find it |
|-----------|---------------|
| `OldProjectPath` | Full path to the .NET Framework .csproj |
| `SolutionPath` | Full path to the .sln/.slnx file |
| `TargetFramework` | TFM of the **new proxy project**, not of the app being migrated. **Use `net10.0` or later** — the hardened templates use `ForwardedHeadersOptions.KnownIPNetworks`, which does not exist before ASP.NET Core 10. Below net10.0 the script still scaffolds, but strips the hardening and warns. A .NET Framework moniker (`net48`, `net472`, …) is rejected: the proxy host must be ASP.NET Core. See **Production hardening**. |
| `NewProjectName` | Name for new project (default: `{OldName}.Core`). Must be unique in the solution — check existing project names and folder names |
| `ProjectType` | `MVC` or `WebAPI` — match the old project's type |
| `OldAppUrl` | **Must be the actual URL the old app runs on.** Find it in the old project's `Properties/launchSettings.json` (look for `applicationUrl` in the active profile), or in IIS/IIS Express bindings. Do NOT guess — if the proxy points to the wrong URL, all forwarded requests will fail silently. |
| `SystemWebAdaptersVersion` | Use `get_supported_package_version` for `Microsoft.AspNetCore.SystemWebAdapters.CoreServices`. **With `-EnableRemoteAuth`, this must be `2.3.0` or newer** — see below. |
| `YarpVersion` | Use `get_supported_package_version` for `Yarp.ReverseProxy` |

**NewProjectName validation:**
- Must not match any existing project name in the solution
- The folder `{parent_of_old_project}/{NewProjectName}` must not already exist
- The script checks both conditions and fails with a clear error if violated
- The new project folder is always created as a **sibling** to the old project's folder

**Forwarded-headers parameters (optional, and net10.0+ only).** The templates ship fail-closed —
`TrustedProxies` and `AllowedHosts` empty, `TrustedNetworks` loopback-only — so the scaffold is safe
before anyone configures it and useless behind a real proxy until someone does. These set that trust
at scaffold time instead of by hand-editing `appsettings.json` afterwards. Below net10.0 the
hardening is stripped, so passing any of them is a **hard error** rather than a silent no-op: the
trust could not be honoured. See **Production hardening** for the full picture.

| Parameter | How to find it |
|-----------|---------------|
| `-TrustedProxies` | Addresses of the real reverse proxies/load balancers in front of this app. Ship at least one of this or `-TrustedNetworks` in production — both empty means forwarded headers are ignored and the app sees the proxy's IP as the client. |
| `-TrustedNetworks` | CIDR ranges to trust instead of individual addresses, e.g. `10.0.0.0/8`. |
| `-AllowedForwardedHosts` | Public hostname(s) the proxy may set via `X-Forwarded-Host`. Empty means the header is ignored, which shows up as links and redirects using the internal host. |

`-SkipBuild` generates the files without running `dotnet build`. It is not TFM-gated.

**Authentication interop parameters (optional, off by default).** Ask the user whether
signed-in users must stay signed in across both apps while the migration runs. If they do,
pick one path — the two are mutually exclusive and the script rejects both together:

| Parameter | Path | How to find it |
|-----------|------|---------------|
| `-EnableSharedCookieAuth` | Shared cookie | Both apps read the same encrypted cookie. Choose **only** when the old app authenticates with **Katana/OWIN cookie middleware** under `Microsoft.Owin.Host.SystemWeb`, and both apps can reach one shared Data Protection key ring. See the two preconditions below — neither is checked, and violating either produces a proxy that builds and signs nobody in. |
| `-SharedKeyRingProvider` | Shared cookie | Where the shared key ring lives: `filesystem` (default) or `azureblob`. **This must match how the old app already persists its keys** — ask, do not assume. It selects which of the two companion pairs below is required, and the wrong choice produces an app that starts and never decrypts. |
| `-SharedKeyRingPath` | Shared cookie, `filesystem` | Directory both apps can read (often a UNC share). **No default** — ask the user. |
| `-SharedCertificateThumbprint` | Shared cookie, `filesystem` | Thumbprint of the X.509 cert protecting the key ring. Both hosts need the private key. |
| `-SharedKeyRingUri` | Shared cookie, `azureblob` | Absolute `https` URI of the Azure Storage blob holding the ring, e.g. `https://acct.blob.core.windows.net/dp/keys.xml`. The container must exist; the blob need not. |
| `-SharedKeyVaultKeyId` | Shared cookie, `azureblob` | Absolute `https` URI of the Key Vault key protecting the ring, e.g. `https://vault.vault.azure.net/keys/dp/<version>`. |
| `-AzureDataProtectionBlobsVersion` | Shared cookie, `azureblob` | Optional. Version of `Azure.Extensions.AspNetCore.DataProtection.Blobs` to add to the generated project. Omit to take the script's verified default; pass `get_supported_package_version` for that package only if the default is unavailable to this customer. |
| `-AzureDataProtectionKeysVersion` | Shared cookie, `azureblob` | Optional. Same, for `Azure.Extensions.AspNetCore.DataProtection.Keys`. |
| `-SharedApplicationName` | Shared cookie | The old app's Data Protection application name. Must be identical on both sides. |
| `-SharedCookieName` | Shared cookie | The old app's Katana cookie name (`CookieAuthenticationOptions.CookieName`, often `.AspNet.ApplicationCookie`). Read it from the OWIN startup class or browser dev tools. **Never guess** — a wrong name means the user silently appears signed out. A `.ASPXAUTH` cookie means classic Forms authentication, which this path does **not** support; see the preconditions below. |
| `-SharedCookieScheme` | Shared cookie | The old app's `AuthenticationType` (Katana `CookieAuthenticationOptions.AuthenticationType`). |
| `-EnableRemoteAuth` | Remote auth | The new app asks the old app to authenticate each request. Choose when the old app uses **classic Forms authentication (`<forms>` in `Web.config`, `.ASPXAUTH`), Windows auth, a custom identity provider, or anything not Katana cookie-based**, or when the two apps cannot share a Data Protection key ring at all. |
| `-RemoteAppUrl` | Remote auth | Optional. Defaults to `-OldAppUrl`. Pass it only when the old app is reachable at a different address from the server. |
| `-RemoteAppApiKey` | Remote auth | A **GUID** shared with the old app. Generate with `[guid]::NewGuid()`. The script rejects a non-GUID. |

Every companion above is **required** when its switch is on and **rejected** when it is off, with
three exceptions, all marked *Optional* in the table: `-RemoteAppUrl` falls back to `-OldAppUrl`, and
the two `-AzureDataProtection*Version` parameters fall back to versions the script has verified
against the feed. Nothing else has a default, deliberately: a wrong cookie name, scheme, or key ring
produces an app that starts and serves traffic while authenticating nobody, with no error anywhere.
The same rule applies one level down — a `filesystem` companion passed with
`-SharedKeyRingProvider azureblob` is rejected, not ignored, and so is either version parameter
passed with the `filesystem` provider.

**Two preconditions on `-EnableSharedCookieAuth`. Neither is validated by the script, and each one
produces a proxy that compiles, starts, and silently signs nobody in.** Check both with the user
before choosing this path; if either fails, use `-EnableRemoteAuth` instead.

1. **The old app must authenticate with Katana/OWIN cookie middleware**, hosted under
   `Microsoft.Owin.Host.SystemWeb`. The emitted code reads a Data Protection ticket, and the
   Framework half is completed by the `sharing-authentication-cookies-katana-interop` skill, which
   requires `Microsoft.Owin.Security.Interop` and the `AspNetTicketDataFormat` shim. **Classic
   ASP.NET Forms authentication is not supported** — a `<forms>` element in `Web.config` and an
   `.ASPXAUTH` cookie mean a `machineKey`-encrypted `FormsAuthenticationTicket`, which ASP.NET Core
   cannot read no matter how the key ring is shared. Such an app must either move to Katana cookie
   middleware first, or use remote auth.
2. **The key ring topology must be one the scaffold emits, and `-SharedKeyRingProvider` must name
   the right one.** Two are supported:
   - `filesystem` — `PersistKeysToFileSystem(...).ProtectKeysWithCertificate(...)`. Both hosts need
     the directory and the certificate's private key.
   - `azureblob` — `PersistKeysToAzureBlobStorage(...).ProtectKeysWithAzureKeyVault(...)`, both
     authenticating with `DefaultAzureCredential`. Each host needs an identity granted **Storage
     Blob Data Contributor** on the blob and **Key Vault Crypto User** on the key. This is the shape
     a multi-instance or multi-slot deployment needs, because instances share the blob rather than a
     local disk.

   An app whose keys live somewhere else — **a database (`PersistKeysToDbContext`), Redis, or
   DPAPI-NG** — has no supported path through this scaffold: the generated `Program.cs` must be
   hand-edited to swap the persistence and protection calls. Confirm the topology before choosing
   this path.

Neither switch completes the job on its own — each configures only the ASP.NET Core half.
The script drops a `README.SHAREDCOOKIE.md` or `README.REMOTEAUTH.md` into the new project
with the .NET Framework half. See **Authentication interop** below.

### 2.2 Run the Script

The script copies template files from `tmpl/mvc/` or `tmpl/webapi/`, applies
variable substitutions (`$TargetFramework$`, `$ProjectName$`, `$OldAppUrl$`, etc.),
adds the project to the solution, links the old project via `_MigrateToProjectGuid`,
and verifies the build.

> **Invoke PowerShell explicitly, on one line, with `-Command` and the call operator.** The
> `execute` tool runs in the *user's* shell, which is often Git Bash or WSL rather than
> PowerShell. Running the `.ps1` by bare path only works if the shell happens to be
> PowerShell, and a PowerShell backtick continuation is **command substitution** in bash: an
> odd number of trailing backticks aborts with `unexpected EOF while looking for matching`,
> and an even number pairs up so the parameters run as commands, the script never runs, and
> the shell still **exits 0**. Never split this command across lines with backticks. Use
> `pwsh` instead of `powershell` on non-Windows hosts.
>
> **Use `-Command "& '<script>' …"`, not `-File`.** `-File` passes arguments as native
> strings, so an array parameter never receives more than one element: `-TrustedProxies
> "10.0.0.5","10.0.0.6"` binds as the single value `10.0.0.5,10.0.0.6`, the script writes one
> invalid address, and the generated `Program.cs` silently falls back to loopback **while the
> command reports success**. `-Command` makes PowerShell parse the arguments, so arrays bind
> correctly. Measured from PowerShell, cmd and Git Bash.

```text
powershell -NoProfile -ExecutionPolicy Bypass -Command "& 'C:\path\to\scaffold-project.ps1' -OldProjectPath '{OLD_PROJECT_PATH}' -SolutionPath '{SOLUTION_PATH}' -TargetFramework '{TFM}' -NewProjectName '{NEW_PROJECT_NAME}' -ProjectType '{MVC|WebAPI}' -OldAppUrl '{OLD_APP_URL}' -SystemWebAdaptersVersion '{VERSION}' -YarpVersion '{VERSION}'"
```

Use a **Windows** path for the script even from Git Bash (`C:\…`, not `/c/…`) — Windows
PowerShell cannot resolve a POSIX path.

To trust real proxy addresses at scaffold time (instead of the fail-closed loopback
defaults), also pass `-TrustedProxies` and/or `-TrustedNetworks`. To let the proxy set the
request host, pass `-AllowedForwardedHosts` — without it, `X-Forwarded-Host` is ignored
(see the spoofing footgun under **Production hardening**). These write the
`ForwardedHeaders` section of the generated `appsettings.json`.

Append to the same single-line command (single-quoted, comma-separated — `-Command` parses
these as a real array):

```text
-TrustedProxies '10.0.0.5','10.0.0.6' -TrustedNetworks '10.0.0.0/8','::1/128' -AllowedForwardedHosts 'www.example.com'
```

When omitted, the template keeps its secure defaults — loopback-only trust, and no
forwarded host honored — and an operator opts in later by editing `appsettings.json`.

To pre-wire authentication interop, append **one** of the following groups to the same
single-line command. Passing both groups, or any companion without its switch, is rejected.

Shared cookie — both apps read the same encrypted cookie. Filesystem key ring (the default),
a directory both hosts can read:

```text
-EnableSharedCookieAuth -SharedKeyRingPath '//fileserver/keyring' -SharedCertificateThumbprint 'A1B2C3...' -SharedApplicationName 'MyLegacyApp' -SharedCookieName '.AspNet.ApplicationCookie' -SharedCookieScheme 'ApplicationCookie'
```

> **Write a UNC key-ring path with forward slashes** (`//fileserver/keyring`), not
> `\\fileserver\keyring`. Git Bash collapses the leading `\\` to a single `\` before
> PowerShell ever sees it, producing a path that is not a UNC share — silently, with a
> successful exit. Windows resolves the forward-slash form identically, and it survives all
> three shells unchanged. Verified from PowerShell, cmd and Git Bash.

Shared cookie, Azure key ring — when the old app already persists its keys to blob storage,
or the two hosts share no filesystem. Adds two Azure NuGet packages:

```text
-EnableSharedCookieAuth -SharedKeyRingProvider azureblob -SharedKeyRingUri 'https://acct.blob.core.windows.net/dataprotection/keys.xml' -SharedKeyVaultKeyId 'https://myvault.vault.azure.net/keys/dp-key/abc123' -SharedApplicationName 'MyLegacyApp' -SharedCookieName '.AspNet.ApplicationCookie' -SharedCookieScheme 'ApplicationCookie'
```

Remote auth — the new app asks the old app who the user is:

```text
-EnableRemoteAuth -RemoteAppApiKey '11111111-2222-3333-4444-555555555555'
```

Add `-SkipBuild` to generate files without running `dotnet build`.

**Verify the script actually ran** before trusting the result: confirm the new project
directory and `Program.cs` exist. A shell-mangled invocation can exit 0 having created
nothing.

After either group, **tell the user the scaffold is only half the work** and point them at
the `README.SHAREDCOOKIE.md` / `README.REMOTEAUTH.md` the script wrote into the new project.
Until the .NET Framework half is wired, shared-cookie users simply appear signed out with no
error message to notice; remote auth instead fails the round trip, and a plain `[Authorize]`
endpoint returns a 500 regardless — see the footgun under **Authentication interop**.

### 2.3 If Script Fails or Is Unavailable

If the script cannot be executed (e.g., PowerShell not available, permissions issue),
do the steps manually. The template files in `tmpl/mvc/` and `tmpl/webapi/`
contain the exact file contents — copy them to the new project folder and replace
the `$placeholder$` variables:

| Placeholder | Replace with |
|-------------|-------------|
| `$TargetFramework$` | Target framework — **use `net10.0` or later**; a hand-copy below that does not compile (see below) |
| `$SystemWebAdaptersVersion$` | Package version from `get_supported_package_version` |
| `$YarpVersion$` | Package version from `get_supported_package_version` |
| `$ProjectName$` | New project name |
| `$HttpsPort$` | HTTPS port (pick 7100-7999, avoid old project's ports) |
| `$HttpPort$` | HTTP port (pick 5100-5999, avoid old project's ports) |
| `$NewPort$` | IIS Express HTTP port (pick 60000-65000) |
| `$NewSslPort$` | IIS Express SSL port (pick 44300-44399) — in `launchSettings.json` this placeholder is quoted (`"sslPort": "$NewSslPort$"`) so the template stays valid JSON; after substituting, remove the surrounding quotes so `sslPort` stays a JSON number, e.g. `"sslPort": 44355` |
| `$OldAppUrl$` | Old app's URL (e.g., `https://localhost:44319`) |

Then manually:
1. Process the marker comments in `Program.cs` — see **Marker comments in the templates** below. This is not a blanket delete: which blocks you keep depends on the TFM and on whether the user wants authentication interop, and keeping the wrong combination emits code that does not compile or that authenticates nobody.
2. Rename `ProjectName.csproj` to `{NewProjectName}.csproj`
3. Run `dotnet sln "{SOLUTION_PATH}" add "{NEW_PROJECT_PATH}"`
4. Find the new project's GUID in the solution file
5. Add `<_MigrateToProjectGuid>{GUID}</_MigrateToProjectGuid>` to the old project's .csproj
6. Run `dotnet build` to verify

The `appsettings.json` template ships configuration sections for **every** optional feature.
A hand-copy must delete the sections whose code it did not keep, or the generated app carries
configuration nothing reads — an operator will populate `SharedDP:KeyRingPath` and reasonably
believe authentication is configured:

| Keep the section | Only if you kept |
|---|---|
| `ForwardedHeaders` | the `hardening` blocks (net10.0+) |
| `SharedCookie`, and `SharedDP:ApplicationName` | the `sharedcookie` blocks |
| `SharedDP:KeyRingPath`, `SharedDP:CertificateThumbprint` | the `dpfilesystem` block |
| `SharedDP:KeyRingUri`, `SharedDP:KeyVaultKeyId` | the `dpazureblob` block |
| `RemoteApp` | the `remoteauth` blocks |

The two `SharedDP` key pairs are alternatives, exactly like the blocks that read them: keep the pair
belonging to the key ring block you kept and delete the other. Leaving both means an operator sees a
blank `KeyRingPath` beside a filled-in `KeyRingUri` and fills it in, which does nothing.

If you kept `dpazureblob`, the project also needs two `PackageReference` entries the template does
**not** ship, because the far more common filesystem scaffold must not carry an Azure dependency:

```xml
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.5.3" />
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Keys" Version="1.6.3" />
```

`Azure.Identity` is **not** added: it arrives transitively through both, and the emitted code names
`Azure.Identity.DefaultAzureCredential` fully qualified, so it needs no `using` either.

Fill in the values by hand; unlike the script, a hand-copy has nothing escaping them. A
Windows path must be written with escaped backslashes (`"C:\\keys\\app"`), or the file is not
valid JSON and the app fails at startup — `dotnet build` will not catch it, because it never
parses `appsettings.json`.

To trust real proxies, edit `ForwardedHeaders:TrustedProxies` /
`ForwardedHeaders:TrustedNetworks` directly; to let the proxy set the host, populate
`ForwardedHeaders:AllowedHosts`. See **Production hardening**.

> **Manual path has no automatic TFM check.** `scaffold-project.ps1` strips the hardening
> below net10.0, but a hand-copy has nothing enforcing that. `tmpl/*/Program.cs` uses
> `ForwardedHeadersOptions.KnownIPNetworks`, so copying it into a project targeting
> net8.0/net9.0 compiles to **CS1061**. Before copying, confirm `$TargetFramework$` is
> `net10.0` or later; if it cannot be, follow **Targeting below net10.0** under
> **Production hardening**.
>
> **Mutual exclusion is enforced only by the script.** The templates carry the shared-cookie
> and remote-auth blocks side by side, so a hand-copy that deletes every marker line without
> deleting the blocks emits both paths at once, plus the placeholder seam — three competing
> `AddAuthentication` registrations and two `AddSystemWebAdapters()` calls. Follow the table
> below instead of deleting markers wholesale.

### Template Files Reference

```
tmpl/
  mvc/                         ← For MVC projects
    ProjectName.csproj         ← SDK-style web project with YARP + SystemWebAdapters packages
    Program.cs                 ← AddControllersWithViews + YARP forwarder + hardening + auth interop blocks
    appsettings.json           ← ProxyTo + ForwardedHeaders + SharedDP/SharedCookie/RemoteApp sections
    appsettings.Development.json ← logging overrides (inherits the base ForwardedHeaders section)
    Properties/
      launchSettings.json      ← ProxyTo in environmentVariables
  webapi/                      ← For WebAPI projects
    ProjectName.csproj         ← Same packages, no Swashbuckle
    Program.cs                 ← AddControllers + YARP forwarder + hardening + auth interop blocks (no UseStaticFiles)
    appsettings.json           ← ProxyTo + ForwardedHeaders + SharedDP/SharedCookie/RemoteApp sections
    appsettings.Development.json ← logging overrides (inherits the base ForwardedHeaders section)
    Properties/
      launchSettings.json
  auth/                        ← Handoff notes. NOT a project template — copied into the new
                                 project only when an auth switch is on, one file, at the root.
    README.SHAREDCOOKIE.md     ← .NET Framework half for -EnableSharedCookieAuth
    README.REMOTEAUTH.md       ← .NET Framework half for -EnableRemoteAuth
marker-processor.ps1           ← Shared marker parser, dot-sourced by scaffold-project.ps1
```

### Marker comments in the templates

Both `Program.cs` templates delimit optional blocks with `//<kind>` / `//</kind>` comment
markers. `scaffold-project.ps1` always removes the marker lines themselves, and removes the
enclosed code when that kind is not selected. Regions are **sequential, never nested**; the
script throws on an unbalanced, mismatched, or unknown marker rather than emitting malformed
source.

| Marker kind | Keep the enclosed code when |
|---|---|
| `hardening` | TFM is net10.0 or later |
| `authseam` | TFM is net10.0+ **and neither** auth switch is on (the parameterless placeholder) |
| `authpipeline` | TFM is net10.0+ **or** either auth switch is on |
| `swadefault` | `-EnableRemoteAuth` is **off** |
| `sharedcookie` | `-EnableSharedCookieAuth` is on |
| `dpfilesystem` | `-EnableSharedCookieAuth` is on **and** `-SharedKeyRingProvider filesystem` (the default) |
| `dpazureblob` | `-EnableSharedCookieAuth` is on **and** `-SharedKeyRingProvider azureblob` |
| `remoteauth` | `-EnableRemoteAuth` is on |

Four of these are easy to get wrong by hand, and each fails silently:

- **`authpipeline` is separate from `hardening` on purpose.** It holds
  `app.UseAuthentication()`, which an auth path needs even below net10.0. Strip it with the
  hardening and you get an app that registers a cookie scheme with no middleware to run it —
  it authenticates nobody, in exactly the configuration shared cookies exist to support.
- **`authseam` and the two auth paths are alternatives.** Each auth path registers its own
  scheme, so keeping the parameterless `AddAuthentication()` as well emits two competing
  registrations.
- **`swadefault` is dropped when remote auth is on.** The remote-auth block re-issues
  `AddSystemWebAdapters()` as the head of a fluent chain rather than extending the plain call,
  because a marker region can only insert lines. Keep both and the call appears twice.
- **`dpfilesystem` and `dpazureblob` are alternatives, and exactly one must survive** whenever
  `sharedcookie` does. Each opens its own `AddDataProtection()` chain, so keeping both means the
  second registration silently wins — and it is the one whose `appsettings.json` keys you were told
  to delete. Keeping neither leaves an `AddCookie` with no shared key ring, which decrypts nothing
  the other app wrote.

There are three `sharedcookie` regions in each template, and that is intentional. The first holds
`using Microsoft.AspNetCore.DataProtection;`: a C# `using` must precede all top-level statements, so
it sits in its own region at the top of the file, far from the code that needs it. It is deliberately
**not** inside `hardening` — that would strip it on a sub-net10 shared-cookie scaffold and
fail the build with CS0103. It also serves **both** key ring topologies, which is why it is
`sharedcookie` rather than duplicated into `dpfilesystem` and `dpazureblob`. The remaining two
bracket the key ring regions: the explanatory comment before them, and the `AddAuthentication` /
`AddCookie` registration after. Regions cannot nest, so a shared block that spans the two
alternatives has to be split around them rather than wrapped about them.

Key things the templates set up:
- `builder.WebHost.ConfigureKestrel(...)` — security policy (server header off, TLS 1.2/1.3)
- `builder.Services.Configure<ForwardedHeadersOptions>(...)` — fail-closed forwarded headers (non-obsolete API)
- `builder.Services.AddAuthentication()` — parameterless seam so `UseAuthentication()` cannot crash at runtime. **Replaced** by a configured scheme when `-EnableSharedCookieAuth` or `-EnableRemoteAuth` is used; see **Authentication interop**.
- `builder.Services.AddSystemWebAdapters()` — System.Web compatibility shims
- `builder.Services.AddHttpForwarder()` — YARP forwarder registration
- `app.UseForwardedHeaders()` — **first** middleware; recovers client scheme/host/IP
- `app.Use(...)` response scrubber — strips the backend's `Server` / `X-Powered-By` / `X-AspNet-Version` / `X-AspNetMvc-Version` headers
- `app.UseAuthentication()` — runs immediately **before** `app.UseAuthorization()`
- `app.UseSystemWebAdapters()` — middleware for adapter support
- `app.MapForwarder("/{**catch-all}", ...)` — catch-all route at lowest priority, forwards unmatched requests to old app

The `appsettings.json` templates also ship a fail-closed `ForwardedHeaders` section
(`TrustedProxies: []`, `TrustedNetworks: [ "127.0.0.1/32", "::1/128" ]`, `AllowedHosts: []`)
that the code above binds. See **Production hardening**.

## Authentication interop

While both apps run side by side, a user who signs in on the .NET Framework app must be
recognised by the new ASP.NET Core proxy, or they appear signed out the moment a request is
handled by the new app. By default the scaffold emits only a **parameterless seam** —
`AddAuthentication()` with no scheme — which compiles and does not throw but authenticates
nobody. That is the right default: the correct interop depends on how the old app
authenticates, and guessing produces a silent failure.

Two paths are supported, and they are mutually exclusive:

| | Shared cookie | Remote authentication |
|---|---|---|
| **How it works** | Both apps read and write the same encrypted cookie | The Core app asks the Framework app to authenticate each request |
| **Script switch** | `-EnableSharedCookieAuth` | `-EnableRemoteAuth` |
| **Tool argument** | `authInterop="sharedcookie"` | `authInterop="remoteauth"` |
| **Confirmed option value** | `Shared Cookie (Data Protection interop)` | `Remote Authentication` |
| **Choose when** | The old app uses Katana cookie auth **and** both apps can reach a shared Data Protection key ring — a filesystem directory or an Azure blob | The old app uses Windows auth, a custom identity provider, or the two apps can share no key ring at all |
| **Requires** | A shared Data Protection ring in one of the two supported topologies (`filesystem` + X.509 certificate, or `azureblob` + Key Vault), plus identical cookie name, scheme, and application name | A shared GUID API key, network reachability from Core to Framework |
| **Framework-side skill** | `sharing-authentication-cookies-katana-interop` | `migrating-mvc-system-web-adapters` |

Neither is on by default; `authInterop` defaults to `none`, which keeps the seam.

**A confirmed `Cross-App Cookie Authentication` value outranks "Choose when".** When that
upgrade option is among the confirmed selections, the path is already settled: take the
**Confirmed option value** row and use the switch in the same column. The option is agreed
with the user during planning and recorded in the compact block, and it is never reopened —
so re-deriving the path here can silently contradict a decision the user already made, with
nothing downstream positioned to notice.

Apply "Choose when" only when the option is **absent** from the confirmed selections. That is
the normal case whenever the scaffold is reached outside the .NET version upgrade scenario, or
when the option did not trigger for this app. Absence carries no information about which path
suits the app; it only means nobody has chosen yet.

**The Framework-side skill row has one gate.** When `Cross-App Cookie Authentication` is
confirmed as `Remote Authentication` **and** `System.Web Adapters` is confirmed as `Direct
Migration to ASP.NET Core APIs`, do not load `migrating-mvc-system-web-adapters` — that skill
carries the shim overlay the user declined. Give them the Framework-half handoff note instead
and say it is not walked through step by step: on the script path that is the
`README.REMOTEAUTH.md` copied into the project, and on the tool path, which writes none, hand
over this skill's `tmpl/auth/README.REMOTEAUTH.md` yourself. With either value absent, use the
row as written.

**The scaffold configures the ASP.NET Core half only.** Neither path works until the .NET
Framework app is changed too. When a switch is used, the script copies a handoff note into the
new project (`README.SHAREDCOOKIE.md` or `README.REMOTEAUTH.md`) describing that half. Tell
the user it exists — with **shared cookie** there is no error state, so an unfinished setup
looks exactly like a user who is not signed in. **Remote auth** is noisier: an unreachable or
unwired Framework host surfaces as a failed round trip, and a plain `[Authorize]` endpoint
returns a 500 (see the footgun below) whether or not the Framework half is wired.

For the Framework half, load the matching skill above. The script's `README.REMOTEAUTH.md`
additionally inlines the server-side registration snippet, because the remote-auth skill covers
the Core side only; on the tool path, hand that snippet over from the skill yourself.

> **Footgun — with remote auth, plain `[Authorize]` is not enough.** The scaffold registers
> remote authentication as a **non-default** scheme
> (`AddAuthenticationClient(isDefaultScheme: false)`), because this app fronts a catch-all
> `MapForwarder` route: as the default scheme, every forwarded request would make a remote
> authentication call to the Framework app that is about to authenticate it anyway,
> double-authenticating each request and risking a redirect loop between the two apps.
>
> The consequence is that a migrated endpoint must name the scheme explicitly —
> `[Authorize(AuthenticationSchemes = RemoteAppAuthenticationDefaults.AuthenticationScheme)]`.
> A plain `[Authorize]` falls back to a default scheme that does not exist, so it denies and then
> throws `InvalidOperationException: No authenticationScheme was specified, and there was no
> DefaultChallengeScheme found` — the endpoint returns **500**, not 401 and not an anonymous
> success. It fails closed; the confusion is the status code, not a hole. The alternative is to
> make remote auth the default and call `.ShortCircuit()` on the forwarder route; naming the
> scheme is the less surprising option and is what the generated `Program.cs` documents inline.

> **Remote auth requires SystemWebAdapters CoreServices `2.3.0` or newer.** The non-default-scheme
> registration above holds only because the adapters *also* register an internal sentinel scheme,
> which stops ASP.NET Core auto-promoting a lone registered scheme to the default. Older releases do
> not: on `2.0.0` the `isDefaultScheme: false` argument is accepted and `Remote` becomes the default
> anyway, so every forwarded request makes the remote authentication call the argument exists to
> prevent — and nothing reports it, because the project restores, builds and starts normally. The
> script therefore **rejects** `-EnableRemoteAuth` together with an older `-SystemWebAdaptersVersion`.
>
> If `get_supported_package_version` returns something older, **do not drop `-EnableRemoteAuth` to
> get past the error**, and do not add the flag back by hand-editing the generated project. Either
> scaffold without auth interop and tell the user that remote auth needs CoreServices `2.3.0` or
> newer, or use `-EnableSharedCookieAuth`, which does not depend on this behaviour.
>
> **If `Remote Authentication` was the confirmed `Cross-App Cookie Authentication` value**, the
> second of those is not yours to take unilaterally. The floor is a constraint the user never
> saw when they chose, and the option is never reopened, so switching mechanism here settles a
> decision behind them. Report that the floor blocks the confirmed path and let them choose
> between raising CoreServices to `2.3.0` and changing mechanism. Scaffolding without auth
> interop meanwhile is fine — it leaves the seam and forecloses nothing.

> **Footgun — the shared-cookie contract has four separate ways to fail silently.** The
> cookie name, the scheme name (which must equal the Framework app's `AuthenticationType`),
> the Data Protection application name, and the key ring itself must all match. Any mismatch
> produces the same symptom: the user appears signed out. This is why none of these parameters
> has a default. The script path writes the debugging order into `README.SHAREDCOOKIE.md`; the
> tool path writes no README, so walk the user through that

…(truncated)
