Hardened migration security checklist
Seven things every .NET Framework 4.x → .NET 8 migration should fix before going live. Each item names the pattern, explains why it matters, shows how to verify it in your codebase, and shows how to fix it. Copy-paste-runnable.
This is not a substitute for a compliance audit. It’s the baseline a working engineer would check before handing an app back to operations.
1. Hardcoded connection strings in web.config / appsettings.json
Pattern
XML (web.config):
<connectionStrings> <add name="Db" connectionString="Server=prod-sql.corp;Database=Orders;User Id=sa;Password=P@ssw0rd1234;" /></connectionStrings>JSON (appsettings.json):
{ "ConnectionStrings": { "Db": "Server=prod-sql.corp;Database=Orders;User Id=sa;Password=P@ssw0rd1234;" }}Why it matters
A password in a config file leaks through git history, container image layers, build artifacts, backup tarballs, stack traces, Application Insights PII exports, developer laptops, and support-ticket attachments. Every one of those is a documented breach vector. If the file was ever committed, assume the credential is compromised regardless of subsequent .gitignore additions — git history retains the original content.
How to verify
Search the working tree for plaintext credentials:
grep -rniE 'password=|pwd=|secret=|apikey=' --include='*.config' --include='*.json' .Search git history for credentials that were committed and later removed:
git log --all --full-history --source -p -- web.config appsettings.json appsettings.*.json \ | grep -iE 'password|pwd|secret|apikey|api_key|connectionstring' | head -50How to fix
Store secrets in Azure Key Vault and retrieve them with DefaultAzureCredential (which chains Managed Identity in Azure, az login locally, and environment variables in CI):
using Azure.Identity;
var builder = WebApplication.CreateBuilder(args);
var vaultUri = new Uri(builder.Configuration["KeyVault:Uri"]!);builder.Configuration.AddAzureKeyVault(vaultUri, new DefaultAzureCredential());// appsettings.json — no secrets, just the vault URI{ "KeyVault": { "Uri": "https://myapp-kv.vault.azure.net/" }}Install: dotnet add package Azure.IdentityInstall: dotnet add package Azure.Extensions.AspNetCore.Configuration.SecretsRotate the password immediately. If it was ever in git history, it is compromised.
2. Expired auth flows: Forms Authentication, ASP.NET Membership, SimpleMembership
Pattern
<!-- web.config --><authentication mode="Forms"> <forms loginUrl="~/Account/Login" timeout="2880" /></authentication>
<membership defaultProvider="SqlMembershipProvider"> <providers> <add name="SqlMembershipProvider" type="System.Web.Security.SqlMembershipProvider" connectionStringName="Db" enablePasswordRetrieval="false" requiresQuestionAndAnswer="false" passwordFormat="Hashed" /> </providers></membership>if (Membership.ValidateUser(username, password)){ FormsAuthentication.SetAuthCookie(username, rememberMe); Response.Redirect("~/Dashboard");}Why it matters
Forms Authentication cookies use MachineKey-based encryption with no key rotation mechanism. ASP.NET Membership’s passwordFormat="Hashed" uses SHA-1 with a single iteration (or PBKDF2 with 1,000 iterations in later versions — still far below the current OWASP minimum of 600,000 for PBKDF2-SHA256). Neither supports refresh tokens, PKCE, or SameSite cookie defaults. System.Web.Security.Membership does not exist in .NET 8 — the code will not compile.
How to verify
grep -rnE '<authentication[^>]*mode="Forms"' --include='*.config' .grep -rnE 'Membership\.|MembershipProvider|FormsAuthentication|SimpleMembership' \ --include='*.cs' --include='*.vb' .How to fix
Option A — ASP.NET Core Identity (self-contained, stores users in your database):
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options => { options.Password.RequiredLength = 12; options.Lockout.MaxFailedAccessAttempts = 5; }) .AddEntityFrameworkStores<AppDbContext>() .AddDefaultTokenProviders();
builder.Services.ConfigureApplicationCookie(options =>{ options.Cookie.HttpOnly = true; options.Cookie.SecurePolicy = CookieSecurePolicy.Always; options.Cookie.SameSite = SameSiteMode.Strict;});PasswordHasher<TUser> uses PBKDF2-HMAC-SHA512 with 100,000 iterations by default.
Option B — OIDC to an external IdP (Entra ID, Auth0, Keycloak):
builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme) .AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd"));Install: dotnet add package Microsoft.Identity.WebInstall: dotnet add package Microsoft.Identity.Web.UIMigrating password hashes: re-hash on first successful login using the new hasher. Force a password reset after 90 days for users who have not logged in (their old hashes cannot be transparently upgraded).
3. Weak crypto primitives: MD5, SHA-1, DES, 3DES, RC4
Pattern
// "Just hashing a file checksum"using var md5 = MD5.Create();byte[] hash = md5.ComputeHash(fileBytes);
// "Encrypting sensitive data"using var des = DES.Create();des.Key = Encoding.ASCII.GetBytes("8bytekey"); // 56-bit keyWhy it matters
MD5 has been collision-broken since 2004 (Wang et al.). SHA-1 has been collision-broken since 2017 (SHAttered, CWE-328). DES is brute-forceable in hours on commodity hardware. 3DES was deprecated by NIST in 2023 due to the Sweet32 birthday attack on 64-bit block ciphers. RC4 has known statistical biases exploitable in TLS (RFC 7465 prohibited it in 2015). All of these are compliance findings under PCI-DSS 4.0, SOC 2 Type II, and GDPR Article 32 (“appropriate technical measures”).
How to verify
grep -rnE '\b(MD5|SHA1|DES|TripleDES|RC4|RijndaelManaged)\b' --include='*.cs' --include='*.vb' .Note: RijndaelManaged is functionally AES but is marked obsolete in .NET 8. Replace with Aes.Create() or AesGcm.
How to fix
| Use case | Replace with |
|---|---|
| Password hashing | PasswordHasher<TUser> (ASP.NET Core Identity) |
| General hash (integrity check) | SHA256.HashData(data) or SHA3_256.HashData(data) (.NET 8+) |
| HMAC (message authentication) | HMACSHA256 |
| Symmetric encryption | AesGcm (authenticated encryption, prevents tampering) |
| Key derivation | Rfc2898DeriveBytes with HashAlgorithmName.SHA256 and iterations: 600_000 |
AES-GCM example:
using System.Security.Cryptography;
byte[] key = RandomNumberGenerator.GetBytes(32); // 256-bitbyte[] nonce = RandomNumberGenerator.GetBytes(AesGcm.NonceByteSizes.MaxSize); // 12 bytesbyte[] tag = new byte[AesGcm.TagByteSizes.MaxSize]; // 16 bytesbyte[] ciphertext = new byte[plaintext.Length];
using var aes = new AesGcm(key, AesGcm.TagByteSizes.MaxSize);aes.Encrypt(nonce, plaintext, ciphertext, tag);Never roll your own construction. Never reuse a nonce with the same key.
4. Unsafe deserialization: BinaryFormatter, JavaScriptSerializer, NetDataContractSerializer
Pattern
// BinaryFormatter — RCE on deserialization of untrusted datavar formatter = new BinaryFormatter();var obj = formatter.Deserialize(networkStream); // attacker controls the stream
// JavaScriptSerializer — no type allowlistvar serializer = new JavaScriptSerializer();var result = serializer.Deserialize<UserProfile>(jsonFromRequest);Why it matters
BinaryFormatter deserializes arbitrary .NET types, including types with dangerous side effects in their constructors or finalizers. An attacker who controls the byte stream achieves remote code execution. This is not theoretical — ysoserial.net generates working payloads for BinaryFormatter, NetDataContractSerializer, LosFormatter, and SoapFormatter. Microsoft marked BinaryFormatter as obsolete in .NET 8 and removed it entirely in .NET 9 (SYSLIB0011). JavaScriptSerializer resolves types without an allowlist, enabling type-confusion attacks. OWASP ranks insecure deserialization as A08:2021.
How to verify
grep -rnE '\b(BinaryFormatter|JavaScriptSerializer|NetDataContractSerializer|LosFormatter|SoapFormatter)\b' \ --include='*.cs' .How to fix
Use System.Text.Json with explicit type contracts. Source generation makes it AOT-safe and avoids reflection-based deserialization:
using System.Text.Json;using System.Text.Json.Serialization;
// Define a source-generated context for the types you serialize[JsonSerializable(typeof(UserProfile))][JsonSerializable(typeof(List<Order>))]internal partial class AppJsonContext : JsonSerializerContext { }
// Deserialize with the generated context — no reflection, no arbitrary type resolutionvar profile = JsonSerializer.Deserialize(json, AppJsonContext.Default.UserProfile);System.Text.Json ships with .NET 8. No extra package needed.For binary protocols where JSON overhead is unacceptable, use MessagePack-CSharp or protobuf-net — both use schema-based contracts that reject unknown types.
5. Missing security headers
Pattern
A Program.cs that sets up the pipeline without security headers:
var app = builder.Build();app.UseRouting();app.UseAuthentication();app.UseAuthorization();// No UseHsts(), no Content-Security-Policy, no X-Frame-Optionsapp.MapControllers();app.Run();Why it matters
Each missing header enables a distinct browser-side attack class:
| Header | Mitigates |
|---|---|
Strict-Transport-Security | HTTPS downgrade / SSL stripping (MITM) |
Content-Security-Policy | Cross-site scripting (XSS) |
X-Frame-Options | Clickjacking |
X-Content-Type-Options | MIME-sniffing drive-by downloads |
Referrer-Policy | URL leakage to third-party origins |
Missing headers are the most common finding in automated security scans (SecurityHeaders.com, Mozilla Observatory). They cost nothing to add and prevent entire categories of attack.
How to verify
curl -sI https://your-app.example.com \ | grep -iE 'strict-transport|content-security|x-frame|referrer-policy|x-content-type'If any of the five headers are absent from the response, the check fails.
How to fix
var app = builder.Build();
app.UseHsts(); // Sends Strict-Transport-Security in non-dev environments
app.Use(async (ctx, next) =>{ var headers = ctx.Response.Headers; headers.Append("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self';"); headers.Append("X-Frame-Options", "DENY"); headers.Append("X-Content-Type-Options", "nosniff"); headers.Append("Referrer-Policy", "strict-origin-when-cross-origin"); headers.Append("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); await next();});
app.UseHttpsRedirection();app.UseRouting();app.UseAuthentication();app.UseAuthorization();app.MapControllers();app.Run();Start with Content-Security-Policy-Report-Only for one week to catch violations without breaking functionality. Switch to enforcing Content-Security-Policy after reviewing the report-uri output. Adjust script-src and style-src as your app requires — the starter policy above is deliberately tight.
6. Vulnerable transitive dependencies
Pattern
A .csproj with pinned versions that have not been updated in years:
<ItemGroup> <PackageReference Include="Newtonsoft.Json" Version="11.0.2" /> <PackageReference Include="System.Data.SqlClient" Version="4.6.1" /> <PackageReference Include="System.Drawing.Common" Version="5.0.0" /></ItemGroup>The direct references look harmless, but their transitive dependencies may carry known CVEs from the NuGet advisory database.
Why it matters
High-severity CVEs have been published against widely used .NET packages: System.Data.SqlClient (CVE-2024-0056, information disclosure via encrypted connection), System.Drawing.Common (CVE-2021-24112, remote code execution), Newtonsoft.Json (no direct CVE, but old versions lack MaxDepth protection against stack-overflow DoS). A vulnerable transitive dependency is as exploitable as a vulnerable direct one — the attack does not care where in the dependency graph the code lives.
How to verify
dotnet list package --vulnerable --include-transitiveThis queries the NuGet advisory database and prints every package (direct or transitive) with a known CVE. Run it against the solution file to cover all projects at once.
How to fix
If the vulnerable package is a transitive dependency, add an explicit PackageReference to pull in the patched version:
<ItemGroup> <!-- Override transitive System.Data.SqlClient pulled by OldOrm.Core --> <PackageReference Include="System.Data.SqlClient" Version="4.8.6" /></ItemGroup>Or migrate to the replacement package when one exists (System.Data.SqlClient -> Microsoft.Data.SqlClient).
Add the vulnerability check to CI so it blocks releases. GitHub Actions example:
- name: Check for vulnerable packages run: | OUTPUT=$(dotnet list package --vulnerable --include-transitive 2>&1) echo "$OUTPUT" if echo "$OUTPUT" | grep -q "has the following vulnerable packages"; then echo "::error::Vulnerable NuGet packages detected" exit 1 fi7. Missing HTTPS enforcement and cookie security attributes
Pattern
var app = builder.Build();// UseHttpsRedirection() is missing — HTTP requests are served as-is
app.MapGet("/login", (HttpContext ctx) =>{ ctx.Response.Cookies.Append("session", token, new CookieOptions { // HttpOnly, Secure, and SameSite are not set Expires = DateTimeOffset.UtcNow.AddHours(8) });});Why it matters
Without UseHttpsRedirection, HTTP requests serve responses in plaintext — an attacker on the same network (coffee shop, hotel, corporate proxy) reads everything via a passive MITM. Without HttpOnly, any XSS vulnerability exfiltrates session cookies via document.cookie. Without Secure, the browser sends the cookie over unencrypted HTTP if the user follows an http:// link. Without SameSite=Strict (or Lax), the browser attaches the cookie to cross-site requests, enabling CSRF.
How to verify
grep -rn 'UseHttpsRedirection\|CookieOptions\|SameSiteMode' --include='*.cs' .Check that:
UseHttpsRedirection()is present inProgram.cs.- Every
new CookieOptionssetsHttpOnly = true,Secure = true, andSameSite. - HSTS is enabled for production (
UseHsts()).
How to fix
var app = builder.Build();
if (!app.Environment.IsDevelopment()){ app.UseHsts();}
app.UseHttpsRedirection();For every cookie your application sets:
ctx.Response.Cookies.Append("session", token, new CookieOptions{ HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict, Expires = DateTimeOffset.UtcNow.AddHours(8)});Audit all cookie-setting code paths, not just the auth cookie. Logging cookies, analytics cookies, and anti-forgery tokens are all vectors if they lack these flags. Search for .Cookies.Append and new CookieOptions across the codebase to find them all.