Securing .NET 10 Applications on Windows: Best Practices
Most ".NET security" advice stops at the application code — validate input, hash passwords, use parameterized queries. That work is necessary, but it is only half the story. The other half is the Windows host the app actually runs on: the IIS Application Pool identity, the NTFS permissions on your content root, where your Data Protection keys live, and how secrets reach the process. A perfectly written ASP.NET Core app can still be quietly exposed by a default app pool identity or a key ring that resets on every recycle. This is the production hardening playbook we apply to every .NET app on Adaptive's Windows hosting — ten practices for the host and platform layer, updated for .NET 10 LTS on Windows Server 2022.
.NET 10LTS — updated for
WS 2022Hardened Windows Server
FREESSL + WAF on every plan
A note on naming before we start: .NET 10 is the framework Microsoft used to call ".NET Core" (the "Core" suffix was dropped at .NET 5 in 2020). If you searched for ".NET Core 10 hosting" or "ASP.NET Core security on Windows," you're in the right place — it's all the same modern runtime. The legacy ASP.NET 4.8 Framework stack is a separate product with its own hardening notes, called out where they differ.
The Windows host attack surface, at a glance
App-code reviews rarely catch these. They live below your Program.cs, in the way the process is configured to run on Windows:
Over-privileged app pool
App running as LocalSystem or an admin account. One RCE and the attacker owns the box, not just the site.
🔴 Critical
Plaintext secrets on disk
Connection strings and API keys sitting in appsettings.json in source control or readable by every account on the server.
🟠 High
Unprotected Data Protection keys
Ephemeral key ring resets on recycle — auth cookies and anti-forgery tokens silently break, or keys sit unencrypted on disk.
🟠 High
Loose NTFS permissions
Everyone: Full Control on the site root. Any compromised process can rewrite your binaries.
🟠 High
Stale runtime / OS patches
Running on an out-of-support runtime like .NET 9 (STS, now end-of-life) means unpatched CVEs in the framework itself.
🟡 Medium
Leaky server headers
Server: Microsoft-IIS/10.0 and X-Powered-By hand attackers your exact stack for free.
None of these are exotic. They're the default state of an unhardened Windows deployment — and every one of them is a configuration fix, not a code rewrite. The ten practices below close them in order of impact.
Quick reference: the 10 practices
- Run Under a Least-Privilege Application Pool Identity
An app pool running as LocalSystem, NetworkService, or a domain admin gives any code-execution bug the keys to the whole server. The blast radius of a single vulnerability becomes the entire machine instead of one site.
Every site should run under its own low-privilege identity. The default ApplicationPoolIdentity is the right baseline — it's a virtual account (IIS AppPool\YourPoolName) with no rights beyond what you explicitly grant. Never share one app pool across unrelated sites, and never elevate the pool to "fix" a permissions error — grant the specific permission instead.
Each site gets its own dedicated application pool
New-WebAppPool -Name "ContosoApp"
Set-ItemProperty IIS:\AppPools\ContosoApp -Name processModel.identityType -Value ApplicationPoolIdentity
Grant the pool identity read+execute on the site root — nothing more
icacls "C:\inetpub\sites\contoso" /grant "IIS AppPool\ContosoApp:(OI)(CI)(RX)"
Every site on Adaptive runs in its own dedicated IIS Application Pool (1–4 GB RAM by tier) under an isolated identity. Your worker process never shares memory or identity with another tenant — a compromised neighbour can't pivot into your app. This is the "Windows-first, not oversold" model in practice.
- Tighten NTFS ACLs on the Content Root
If the app pool identity can write to the folder it executes from, a file-upload or path-traversal bug becomes remote code execution. The attacker drops a web shell into your site root and IIS happily runs it.
The principle is simple: the process should have read + execute on the binaries, write only where it genuinely needs to write (a dedicated App_Data or uploads folder, ideally outside the executable path). Remove inherited Everyone and broad Users grants.
Read+execute on the app root (no write)
icacls "C:\inetpub\sites\contoso" /inheritance:r
/grant "IIS AppPool\ContosoApp:(OI)(CI)(RX)"
/grant "Administrators:(OI)(CI)(F)"
Write only on the data folder, and mark it non-executable in IIS
icacls "C:\inetpub\sites\contoso\App_Data" /grant "IIS AppPool\ContosoApp:(OI)(CI)(M)"
Add an IIS Request Filtering rule that denies execution in your uploads directory, and store user uploads outside the web root entirely where you can. Defense in depth: even if ACLs slip, the directory can't serve executable content.
- Persist and Protect the Data Protection Key Ring
ASP.NET Core's Data Protection API encrypts auth cookies, anti-forgery tokens, and TempData. Under IIS, if the key ring isn't persisted to a stable, protected location, it can regenerate on every app pool recycle — logging every user out — or worse, sit on disk unencrypted where any reader can decrypt session cookies.
On Windows, persist the keys to a fixed folder and encrypt them at rest. DPAPI tied to the machine (or a user account) is the simplest option; an X.509 certificate is better for web farms:
builder.Services.AddDataProtection()
.SetApplicationName("ContosoApp") // stable across recycles & farm nodes
.PersistKeysToFileSystem(new DirectoryInfo(@"C:\keys\contoso"))
.ProtectKeysWithDpapi() // encrypt at rest with Windows DPAPI
.SetDefaultKeyLifetime(TimeSpan.FromDays(90));
// For a multi-server farm, prefer a shared key store + certificate:
// .PersistKeysToFileSystem(new DirectoryInfo(@"\\share\keys"))
// .ProtectKeysWithCertificate(thumbprint);
Lock the keys folder down with icacls so only the app pool identity and administrators can read it, and keep it outside the deployable site folder so a redeploy never wipes it. Pair this with the app pool recycling diagnostic guide if you're seeing unexpected logouts — a recycling pool plus an ephemeral key ring is the classic "users randomly signed out" bug.
- Get Secrets Out of Config Files
A connection string in appsettings.json committed to git is a credential leak waiting to happen. The same file readable by every account on a shared box is a lateral-movement gift. Secrets in source control are the single most common way databases get popped.
ASP.NET Core's configuration system layers providers in order, so production secrets can come from the environment without touching your JSON. On Windows, set them per-app-pool as environment variables (the ASP.NET Core Module passes them to the process), or read from a vault:
<!-- web.config — scope environment variables to this app only -->
<aspNetCore processPath="dotnet" arguments=".\Contoso.dll" stdoutLogEnabled="false">
<environmentVariables>
<environmentVariable name="ASPNETCORE_ENVIRONMENT" value="Production" />
<environmentVariable name="ConnectionStrings__Default" value="..." />
</environmentVariables>
</aspNetCore>
On the legacy ASP.NET 4.8 Framework, you encrypt web.config sections with aspnet_regiis -pe. That tool does not apply to .NET 10 — ASP.NET Core ignores those encrypted sections. For .NET 10, use environment variables, a secrets vault (Azure Key Vault, HashiCorp Vault), or DPAPI-protected blobs via System.Security.Cryptography.ProtectedData. Use dotnet user-secrets in development only — never in production.
- Harden IIS Request Filtering and Strip Server Headers
Default IIS advertises its version in the Server header, and ASP.NET historically added X-Powered-By and X-AspNet-Version. Combined with verbose error pages, that's a free reconnaissance report for anyone probing your site — and unbounded request limits invite resource-exhaustion attacks.
Strip the fingerprinting headers, cap request sizes, and lock down verbs and file extensions in web.config:
<system.webServer>
<httpProtocol>
<customHeaders>
<remove name="X-Powered-By" />
<add name="X-Content-Type-Options" value="nosniff" />
<add name="X-Frame-Options" value="DENY" />
<add name="Referrer-Policy" value="strict-origin-when-cross-origin" />
</customHeaders>
</httpProtocol>
<security>
<requestFiltering removeServerHeader="true">
<requestLimits maxAllowedContentLength="10485760" /> <!-- 10 MB -->
<verbs allowUnlisted="false">
<add verb="GET" allowed="true" />
<add verb="POST" allowed="true" />
<add verb="PUT" allowed="true" />
<add verb="DELETE" allowed="true" />
</verbs>
</requestFiltering>
</security>
</system.webServer>
For the Server header that Kestrel adds before IIS sees it, also disable it in code so nothing leaks even on direct connections:
builder.WebHost.ConfigureKestrel(o => o.AddServerHeader = false);
// Send a proper Content-Security-Policy too — your strongest XSS backstop
app.Use(async (ctx, next) =>
{
ctx.Response.Headers["Content-Security-Policy"] =
"default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'";
await next();
});
- TLS 1.3, HSTS, and Post-Quantum on .NET 10
.NET 10 LTS on Windows Server 2022 negotiates TLS 1.3 by default and adds support for post-quantum cryptography — ML-KEM for key exchange and ML-DSA for signatures, the NIST-standardized algorithms designed to survive "harvest now, decrypt later" attacks. For data with a long secrecy requirement, that matters today, not in a decade.
At the application layer, enforce HTTPS and emit a strong HSTS policy. At the OS layer, disable the legacy protocols and weak ciphers Schannel still allows by default:
if (!app.Environment.IsDevelopment())
{
app.UseHsts(); // Strict-Transport-Security
app.UseHttpsRedirection(); // 308 redirect HTTP → HTTPS
}
builder.Services.AddHsts(o =>
{
o.MaxAge = TimeSpan.FromDays(365);
o.IncludeSubDomains = true;
o.Preload = true;
});
Every .NET 10 LTS hosting plan includes FREE SSL on every site, TLS 1.3 termination, and a hardened Schannel baseline with old protocols disabled — no "HTTPS costs extra" tax. If you're issuing your own certificate, our free Let's Encrypt SSL on Plesk guide walks through it in the control panel.
- Windows / Kerberos Authentication Done Right
Intranet apps often lean on Windows Authentication, then fall back to NTLM because a Service Principal Name (SPN) is misconfigured. NTLM is weak to relay attacks and doesn't support modern delegation safely. Unconstrained delegation, meanwhile, lets a compromised service impersonate users anywhere on the domain.
For Windows-authenticated apps, prefer Negotiate/Kerberos, register SPNs correctly, and use constrained delegation only. For public-facing apps, don't use Windows auth at all — use OpenID Connect (see the API security best practices for the OIDC pattern):
builder.Services
.AddAuthentication(NegotiateDefaults.AuthenticationScheme)
.AddNegotiate();
builder.Services.AddAuthorization(o =>
{
o.FallbackPolicy = o.DefaultPolicy; // require auth by default
});
Internet-facing identity belongs in an OIDC provider (Microsoft Entra ID, Auth0, Okta, Keycloak) that handles MFA, passkeys, and breach detection for you. Reserve Windows/Kerberos auth for genuinely intranet workloads behind the corporate perimeter.
- Patch Discipline: Windows and the .NET Runtime
A perfectly hardened config on an out-of-support runtime is still vulnerable — the CVE is in the framework, not your code. .NET 9 is a Standard-Term Support (STS) release and reached end-of-life in May 2026. Apps still on it receive no security patches.
Two patch streams matter on Windows: the OS (monthly cumulative updates) and the .NET runtime (serviced on Patch Tuesday). Choose an LTS runtime so you get three years of fixes, and keep dependencies current:
Audit your dependencies for known vulnerabilities, including transitive
dotnet list package --vulnerable --include-transitive
Confirm the runtime you're actually deploying on
dotnet --list-runtimes
Target .NET 10 LTS (supported into 2028) or .NET 8 LTS for production. If you're stranded on .NET 9, follow the .NET 9 STS end-of-life upgrade path — it's usually a one-line target-framework bump. Adaptive keeps the Windows Server 2022 OS and IIS patched at the platform level; you keep your app's runtime target and NuGet packages current.
- Defense in Depth: WAF, DDoS, and App Pool Isolation
Relying on a single control means a single bypass owns you. No edge filtering means malicious traffic hits your app directly; shared app pools mean one compromised site can read another's memory and files.
Layer your controls so any one failure is caught by the next. In the app, wire the built-in rate limiter for your expensive endpoints; at the edge, let the WAF absorb the cheap floods before they ever reach your worker:
builder.Services.AddRateLimiter(o =>
{
o.AddFixedWindowLimiter("default", l =>
{
l.PermitLimit = 100;
l.Window = TimeSpan.FromMinutes(1);
});
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
app.UseRateLimiter();
A managed Web Application Firewall and DDoS protection sit in front of every plan, rate-limiting and filtering malicious requests at the edge. Combined with per-site dedicated Application Pools, the edge stops the noise and the isolation contains the blast radius — defense in depth you don't have to assemble yourself.
- Event Log Auditing and Structured Logs
When an incident happens, the question is "who did what, when?" If your only record is the IIS access log, you can't answer it. No audit trail turns a one-hour incident review into a three-week forensic guess.
Write security-relevant events as structured logs, surface the critical ones to the Windows Event Log, and forward everything to a central store you can query during an incident:
// Structured JSON logs feed straight into Splunk / Datadog / Elastic
builder.Host.UseSerilog((ctx, lc) => lc
.ReadFrom.Configuration(ctx.Configuration)
.Enrich.FromLogContext()
.Enrich.WithProperty("Application", "ContosoApp")
.WriteTo.EventLog("ContosoApp", manageEventSource: true,
restrictedToMinimumLevel: LogEventLevel.Warning)
.WriteTo.File(new CompactJsonFormatter(),
@"C:\logs\contoso\.json", rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30));
// Log auth outcomes as structured events — the data you'll wish you had
Log.Information("Auth {Outcome} for {User} from {ClientIp}",
outcome, userId, ctx.Connection.RemoteIpAddress);
Where should each secret live?
The most common host-layer question we get is "where do I actually put my connection string on Windows?" Here's the decision matrix:
Windows hardening checklist
✅ Identity & Secrets
Data Protection keys persisted + DPAPI/cert-protected
No secrets in committed appsettings.json
Env vars / vault / DPAPI for connection strings
OIDC for public auth; constrained delegation for Kerberos
✅ Transport & Headers
TLS 1.3 enabled; legacy protocols disabled in Schannel
HSTS with includeSubDomains; preload
X-Content-Type-Options, X-Frame-Options, CSP set
X-Powered-By / Server headers stripped
✅ Operations
On an LTS runtime (.NET 10 / .NET 8), not STS
dotnet list package --vulnerable in CI
Structured logs + Event Log for security events
WAF + DDoS active at the edge
The Hosting-Layer Security Adaptive Provides
Even with flawless application code, your hosting layer has to cooperate. Every Adaptive Web Hosting plan ships these Windows-layer controls so you're not assembling them yourself:
LayerWhat's included
EdgeDDoS protection and a managed Web Application Firewall — rate-limits and filters malicious requests before they reach your worker
TransportTLS 1.3 termination, FREE SSL on every site, hardened Schannel, post-quantum-ready on .NET 10 LTS
Host OSHardened Windows Server 2022 baseline, Server Core where possible, regular platform patching
IISDedicated Application Pools per site (1–4 GB RAM by tier) under isolated identities — no cross-tenant pivot
StorageHigh-performance SSD, automated backups, encryption at rest
DatabaseReal SQL Server 2022 with Always Encrypted, ledger tables, and Query Store available
InfrastructureAWS US-East data center, 99.99% uptime SLA, 30-day money-back guarantee
Choose a plan
$9.49/mo
Dev, staging, and smaller production apps. 1 GB dedicated app pool, isolated identity, real SQL Server.
See Developer plan features →
Popular
ASP.NET Business
$17.49/mo
Production line-of-business apps. 2 GB dedicated app pool, WAF, free SSL, higher-priority scheduling.
See Business plan features →
ASP.NET Professional
$27.49/mo
Agencies and SaaS builders. 10 sites, 200 GB SSD, 4 GB dedicated app pool, top-priority scheduling.
See Professional plan features →
Frequently Asked Questions
Is .NET 10 the same as .NET Core 10?
Yes. Microsoft dropped the "Core" suffix at .NET 5 in 2020, so ".NET 10" is the direct successor to ".NET Core 3.1" and everything people still call ".NET Core." The hardening practices here apply to any modern .NET (Core) version — .NET 8 LTS and .NET 10 LTS included. The separate legacy stack is .NET Framework / ASP.NET 4.8, which has some different host-layer steps (noted in practice #4).
What app pool identity should my .NET app use on Windows?
Use ApplicationPoolIdentity — the per-pool virtual account IIS creates automatically. It has no rights beyond what you grant, and each pool gets its own. Never run a web app as LocalSystem, NetworkService, or a domain admin. If the app needs a network identity (to reach a remote SQL Server with Windows auth), use a Group Managed Service Account (gMSA) rather than a shared service password.
How do I store secrets on Windows without a cloud vault?
Two solid options without bringing in Azure Key Vault: set them as per-app-pool environment variables (kept out of source control and scoped to one app), or encrypt them with Windows DPAPI via System.Security.Cryptography.ProtectedData so they're encrypted at rest and tied to the machine. Both beat a plaintext connection string in appsettings.json. The legacy aspnet_regiis config encryption does not work for .NET 10 — that's ASP.NET 4.8 only.
Why do my users get randomly logged out after a deploy or recycle?
Almost always an unprotected Data Protection key ring (practice #3). If the keys aren't persisted to a stable folder, the app generates a fresh ring on recycle, invalidating every existing auth cookie and anti-forgery token. Persist the keys outside the deployable folder and encrypt them with DPAPI or a certificate. If recycles themselves are frequent, the app pool recycling diagnostic guide covers the root causes.
Do I need to configure TLS and SSL myself on Adaptive?
The transport baseline — TLS 1.3, a hardened Schannel cipher set, and FREE SSL on every site — is handled at the platform level. You still set application-level HTTPS enforcement (UseHttpsRedirection) and your HSTS policy in code, because those are your app's behavior, not the host's. If you want a custom certificate, the Let's Encrypt on Plesk guide shows the control-panel flow.
How is this different from ASP.NET Core application security?
Complementary. This article is the host and platform layer — app pool identity, NTFS, key ring, secrets, IIS, OS patching. The ASP.NET Core security best practices article covers the application layer — Identity, anti-forgery, input validation. For API-specific concerns see securing ASP.NET Core APIs, for the database layer see securing SQL Server, and for Blazor surfaces see securing Blazor applications. Read them together for full-stack coverage.
Does Adaptive patch the Windows OS automatically?
Yes — the Windows Server 2022 OS and IIS are patched at the platform level as part of the managed environment. What stays your responsibility is your application's runtime target (stay on an LTS like .NET 10 or .NET 8) and your NuGet dependencies. Run dotnet list package --vulnerable in CI so a transitive CVE can't slip through.
Bottom line
Application code gets the security attention, but the Windows host is where a surprising number of .NET apps are quietly exposed — a default-elevated app pool, a key ring that resets on recycle, a connection string in source control, a Server header advertising the stack. None of these are code rewrites; they're configuration the ten practices above close. The reassuring part: on a well-run Windows platform, several of them are already handled for you.
On Adaptive Web Hosting, the host-layer primitives — dedicated IIS Application Pools under isolated identities, a hardened Windows Server 2022 baseline, WAF and DDoS protection, FREE SSL, and post-quantum-ready TLS 1.3 on .NET 10 LTS — ship on every tier. ASP.NET Developer ($9.49/mo) for development and smaller apps, ASP.NET Business ($17.49/mo) for the typical line-of-business app, ASP.NET Professional ($27.49/mo) for agencies running many sites. Every plan includes a 30-day money-back guarantee. View all plans, read the broader .NET 10 LTS hosting guide, or talk to an engineer about a specific hardening question.