Most .NET breaches don't come from clever zero-days in the CLR. They come from a NuGet package nobody audited, a BinaryFormatter call somebody forgot to remove, a connection string committed to git, or an ASP.NET Core app shipped with debug settings still on. The .NET platform gives you strong primitives, but the defaults are tuned for developer convenience, not adversarial production traffic. This guide walks through the .NET security practices that actually change your risk profile in 2026, grouped by the questions engineering and security teams ask most often, with the exact flags, APIs, and version numbers you need.
What is the biggest security risk in a typical .NET application?
The biggest risk is the dependency tree, not your own code. A modern ASP.NET Core service pulls in dozens of transitive NuGet packages, and a single vulnerable one is enough. The SolarWinds SUNBURST compromise of 2020 was a C#/.NET supply-chain attack: malicious code was planted in the Orion build pipeline and signed with a legitimate certificate, so every downstream customer trusted it. That incident reframed .NET security around provenance, not just published CVEs.
Enable NuGet audit and treat its warnings as build errors. Since .NET 8, dotnet restore runs NuGet Audit automatically against the GitHub Advisory Database, and .NET 9 extended auditing to transitive dependencies by default. Turn the warnings into failures in CI:
<PropertyGroup>
<NuGetAudit>true</NuGetAudit>
<NuGetAuditMode>all</NuGetAuditMode>
<NuGetAuditLevel>low</NuGetAuditLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
Run an explicit vulnerability listing in the pipeline so the failure is loud and legible:
dotnet list package --vulnerable --include-transitive
Pairing NuGet audit with continuous software composition analysis for .NET dependencies closes the gap between "a CVE was published" and "someone noticed."
How do you stop insecure deserialization in .NET?
You stop it by never deserializing untrusted data with type-aware formatters. BinaryFormatter is the classic offender — it can reconstruct arbitrary object graphs and trigger gadget chains that end in remote code execution. Microsoft obsoleted it (warning SYSLIB0011), disabled it by default, and in .NET 9 removed the implementation entirely so it throws unless you pull an out-of-support compatibility shim. Do not pull the shim.
The subtler trap is Newtonsoft.Json with polymorphic type handling turned on:
// Dangerous: an attacker controls which .NET type gets instantiated
var settings = new JsonSerializerSettings {
TypeNameHandling = TypeNameHandling.All
};
var obj = JsonConvert.DeserializeObject<Payload>(userInput, settings);
TypeNameHandling.All, .Auto, and .Objects let the JSON itself name the CLR type to build, which is functionally the same RCE risk as BinaryFormatter. Prefer System.Text.Json, the default in ASP.NET Core, which does not resolve arbitrary types by name. Where you truly need polymorphism, use its opt-in [JsonDerivedType] attributes with an explicit discriminator allow-list rather than free-form type names.
How should .NET applications handle secrets?
They should load secrets at runtime from a managed store and keep them out of source entirely. appsettings.json, appsettings.Production.json, and web.config are the wrong place for connection strings and API keys because they end up in git history and container image layers.
For local development, use the Secret Manager, which stores values outside the project directory:
dotnet user-secrets init
dotnet user-secrets set "Db:ConnectionString" "Server=...;Password=..."
Note that user secrets are stored in plaintext in your profile and are strictly a dev convenience — never a production mechanism. In production, bind a real vault through the configuration system, using a managed identity so no bootstrap secret is needed:
builder.Configuration.AddAzureKeyVault(
new Uri("https://my-vault.vault.azure.net/"),
new DefaultAzureCredential());
Enforce this with a pre-commit secret scanner (gitleaks or trufflehog) so a mistyped credential never reaches a remote branch — remediation after a push means rotating everything, since git keeps the old value forever.
What cryptography defaults should .NET developers change?
Change three: your randomness source, your password storage, and your symmetric mode. Use RandomNumberGenerator, never System.Random, for anything security-relevant like tokens or salts. For passwords, use PBKDF2 through the built-in helper (or ASP.NET Core Identity's PasswordHasher<T>, which wraps it), never a raw SHA-256:
byte[] salt = RandomNumberGenerator.GetBytes(16);
byte[] hash = Rfc2898DeriveBytes.Pbkdf2(
password, salt, iterations: 210_000,
HashAlgorithmName.SHA256, outputLength: 32);
For symmetric encryption, use authenticated encryption with AesGcm rather than plain AesCbc, and never reuse a nonce with the same key. Let the OS negotiate TLS — don't hardcode SslProtocols to a specific version, since pinning old code to TLS 1.0/1.1 is a recurring downgrade problem.
How do you harden ASP.NET Core for production?
You harden it by turning off developer conveniences and turning on transport and header protections. The essentials:
| Control | How | Why |
|---|---|---|
| HTTPS + HSTS | app.UseHttpsRedirection() + app.UseHsts() | Blocks downgrade and cleartext leaks |
| Antiforgery | app.UseAntiforgery() + [ValidateAntiForgeryToken] | Stops CSRF on cookie-auth flows |
| No stack traces | app.UseExceptionHandler("/error") in prod | Avoids leaking types and paths |
| Rate limiting | app.UseRateLimiter() (built in since .NET 7) | Blunts brute force and scraping |
| Non-root container | USER app in the Dockerfile | Limits breakout blast radius |
| CSP header | Middleware setting Content-Security-Policy | Defense-in-depth against XSS |
Run a dynamic scan against the running app after deploying to staging to confirm the headers actually landed — configuration drift between profiles is one of the most common ways a hardened setting silently disappears in production.
What build-pipeline controls reduce .NET supply-chain risk?
Pin and verify what enters your build. Enable a lock file so restores are reproducible and a swapped mirror can't slip in a different artifact:
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
Then restore with dotnet restore --locked-mode in CI. Add packageSourceMapping in nuget.config (available since NuGet 6.0) so internal package names always resolve to your private feed — this is the direct defense against the dependency-confusion class of attacks that Alex Birsan demonstrated in 2021. Generate a CycloneDX or SPDX SBOM on every build and keep it, so when the next widely-exploited CVE lands you can answer "are we affected?" in minutes with a generated SBOM instead of grepping repos by hand.
A .NET security checklist
- NuGet audit on, warnings as errors, transitive mode
all dotnet list package --vulnerablegate in CI- No
BinaryFormatter;System.Text.JsonoverTypeNameHandling - Secrets in a vault via managed identity; secret scanning on commits
RandomNumberGenerator+ PBKDF2/AesGcm; OS-negotiated TLS- HTTPS, HSTS, antiforgery, rate limiting, CSP, non-root containers
- Lock files,
--locked-mode, package source mapping, an SBOM per build
How Safeguard Helps
Safeguard turns this checklist into an enforced pipeline instead of a wiki page. It continuously scans your NuGet dependency tree — direct and transitive — and uses reachability analysis to tell you which of the flagged CVEs actually touch a code path your app executes, so your team patches the handful that matter rather than triaging every advisory equally. Griffin AI reads the vulnerable method, traces it through your controllers and services, and explains in plain language whether a deserialization or injection finding is exploitable in your specific configuration. When a fix exists, Safeguard's auto-fix engine opens a pull request with the minimal version bump already applied and the SBOM regenerated, and you can drive the whole loop from the Safeguard CLI inside your existing GitHub Actions or Azure DevOps pipeline.
Start free at app.safeguard.sh/register, check what's included on the Safeguard pricing page, or read the integration guides at docs.safeguard.sh.