06 · Security Best Practices¶
Module 02 covered authentication mechanics. This module covers the broader set of defenses every production .NET web app needs: password storage, input validation against injection, secrets management, and the standard security headers.
Password hashing — never store plaintext or use a fast hash¶
using Microsoft.AspNetCore.Identity;
public class PasswordService
{
private readonly PasswordHasher<object> _hasher = new();
public string Hash(string password) => _hasher.HashPassword(new object(), password);
public bool Verify(string hashedPassword, string providedPassword) =>
_hasher.VerifyHashedPassword(new object(), hashedPassword, providedPassword)
is PasswordVerificationResult.Success or PasswordVerificationResult.SuccessRehashNeeded;
}
var service = new PasswordService();
var hash = service.Hash("correct-horse-battery-staple");
// hash looks like: "AQAAAAIAAYagAAAAEL3f9..." — salted, versioned, one-way
bool ok = service.Verify(hash, "correct-horse-battery-staple"); // true
bool bad = service.Verify(hash, "wrong-password"); // false
PasswordHasher<T> uses PBKDF2 with a random salt per password and a high
iteration count by default — deliberately slow, unlike SHA256, which is
fast and therefore bad for passwords (fast hashes let an attacker who steals
the hash database try billions of guesses per second on cheap hardware).
SuccessRehashNeeded signals the hasher's parameters were upgraded since
this hash was created — re-hash and store the new value on next successful
login.
Preventing SQL injection¶
// VULNERABLE — string concatenation lets an attacker inject SQL
string sql = $"SELECT * FROM Users WHERE Name = '{userInput}'";
// userInput = "x' OR '1'='1" turns this into a query that returns every row
// SAFE — EF Core LINQ is parameterized automatically
var users = db.Users.Where(u => u.Name == userInput).ToList();
// SAFE — raw SQL with parameters, when you need raw SQL
var users2 = db.Users.FromSqlInterpolated($"SELECT * FROM Users WHERE Name = {userInput}").ToList();
FromSqlInterpolated looks like string interpolation but actually builds a
parameterized query under the hood — userInput is passed to the database
as a parameter value, never spliced into the SQL text, so it can't change
the query's structure no matter what characters it contains. Plain
FromSqlRaw with manually concatenated strings reintroduces the same
vulnerability as the first example — never build SQL text from untrusted
input.
Preventing XSS (Cross-Site Scripting)¶
// Razor views auto-encode by default:
// @Model.Comment -- HTML-encoded automatically, safe even if Comment contains "<script>"
// DANGEROUS — explicitly opting out of encoding
// @Html.Raw(Model.Comment) -- only ever safe if Comment is from a trusted source
For APIs returning JSON (not rendering HTML), XSS is primarily the
consuming frontend's responsibility to encode on render — but an API
should still validate/sanitize any field that might later be rendered as
HTML somewhere downstream, and never reflect unescaped user input into an
text/html response.
Input validation¶
public record CreateUserRequest(
[property: Required, EmailAddress] string Email,
[property: Required, MinLength(8)] string Password,
[property: Range(13, 120)] int Age);
app.MapPost("/users", (CreateUserRequest request) =>
{
var context = new ValidationContext(request);
var results = new List<ValidationResult>();
if (!Validator.TryValidateObject(request, context, results, validateAllProperties: true))
return Results.BadRequest(results.Select(r => r.ErrorMessage));
return Results.Created("/users/1", new { request.Email });
});
Validate at the boundary, before the data touches business logic or
storage — [EmailAddress], [Range], and friends from
System.ComponentModel.DataAnnotations catch malformed input cheaply and
consistently rather than relying on ad hoc checks scattered through the
codebase.
Secrets management¶
# Local development — never commit secrets to source control
dotnet user-secrets init
dotnet user-secrets set "ConnectionStrings:Default" "Server=...;Password=devonly"
var builder = WebApplication.CreateBuilder(args);
// User secrets are loaded automatically in Development via
// builder.Configuration, no extra code needed.
string? connectionString = builder.Configuration.GetConnectionString("Default");
In production, secrets come from a managed vault (Azure Key Vault, AWS
Secrets Manager, HashiCorp Vault) injected as environment variables or
mounted files at deploy time — never from appsettings.json checked into
git, and never hardcoded, even "temporarily."
// Production: Azure Key Vault as a configuration source
builder.Configuration.AddAzureKeyVault(
new Uri("https://my-vault.vault.azure.net/"),
new DefaultAzureCredential());
Security headers¶
app.Use(async (context, next) =>
{
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
context.Response.Headers["X-Frame-Options"] = "DENY";
context.Response.Headers["Content-Security-Policy"] = "default-src 'self'";
context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
await next();
});
app.UseHsts(); // Strict-Transport-Security, production only
app.UseHttpsRedirection();
X-Content-Type-Options: nosniff stops browsers from guessing (and
potentially misinterpreting) content types; X-Frame-Options: DENY blocks
clickjacking via iframe embedding; a restrictive Content-Security-Policy
limits what scripts/styles/resources a page is even allowed to load,
containing the blast radius of any XSS that does slip through.
CORS — only as permissive as needed¶
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowedOrigins", policy =>
policy.WithOrigins("https://app.example.com")
.AllowAnyMethod()
.AllowAnyHeader());
});
app.UseCors("AllowedOrigins");
AllowAnyOrigin() (permitting every origin) combined with credentials is
outright rejected by browsers for good reason — it would let any site on
the internet make authenticated requests to your API using a logged-in
user's cookies. Always name specific trusted origins.
How It Actually Works¶
Password hashing algorithms like bcrypt/Argon2 are deliberately slow because they run a tunable number of internal rounds — each one mixing the password with a per-user salt through a memory-hard function, so brute-forcing means paying that same cost per guess with no shortcut (unlike a fast hash like SHA-256, which an attacker with a GPU farm can try billions of times a second). SQL injection defenses work because a parameterized query sends the SQL text and the user's data over the wire separately — the database compiles the query plan from the fixed SQL first, then binds the parameters as pure data, so there's no way for a malicious string to be reinterpreted as a command token. CORS is not a security boundary for your server (a server-to-server request never triggers it) — it's a lock the browser enforces on behalf of the user, checking your Access-Control-Allow-Origin response header before letting the calling page's JavaScript read the response; without that header allowing it, the request often still runs on the server, only the browser blocks the JS from seeing the result.
Exercise¶
Add proper authentication to the Level 3 REST API project: hash and store a
password with PasswordHasher<T> instead of the hardcoded check from module
02's login example, validate CreateUserRequest-style input with data
annotations on the book-creation endpoint (non-empty title, year in a sane
range — module 10 already does this manually; convert it to attributes),
and add the security headers middleware above. Write an integration test
confirming a request with Content-Security-Policy and X-Frame-Options
headers present on every response.