Search Tech Journey

Find topics, journeys and posts

back to blog
system designintermediate 30m2026-06-10

MS Stack Ch 20 — Security baseline

OWASP Top 10, secrets management, transport security, auth hardening, supply-chain integrity, input validation, output encoding, security headers. The non-negotiable security checklist for every web app.

Chapter 20 of From Novice to Fluent on the Modern Microsoft Web Stack — a 22-chapter self-study plan.

Why this chapter

Security is not a feature you bolt on at the end. It is the cumulative effect of fifty small decisions across every chapter you have already read: how the runtime handles input, how the framework wires auth, how the deploy pipeline injects secrets, how the rollout gates on health. This chapter consolidates those decisions into one defensible checklist a backend engineer can audit a service against in an afternoon.

The shipping-grade version of this skill is "I can explain OWASP Top 10 without notes and show where each item is mitigated in an ASP.NET Core + React + Azure app". The expert-tier version is "I run threat-modelling exercises on new features, can articulate the defence-in-depth layers, and know which third-party libraries are repeatedly the source of CVEs".

Most real breaches in production web apps are not novel. They are SQL injection in a forgotten admin tool, secrets in source control, an HTTPS redirect missing on one route, a JWT validation that did not check the audience, a deserialiser that accepted arbitrary types. The defence is to know the catalogue, walk the application against it on every release, and never assume that "the framework handles it" without verifying.

You finish this chapter when you can audit any one of your services against OWASP Top 10 in a one-page document, know where each mitigation lives in your code, and explain the trust boundary that each defence enforces.

Concepts and depth

OWASP Top 10 at backend-dev depth

OWASP publishes the Top 10 web application risks every few years. The list is not a ceiling, but if you cannot mitigate every item in your application, you have not earned the right to add features. Walk through each at depth.

A03 Injection. SQL injection is the canonical case but the family includes NoSQL injection (Mongo, Cosmos), command injection (shell exec with concatenated args), LDAP injection, and template injection. The mitigation is the same shape: never concatenate untrusted input into the structural part of a query or command. In .NET, use parameterised queries (SqlCommand with Parameters.AddWithValue) or, better, an ORM (Entity Framework Core) that parameterises by default. Cosmos DB's SDK parameterises if you use WithParameter on QueryDefinition. For shell exec, use Process.Start(ProcessStartInfo) with ArgumentList not Arguments. The gotcha: people sanitise instead of parameterising, then miss an edge case (UTF-16 encoded quote, MySQL-specific escape) and ship the vulnerability anyway. Parameterisation is the only correct answer.

A07 Identification and Authentication failures. Most modern web apps delegate auth to an identity provider (Entra ID, Auth0, Okta). The remaining responsibility is validating tokens correctly and managing session lifecycle. Failures: accepting an unsigned JWT (alg: none attack), not validating the audience, not validating the issuer, not validating the signature against the correct key set, allowing token replay because there is no jti/nbf/exp check. ASP.NET Core's AddJwtBearer handles most of this if you set the options correctly; the gotcha is treating defaults as sufficient and skipping the ValidateIssuer, ValidateAudience, ValidateIssuerSigningKey, ValidateLifetime flags. All four must be true.

A02 Cryptographic failures (formerly sensitive data exposure). Sensitive data in transit must be on TLS 1.2+; in storage must be encrypted at rest with a key you can rotate. The failures are: logging sensitive fields (passwords, full credit card numbers, PII), storing passwords with weak hashing (MD5, SHA-1, plain SHA-256), using ECB block-cipher mode, hardcoding crypto keys. Use ASP.NET Core's Data Protection API for encryption at rest; never roll your own crypto.

A01 Broken Access Control. The most common breach class. The pattern: a user authenticates as Alice, then sends GET /api/orders/42 where order 42 belongs to Bob. The server returns Bob's order because the only check was "is the request authenticated" rather than "does the authenticated user own this resource". Mitigation: every resource-fetching endpoint must check ownership server-side. [Authorize] is necessary but not sufficient; you need authorisation handlers that take the resource and the user and answer "can this user access this resource?". Resource-based authorisation in ASP.NET Core (IAuthorizationService.AuthorizeAsync(user, resource, policy)) is the framework's answer. The gotcha is hiding the wrong-resource UI in the front-end and assuming the back-end is therefore safe — anyone can call the API directly.

A10 Server-Side Request Forgery (SSRF). Your service accepts a URL from user input and fetches it server-side (image upload by URL, webhook callbacks, link previews). An attacker submits http://169.254.169.254/metadata/instance (Azure IMDS) or an internal IP and gets back credentials or internal-only data. Mitigation: validate that the URL resolves to a non-private IP before fetching; disable redirects or revalidate each redirect target; ideally proxy through a service with no access to internal networks.

A08 Software and Data Integrity / Insecure Deserialisation. Accepting a serialised object from untrusted input and deserialising it into arbitrary types is a remote code execution surface. .NET's BinaryFormatter is the historical culprit (and is now obsolete; refuse to use it). JSON deserialisation with TypeNameHandling.Auto in Newtonsoft.Json is the same vulnerability with a JSON face. System.Text.Json defaults to safer behaviour; never enable polymorphic deserialisation against untrusted input.

A09 Security Logging and Monitoring failures. You cannot respond to what you cannot see. Auth failures, authorisation denials, input validation rejections, suspicious patterns — all must be logged with enough context to investigate. The gotcha: logging too much (PII, secret values) is also a failure. Strike the balance: log what an investigator needs (user ID, IP, action, outcome) without what an attacker would harvest (passwords, tokens, full payloads).

The Top 10 not deep-dived above (A04 Insecure Design, A05 Security Misconfiguration, A06 Vulnerable Components) are at least as important. A04 is "the architecture itself has a security flaw" — needs threat modelling. A05 is "the framework default is unsafe and you did not change it" — needs hardening checklists. A06 is "you depend on a library with a known CVE" — needs SBOM + dependency scanning from chapter 18.

Good enough to ship
  • • Parameterised queries everywhere; no string concat in SQL
  • [Authorize] + resource-based handlers on every protected endpoint
  • • JWT validation with all four Validate* flags on
  • • No BinaryFormatter; no polymorphic JSON deserialisation
Expert tier
  • • Threat modelling on every new feature
  • • Annual penetration test against staging
  • • Custom CodeQL queries for domain-specific anti-patterns
  • • Centralised authorisation policy with auditable rules

Secret management

The cardinal rules: never in source, never in logs, never in error messages. Source control is forever; once a secret hits Git, treat it as compromised even if you immediately delete the commit (history is preserved, mirrors exist, indexers have already scraped it). Rotate it.

The mechanism is Azure Key Vault plus managed identity. Your app authenticates to KV using its managed identity (no client secret stored anywhere); KV returns the secret; the app uses it. For values referenced in App Service or Functions configuration, use Key Vault references in app settings: @Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/MySecret/). The platform fetches the secret at startup and refreshes on a schedule. Rotate the secret in KV, restart (or wait for the refresh), and the app picks up the new value with no code or config change.

Rotation is the often-skipped part. A secret that has not been rotated in two years is effectively a permanent credential. Build rotation pipelines for every secret with a non-trivial blast radius. Database connection strings, third-party API keys, signing keys — each should have an automated pipeline that creates a new value at the source, writes it to KV, and waits for app refresh. Manual rotation is where rotation goes to die.

Logging: pipelines, application logs, and exception messages are all places secrets can leak. Azure DevOps redacts secret variables in logs as ***, but if your code writes a secret to a file or sends it to a third-party service (an APM, a logging aggregator), the redaction does not follow. ASP.NET Core's logging will happily serialise an object containing a connection string into a log entry unless you mark it with [LoggerMessage]-style structured-logging discipline. Audit your log output for the patterns of secret leakage; CodeQL has queries for this.

Good enough to ship
  • • All secrets in Key Vault; app uses managed identity
  • • Key Vault references in app settings
  • • CredScan in pipeline blocks secret commits
  • • Quarterly access review of who can read each KV
Expert tier
  • • Automated rotation for every secret with a non-trivial blast radius
  • • Soft-delete + purge protection on every KV
  • • Logging pipelines audited for secret leakage
  • • Just-in-time access for human readers; no standing access

Transport security

Every request and response is plaintext until you encrypt it. The defaults you must enforce: HTTPS only, HSTS, TLS 1.2 minimum (1.3 preferred), modern cipher suites.

App Service has httpsOnly: true (Bicep properties.httpsOnly) — set it; it forces an HTTP-to-HTTPS redirect at the platform level. In ASP.NET Core, app.UseHttpsRedirection() does the same in code; either layer alone is enough, both is belt-and-braces.

HSTS (HTTP Strict Transport Security) is a response header that tells the browser "for the next N seconds, never request this domain over HTTP, even if the user typed http://". app.UseHsts() adds it; in production, configure a max-age of at least 6 months and includeSubDomains; preload. Submitting to the HSTS preload list bakes the rule into the browser itself, eliminating the first-request window.

TLS version is set at the platform layer. App Service's minTlsVersion: '1.2' (Bicep) rejects any handshake from clients that cannot negotiate 1.2+. Application Gateway and Front Door have similar controls. Audit these settings on every environment; the default may not be what you want.

Certificates: App Service has free managed certificates for custom domains; for anything else, use Key Vault-backed certificates with automated renewal. Manual cert renewal is the single most common reason a service unexpectedly stops serving in the middle of the night.

Good enough to ship
  • • httpsOnly + minTlsVersion 1.2 in Bicep
  • • UseHttpsRedirection + UseHsts in Program.cs
  • • Managed certs on custom domains, automated renewal
Expert tier
  • • TLS 1.3 enforced where supported
  • • HSTS preload list submission
  • • Periodic SSL Labs scan; A+ as the baseline
  • • Certificate Transparency monitoring for unauthorised certs

Auth hardening: token validation in depth

Every byte of a JWT must be validated. The four checks AddJwtBearer performs when configured correctly:

  1. Signature validation — recompute the signature with the issuer's public key (from the JWKS endpoint) and compare. If the alg is none, reject; if the kid does not match a key in the JWKS, reject; if the signature is invalid, reject.
  2. Issuer validation — the iss claim must match the expected issuer URL. Defends against tokens minted by a different IdP.
  3. Audience validation — the aud claim must include your API's identifier. Defends against tokens minted for a different application sharing the same IdP.
  4. Lifetime validationnbf (not before) and exp (expiry) must bracket the current time, with a small clock-skew allowance (default 5 minutes).
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(opts =>
    {
        opts.Authority = "https://login.microsoftonline.com/<tenantId>/v2.0";
        opts.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = "https://login.microsoftonline.com/<tenantId>/v2.0",
            ValidateAudience = true,
            ValidAudience = "api://my-api-app-id",
            ValidateIssuerSigningKey = true,
            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromMinutes(2)
        };
    });

Tenant allow-list is a fifth check often needed in multi-tenant scenarios: the tid claim must be in your allowed-tenants list. This defends against the case where you accept Entra tokens but only customers from specific tenants should have access.

Clock skew trips people up. The default 5 minutes is generous and assumes nodes do not all have NTP synced perfectly. Lower it for stricter security (2 minutes is reasonable); zero it only if you control the entire pipeline of clocks. Setting it too low produces flaky "token expired" failures during normal use.

The most important rule: never disable any of the four validations to fix a test. If your test breaks because of audience validation, the test was sending the wrong token. Disabling validation in production is a CVE waiting to be written.

Good enough to ship
  • • Four Validate* flags on; valid issuer + audience pinned
  • • ClockSkew explicit (not relying on default)
  • [Authorize] on every endpoint; opt-out endpoints explicit
Expert tier
  • • Tenant allow-list enforced
  • • Custom token validators for domain claims (role, scope)
  • • Token-replay detection via jti cache
  • • Periodic key-rotation drill

Defence in depth

The principle: no single control is sufficient. Stack them so that a bypass of one is caught by the next. Concrete layers for a typical web app:

  1. Network — Front Door / WAF in front; only HTTPS; rate limits; IP allow-list for admin endpoints.
  2. Platform — App Service httpsOnly, minTlsVersion, managed identity, no public outbound for sensitive backends.
  3. Framework — ASP.NET Core auth + authorisation, anti-forgery tokens, output encoding.
  4. Application — input validation, resource-based authz, parameterised queries.
  5. Data — encryption at rest, row-level security where applicable, key rotation.
  6. Audit — structured logs, alerting on anomalies, retention long enough for forensic review.

A SQL injection that slips through input validation should still fail because the DB query is parameterised. A leaked credential should still be limited because the principal has least-privilege RBAC. A successful exploit should still be contained because the service has no path to the data plane it does not need.

The mental model is "assume breach". When you design a feature, ask: if this layer is compromised, what does the attacker get? The answer should always be "less than they think". If the answer is "everything", add a layer.

Good enough to ship
  • • Three+ independent layers between attacker and data
  • • Least-privilege RBAC on every managed identity
  • • Anomaly alerts on auth failures, authz denials
Expert tier
  • • Red-team exercises against staging
  • • Blast-radius analysis per credential
  • • Privileged access management (PAM) for break-glass paths

Worked examples

Hardening Program.cs

var builder = WebApplication.CreateBuilder(args);

// Auth (chapter 14) with all validations on
builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(opts =>
    {
        opts.Authority = builder.Configuration["Auth:Authority"];
        opts.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuers = builder.Configuration.GetSection("Auth:ValidIssuers").Get<string[]>(),
            ValidateAudience = true,
            ValidAudiences = builder.Configuration.GetSection("Auth:ValidAudiences").Get<string[]>(),
            ValidateIssuerSigningKey = true,
            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromMinutes(2),
            RequireExpirationTime = true,
            RequireSignedTokens = true
        };
        opts.RequireHttpsMetadata = true;
    });

builder.Services.AddAuthorization(opts =>
{
    opts.FallbackPolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build();
});

// HSTS + HTTPS redirect
builder.Services.AddHsts(opts =>
{
    opts.Preload = true;
    opts.IncludeSubDomains = true;
    opts.MaxAge = TimeSpan.FromDays(365);
});

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseHsts();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();
app.Run();

What to notice:

  • FallbackPolicy = RequireAuthenticatedUser() means every endpoint requires auth unless explicitly [AllowAnonymous]. Default-deny is safer than default-allow.
  • RequireHttpsMetadata = true means OIDC discovery must use HTTPS — defends against MITM on the metadata fetch.
  • Preload = true on HSTS prepares the domain for the preload list.
  • ClockSkew explicit at 2 minutes, not the 5-minute default.

Resource-based authorisation

public class OrderOwnerRequirement : IAuthorizationRequirement { }

public class OrderOwnerHandler : AuthorizationHandler<OrderOwnerRequirement, Order>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        OrderOwnerRequirement requirement,
        Order resource)
    {
        var userId = context.User.FindFirst("oid")?.Value;
        if (userId is not null && resource.OwnerId == userId)
        {
            context.Succeed(requirement);
        }
        return Task.CompletedTask;
    }
}

// Program.cs
builder.Services.AddSingleton<IAuthorizationHandler, OrderOwnerHandler>();
builder.Services.AddAuthorization(opts =>
{
    opts.AddPolicy("OrderOwner", policy => policy.Requirements.Add(new OrderOwnerRequirement()));
});

// Controller
[HttpGet("orders/{id:guid}")]
public async Task<IActionResult> GetOrder(Guid id, [FromServices] IAuthorizationService authz)
{
    var order = await _orders.FindAsync(id);
    if (order is null) return NotFound();

    var result = await authz.AuthorizeAsync(User, order, "OrderOwner");
    if (!result.Succeeded) return Forbid();

    return Ok(order);
}

What to notice:

  • The handler takes the user AND the resource — that is the whole point.
  • Forbid() (403) when authenticated-but-not-authorised; Unauthorized() (401) when not authenticated.
  • The check runs server-side, every request, regardless of what the front end shows.
  • Returning NotFound() before the authz check leaks existence — fine here because we want users to know an order exists if they own it; in stricter contexts, return 404 even when forbidden.

Parameterised query against Cosmos DB

public async Task<List<Item>> SearchAsync(string nameFragment)
{
    var query = new QueryDefinition(
        "SELECT * FROM c WHERE CONTAINS(c.name, @fragment, true)")
        .WithParameter("@fragment", nameFragment);

    var iterator = _container.GetItemQueryIterator<Item>(query);
    var items = new List<Item>();
    while (iterator.HasMoreResults)
    {
        foreach (var item in await iterator.ReadNextAsync())
        {
            items.Add(item);
        }
    }
    return items;
}

What to notice:

  • @fragment is a placeholder; the SDK escapes the value on send.
  • The string concatenation that would yield NoSQL injection is impossible because the value never enters the query text.
  • The same pattern (parameterised) applies to SQL, MongoDB, Cypher — any query language with an SDK that supports it.

Catching a leaky log entry

// BAD: serialises whole exception including inner secrets
catch (Exception ex)
{
    _logger.LogError(ex, "Database call failed: {Details}", ex.ToString());
    throw;
}

// GOOD: structured fields, no whole-object dump
catch (SqlException ex)
{
    _logger.LogError(ex,
        "Database call failed. ErrorNumber={ErrorNumber} Server={Server}",
        ex.Number, ex.Server);
    throw;
}

What to notice:

  • The bad version's ex.ToString() includes the full stack trace; if any inner exception is a SqlException with a connection string in Data, the connection string lands in logs.
  • The good version names the fields it cares about; nothing else leaks.
  • Catch the specific exception type to avoid swallowing unrelated errors.

Hands-on exercises

  1. Goal: Audit one of your services against OWASP Top 10. Steps: (1) Print the Top 10 list. (2) Walk each item, find the code or config that mitigates it, note any gap. (3) File issues for the gaps. You're done when you have a one-page checklist with every item either marked "mitigated by X" or "issue #Y filed".

  2. Goal: Configure JWT validation with all flags on. Steps: (1) Find the AddJwtBearer call. (2) Verify ValidateIssuer, ValidateAudience, ValidateIssuerSigningKey, ValidateLifetime are all true. (3) Pin the issuer and audience. (4) Set ClockSkew explicitly. (5) Run integration tests; they should still pass. You're done when the call matches the worked example above.

  3. Goal: Replace a string-concat SQL with parameterised. Steps: (1) Search for string.Format or $"SELECT ... {var}" patterns in any query. (2) Replace with SqlCommand + Parameters.AddWithValue or with EF Core. (3) Add a CodeQL query (or rule) to fail the build if the pattern returns. You're done when no concat-based SQL exists and the build will catch regressions.

  4. Goal: Add a resource-based authz handler. Steps: (1) Pick a resource type users own (orders, documents, etc.). (2) Write a requirement + handler. (3) Apply via IAuthorizationService.AuthorizeAsync in the controller. (4) Write tests proving Alice cannot fetch Bob's resource. You're done when cross-user fetch returns 403.

  5. Goal: Wire Key Vault references. Steps: (1) Create a Key Vault, put a secret in it. (2) Give the App Service's managed identity Key Vault Secrets User. (3) In app settings, reference @Microsoft.KeyVault(SecretUri=...). (4) Restart the app; verify it reads the secret. (5) Rotate the secret in KV; verify the app picks up the new value (may need restart). You're done when the secret value never appears in code or pipeline.

  6. Goal: Enable HSTS preload and verify. Steps: (1) Configure HSTS with Preload = true, MaxAge >= 1 year, IncludeSubDomains = true. (2) Deploy. (3) Test the response header with curl -I. (4) Submit the domain to https://hstspreload.org/. You're done when the header is set and the preload submission validates.

Self-check questions

  1. Name the OWASP Top 10 items in order, with a one-sentence example each.
  2. Why does parameterisation beat sanitisation for SQL?
  3. What are the four Validate* flags on TokenValidationParameters, and what does each defend against?
  4. Why is ValidateIssuerSigningKey = false dangerous?
  5. What is broken access control, and how does resource-based authz prevent it?
  6. Describe SSRF and the defence against it.
  7. What is BinaryFormatter and why must you never use it on untrusted input?
  8. Why are Key Vault references in app settings safer than embedding secrets in app settings?
  9. Explain HSTS and the preload list. What does preload solve that the header alone does not?
  10. What is defence in depth? Give three layers you would stack for a public API.
  11. Why log auth failures, and what fields should each log entry include?
  12. Walk through the rotation pipeline for a third-party API key in Key Vault.

High-signal resources

Official docs

Books or courses

  • The Web Application Hacker's Handbook — Stuttard & Pinto. Old but the structure still teaches.
  • Threat Modeling: Designing for Security — Adam Shostack.

Practitioner posts

Weekly milestones

  1. Day 1: Read the OWASP Top 10 current edition. Map each item to your stack. Answer self-check 1, 2, 5.
  2. Day 2: Audit JWT validation in your service; pin all four flags. Answer self-check 3, 4.
  3. Day 3: Add resource-based authz on one endpoint; write the cross-user test. Answer self-check 6, 7.
  4. Day 4-5: Move every secret to Key Vault references; enable CredScan in pipeline. Answer self-check 8, 12.
  5. Day 6-7: Enable HSTS preload; SSL Labs scan to A+; document defence-in-depth layers for your service. Answer self-check 9, 10, 11.

How it shows up in the capstone

The capstone service is hardened to this baseline by construction. Program.cs pins all four JWT validation flags, enforces HTTPS, sets HSTS with preload, and uses default-deny authorisation via FallbackPolicy. Every endpoint that fetches a resource owned by a user goes through a resource-based authz handler. Every DB query is parameterised, every secret is a Key Vault reference, and every managed identity has least-privilege RBAC scoped to its purpose.

The pipeline (chapter 18) injects CodeQL, CredScan, dependency review, container scanning, and SBOM. The infra (chapter 19) enforces httpsOnly, minTlsVersion: '1.2', no public outbound for sensitive backends, and managed identities throughout. When a future engineer adds a feature, the threat-model template lives in the repo to prompt the conversation before the code lands.

When you can audit the capstone against the OWASP Top 10 in one sitting and find no gaps, the chapter has landed.


Previous chapter → Ch 19 — IaC + rollout orchestration
Next chapter → Ch 21 — Testing strategy