Skip to content

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:

Terminal window
grep -rniE 'password=|pwd=|secret=|apikey=' --include='*.config' --include='*.json' .

Search git history for credentials that were committed and later removed:

Terminal window
git log --all --full-history --source -p -- web.config appsettings.json appsettings.*.json \
| grep -iE 'password|pwd|secret|apikey|api_key|connectionstring' | head -50

How 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):

Program.cs
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.Identity
Install: dotnet add package Azure.Extensions.AspNetCore.Configuration.Secrets

Rotate 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>
Login.aspx.cs
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

Terminal window
grep -rnE '<authentication[^>]*mode="Forms"' --include='*.config' .
Terminal window
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):

Program.cs
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):

Program.cs
builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd"));
Install: dotnet add package Microsoft.Identity.Web
Install: dotnet add package Microsoft.Identity.Web.UI

Migrating 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 key

Why 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

Terminal window
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 caseReplace with
Password hashingPasswordHasher<TUser> (ASP.NET Core Identity)
General hash (integrity check)SHA256.HashData(data) or SHA3_256.HashData(data) (.NET 8+)
HMAC (message authentication)HMACSHA256
Symmetric encryptionAesGcm (authenticated encryption, prevents tampering)
Key derivationRfc2898DeriveBytes with HashAlgorithmName.SHA256 and iterations: 600_000

AES-GCM example:

using System.Security.Cryptography;
byte[] key = RandomNumberGenerator.GetBytes(32); // 256-bit
byte[] nonce = RandomNumberGenerator.GetBytes(AesGcm.NonceByteSizes.MaxSize); // 12 bytes
byte[] tag = new byte[AesGcm.TagByteSizes.MaxSize]; // 16 bytes
byte[] 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 data
var formatter = new BinaryFormatter();
var obj = formatter.Deserialize(networkStream); // attacker controls the stream
// JavaScriptSerializer — no type allowlist
var 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

Terminal window
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 resolution
var 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-Options
app.MapControllers();
app.Run();

Why it matters

Each missing header enables a distinct browser-side attack class:

HeaderMitigates
Strict-Transport-SecurityHTTPS downgrade / SSL stripping (MITM)
Content-Security-PolicyCross-site scripting (XSS)
X-Frame-OptionsClickjacking
X-Content-Type-OptionsMIME-sniffing drive-by downloads
Referrer-PolicyURL 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

Terminal window
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

Program.cs
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

Terminal window
dotnet list package --vulnerable --include-transitive

This 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:

.github/workflows/ci.yml
- 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
fi

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

Terminal window
grep -rn 'UseHttpsRedirection\|CookieOptions\|SameSiteMode' --include='*.cs' .

Check that:

  1. UseHttpsRedirection() is present in Program.cs.
  2. Every new CookieOptions sets HttpOnly = true, Secure = true, and SameSite.
  3. HSTS is enabled for production (UseHsts()).

How to fix

Program.cs
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.