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.CoreServicesand the_MigrateToProjectGuidlink 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 noSystem.Webto 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 andSystemWebAdapters.CoreServiceshave 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_projecttool 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}" )authInteropselects which authentication interop path to pre-wire, and defaults tonone. Ask the user which they need before calling — see Authentication interop below for how to choose.sharedKeyRingProviderapplies only tosharedcookie, and is rejected with any other mode; it selects where the shared Data Protection key ring lives and therefore whichSharedDP:*keys are emitted. Two limits are specific to this path:sharedcookieandremoteauthrequiretargetFrameworknet10.0 or later. The tool rejects the combination up front rather than scaffolding a project with the parameter silently dropped. Below net10.0, scaffold withnoneand 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.jsonfor the user to fill in. Every blank value fails loudly rather than appearing to work, but when it fails differs by mode:sharedcookievalues are read eagerly while the host is built, so a blank one throws at startup;remoteauthvalues 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 — forsharedcookiethat set depends onsharedKeyRingProvider.scaffold-project.ps1takes 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.
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 thesharing-authentication-cookies-katana-interopskill, which requiresMicrosoft.Owin.Security.Interopand theAspNetTicketDataFormatshim. Classic ASP.NET Forms authentication is not supported — a<forms>element inWeb.configand an.ASPXAUTHcookie mean amachineKey-encryptedFormsAuthenticationTicket, 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.The key ring topology must be one the scaffold emits, and
-SharedKeyRingProvidermust 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 withDefaultAzureCredential. 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 generatedProgram.csmust 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
-Commandand the call operator. Theexecutetool runs in the user's shell, which is often Git Bash or WSL rather than PowerShell. Running the.ps1by 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 withunexpected 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. Usepwshinstead ofpowershellon non-Windows hosts.Use
-Command "& '<script>' …", not-File.-Filepasses 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 value10.0.0.5,10.0.0.6, the script writes one invalid address, and the generatedProgram.cssilently falls back to loopback while the command reports success.-Commandmakes PowerShell parse the arguments, so arrays bind correctly. Measured from PowerShell, cmd and Git Bash.
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):
-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:
-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:
-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:
-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:
- 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. - Rename
ProjectName.csprojto{NewProjectName}.csproj - Run
dotnet sln "{SOLUTION_PATH}" add "{NEW_PROJECT_PATH}" - Find the new project's GUID in the solution file
- Add
<_MigrateToProjectGuid>{GUID}</_MigrateToProjectGuid>to the old project's .csproj - Run
dotnet buildto 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:
<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.ps1strips the hardening below net10.0, but a hand-copy has nothing enforcing that.tmpl/*/Program.csusesForwardedHeadersOptions.KnownIPNetworks, so copying it into a project targeting net8.0/net9.0 compiles to CS1061. Before copying, confirm$TargetFramework$isnet10.0or 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
AddAuthenticationregistrations and twoAddSystemWebAdapters()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:
authpipelineis separate fromhardeningon purpose. It holdsapp.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.authseamand the two auth paths are alternatives. Each auth path registers its own scheme, so keeping the parameterlessAddAuthentication()as well emits two competing registrations.swadefaultis dropped when remote auth is on. The remote-auth block re-issuesAddSystemWebAdapters()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.dpfilesystemanddpazureblobare alternatives, and exactly one must survive wheneversharedcookiedoes. Each opens its ownAddDataProtection()chain, so keeping both means the second registration silently wins — and it is the one whoseappsettings.jsonkeys you were told to delete. Keeping neither leaves anAddCookiewith 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 soUseAuthentication()cannot crash at runtime. Replaced by a configured scheme when-EnableSharedCookieAuthor-EnableRemoteAuthis used; see Authentication interop.builder.Services.AddSystemWebAdapters()— System.Web compatibility shimsbuilder.Services.AddHttpForwarder()— YARP forwarder registrationapp.UseForwardedHeaders()— first middleware; recovers client scheme/host/IPapp.Use(...)response scrubber — strips the backend'sServer/X-Powered-By/X-AspNet-Version/X-AspNetMvc-Versionheadersapp.UseAuthentication()— runs immediately beforeapp.UseAuthorization()app.UseSystemWebAdapters()— middleware for adapter supportapp.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-allMapForwarderroute: 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 throwsInvalidOperationException: 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 generatedProgram.csdocuments inline.
Remote auth requires SystemWebAdapters CoreServices
2.3.0or 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: on2.0.0theisDefaultScheme: falseargument is accepted andRemotebecomes 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-EnableRemoteAuthtogether with an older-SystemWebAdaptersVersion.If
get_supported_package_versionreturns something older, do not drop-EnableRemoteAuthto 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 CoreServices2.3.0or newer, or use-EnableSharedCookieAuth, which does not depend on this behaviour.If
Remote Authenticationwas the confirmedCross-App Cookie Authenticationvalue, 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 to2.3.0and 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 intoREADME.SHAREDCOOKIE.md; the tool path writes no README, so walk the user through that
…(truncated)