Web security basics
How an ASP.NET Core web app recognizes the user and decides what they may do, which vulnerabilities show up most often, and which techniques and settings close them. Security conversations in interviews usually start here before moving on to OAuth, JWT and corporate sign-in.
Before this topic: Hosting and the pipeline.
How does authentication differ from authorization, and how does an app “remember” the user: a cookie session or a token?
Section titled “How does authentication differ from authorization, and how does an app “remember” the user: a cookie session or a token?”What it is
- Authentication — checking who the user is: password, certificate, Windows account.
- Authorization — checking what they’re allowed to do: a role, access to a specific document.
How the app remembers the user
HTTP keeps no state between requests, so after sign-in the server hands out a “pass”:
- cookie — the browser attaches it to every request to that site on its own;
- token — the client adds it to the
Authorization: Bearer ...header itself.
How to use
- Register a scheme:
AddAuthentication().AddCookie(). - After checking the password, call
HttpContext.SignInAsync— it issues the cookie. - Protect the endpoint with
[Authorize]orRequireAuthorization().
Common mistake
Mixing up the status codes. 401 means “I don’t know who you are, sign in”, 403 means “I know you, but no”.
Takeaway: authentication identifies the user, authorization decides their rights, and a cookie or token carries the sign-in result between requests.
How it works
- A scheme is a name, a handler and its options.
AddCookie,AddJwtBearerandAddNegotiateregister schemes; a single scheme becomes the default. UseAuthenticationcalls Authenticate on the default scheme and puts the result intoHttpContext.User. It doesn’t reject anonymous requests.UseAuthorizationreads[Authorize]and the endpoint’s policies. Not signed in — Challenge; signed in without permission — Forbid.
How schemes respond
| Action | Cookie | JWT Bearer |
|---|---|---|
| Challenge | 302 to the login page | 401 and WWW-Authenticate: Bearer |
| Forbid | 302 to the “access denied” page | 403 |
When to choose what
- Cookie — server-rendered UI (MVC, Razor Pages, Blazor Server) and SPAs on the same domain.
- Bearer token — APIs for external, mobile and server-side clients.
Pitfalls
- Since .NET 10 the cookie scheme returns 401 and 403 without a redirect on API endpoints (
[ApiController], Minimal APIs with JSON, SignalR). Before that,fetchgot the login page HTML after the redirect. WebApplicationaddsUseAuthenticationandUseAuthorizationright afterUseRoutingon its own. WithUseCorsyou call them explicitly, after it.- Authentication alone protects nothing: without
[Authorize]or a global policy the endpoint is open.
Takeaway: the scheme identifies the user and shapes the refusal, while authorization decides when to refuse.
Under the hood
- The cookie scheme serializes the
AuthenticationTicket— claims and properties — encrypts it with Data Protection and writes it to the.AspNetCore.Cookiescookie. The server stores nothing. - By default the cookie is
HttpOnly,SameSite=Laxand lives 14 days with sliding expiration. A large cookie is split into chunks but still goes out with every request. - Nodes in a farm must share Data Protection keys, otherwise a cookie issued by one node can’t be decrypted by another.
Trade-offs
| Property | Cookie | Bearer token |
|---|---|---|
| Who sends it | the browser itself | client code |
| Main risk | CSRF | theft by a script on the page |
| Revocation before expiry | a per-request check or ITicketStore |
short lifetime and a refresh token |
- A locked-out user keeps working while their cookie is alive. Identity handles this with a security stamp check (
SecurityStampValidator, every 30 minutes by default); a custom scheme uses theOnValidatePrincipalevent. ITicketStoremoves the ticket to the server: the cookie is small and revocation is instant, but you get state and a store round trip on every request.- A SPA without tokens in the browser is the BFF pattern, covered in OAuth 2.0 and OpenID Connect.
How to check in production
- The .NET 10 metrics
aspnetcore.authentication.challenges,aspnetcore.authentication.forbidsandaspnetcore.authorization.attemptsshow refusals by scheme and policy. - Users being signed out en masse after a deployment means the nodes have different Data Protection keys.
Takeaway: a cookie and a token are both passes without server state, and instant revocation always costs either state or a short lifetime.
using System.Security.Claims;using Microsoft.AspNetCore.Authentication;using Microsoft.AspNetCore.Authentication.Cookies;
var builder = WebApplication.CreateBuilder(args);
// A single scheme becomes the default schemebuilder.Services.AddAuthentication().AddCookie();builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication(); // fills HttpContext.Userapp.UseAuthorization(); // checks the endpoint's requirements
app.MapPost("/login", async (HttpContext ctx) =>{ // The password is already verified: issue a cookie with an encrypted ticket var identity = new ClaimsIdentity( [new Claim(ClaimTypes.Name, "alice")], CookieAuthenticationDefaults.AuthenticationScheme); await ctx.SignInAsync(new ClaimsPrincipal(identity));});
app.MapGet("/me", (ClaimsPrincipal user) => new { name = user.Identity!.Name }) .RequireAuthorization(); // no cookie — 401, not a redirect (.NET 10)app.MapGet("/admin", () => new { secret = 42 }) .RequireAuthorization(p => p.RequireRole("Admin")); // alice without the Admin role — 403
app.Run();- What does an anonymous user get from a protected endpoint? — It depends on the scheme: JWT — 401 with
WWW-Authenticate, cookie — a redirect to sign-in, and on API endpoints since .NET 10 — 401. - Where is the session stored with cookie authentication? — Nowhere: the encrypted ticket lives in the cookie itself; server-side storage goes through
ITicketStore. - How do you sign a user out on all devices? — Change the security stamp that
SecurityStampValidatorchecks, or keep tickets on the server. - How does
AddSessiondiffer from cookie authentication? — Session stores data on the server under an ID in a cookie and doesn’t prove identity. - Why have several schemes in one app? — For example, cookies for the UI and JWT for the API; a policy or
[Authorize(AuthenticationSchemes = ...)]picks the right one.
What is the OWASP Top 10, and which vulnerabilities in it matter most?
Section titled “What is the OWASP Top 10, and which vulnerabilities in it matter most?”What it is
OWASP (Open Worldwide Application Security Project) is an open application security community. The Top 10 is its ranking of the main web application risks, updated every few years: 2017, 2021, 2025.
Top five in 2025
- A01 Broken Access Control — a user gets someone else’s data or functions.
- A02 Security Misconfiguration — dangerous settings: detailed errors in production, exposed service endpoints.
- A03 Software Supply Chain Failures — vulnerable or tampered packages and builds.
- A04 Cryptographic Failures — unencrypted data, weak algorithms.
- A05 Injection — SQL injection, XSS, command injection.
Common mistake
Assuming the framework protects against everything. An ORM closes SQL injection but won’t check that an order belongs to the current user.
Takeaway: the Top 10 is a map of the most common risks, and access control comes first.
How it works
A category is a group of weaknesses from the CWE catalog. Its rank depends on frequency and impact in testing data, and two categories are added by the practitioner survey.
Risk — example — defense in .NET
| Risk | Example | Defense in .NET |
|---|---|---|
| A01 Access control | GET /orders/43 returns someone else’s order |
owner check, [Authorize], FallbackPolicy |
| A02 Misconfiguration | developer exception page in production | UseExceptionHandler, environment checks |
| A03 Supply chain | a package with a known CVE | NuGetAudit, lock file, Package Source Mapping |
| A04 Cryptography | password in SHA-256, site over HTTP | PasswordHasher, HTTPS and HSTS, Data Protection |
| A05 Injection | SQL built by string concatenation | parameters, FromSql, Razor encoding |
| A06 Insecure design | password reset without an attempt limit | threat modeling, rate limiting |
| A07 Authentication | password brute force | Identity lockout, MFA |
| A08 Integrity | BinaryFormatter, unsigned updates |
System.Text.Json, signed artifacts |
| A09 Logging | failed sign-ins don’t reach the log | security events in logs and alerts |
| A10 Exceptional conditions | a failed permission check lets the request through | fail closed, responses without details |
Pitfalls
- In the 2021 edition SSRF was its own A10, and vulnerable components were A06. Category numbers in older reports don’t match 2025.
[Authorize]checks that the user signed in, not that the resource is theirs. A01 is closed by a check in code.
Takeaway: almost every category has a framework defense, but access control and design are closed only by your own code.
Under the hood
- 2025 is the eighth edition. Eight categories are chosen from data and two from the survey, to capture risks the data doesn’t show yet.
- Categories became broader and closer to root causes. The supply chain covers not just known CVEs but CI/CD, and A10 covers failing open and leaking error details.
Trade-offs
- The Top 10 is an awareness document. For requirements and acceptance OWASP offers ASVS, for testing — WSTG.
- Injection dropped in the ranking largely thanks to ORMs and template engines. What remains is what the framework can’t know: who owns a resource and which actions are allowed.
- Since .NET 10
NuGetAuditchecks transitive packages by default. New warnings can break a build withTreatWarningsAsErrors— triage them rather than silencing them. - A
FallbackPolicywithRequireAuthenticatedUser()closes everything by default: a forgotten[Authorize]stops being a hole, and public endpoints are marked[AllowAnonymous]explicitly.
How to check in production
- In CI — Roslyn security analyzers (
CA2100,CA5350,CA5351) anddotnet list package --vulnerable --include-transitive; on staging — DAST such as ZAP. - Spikes of 403 and 404 while IDs are being enumerated in the logs are a sign of someone probing for IDOR.
Takeaway: the Top 10 sets priorities, not completeness: the framework closes injection, while access, configuration and dependencies remain a team process.
using System.Security.Claims;using Microsoft.AspNetCore.Authorization;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddCookie();// Everything is closed by default: public endpoints are marked AllowAnonymousbuilder.Services.AddAuthorizationBuilder() .SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());builder.Services.AddSingleton<OrderStore>();
var app = builder.Build();
app.MapGet("/orders/{id:int}", (int id, ClaimsPrincipal user, OrderStore store) =>{ var order = store.Find(id); // A01: the policy checked sign-in, we check the owner ourselves return order is not null && order.OwnerId == user.Identity?.Name ? Results.Ok(order) : Results.NotFound(); // 404, not 403: don't confirm that someone else's order exists});
app.Run();
public record Order(int Id, string OwnerId, decimal Total);
public class OrderStore{ private readonly Dictionary<int, Order> _orders = new() { [42] = new(42, "alice", 100m) }; public Order? Find(int id) => _orders.GetValueOrDefault(id);}- What changed in the 2025 edition compared with 2021? — SSRF moved into A01, the supply chain (A03) and exceptional conditions (A10) appeared, and misconfiguration rose to second place.
- Why is XSS a kind of injection? — User data ends up in HTML or JavaScript and becomes code; since 2021 XSS is part of Injection.
- What is IDOR and how do you find it? — Accessing someone else’s object by changing an id; you find it with tests where user A requests user B’s resource.
- How does the Top 10 differ from ASVS? — The Top 10 is an awareness ranking of risks; ASVS is a verifiable list of requirements by level.
- How do you track vulnerable packages in .NET? —
NuGetAuditduring restore anddotnet list package --vulnerable --include-transitive.
How do XSS and CORS work, and how do you defend against them?
Section titled “How do XSS and CORS work, and how do you defend against them?”What it is
- XSS (cross-site scripting) — an attacker leaves
<script>...</script>in a comment, and the site shows the comment without encoding. The script runs for every reader and acts on their behalf. - CORS — permission from the server. A script on
https://shop.examplecan read a response fromhttps://api.exampleonly if the API returns a matchingAccess-Control-Allow-Origin.
How to use
- Output data in Razor with
@: it turns<into<. Don’t useHtml.Rawwith user data. - Set the
HttpOnlyandSecureflags on cookies. - In an API, allow CORS only for your own front ends:
policy.WithOrigins("https://shop.example").
Common mistake
Treating CORS as API protection. It restricts only the browser; curl or an attacker’s server calls the API with no CORS at all.
Takeaway: output encoding protects against XSS, while CORS merely lets the browser hand responses to other sites.
How it works: XSS
| Type | Where the script comes from |
|---|---|
| Stored | from the database: a comment, a profile name |
| Reflected | from the request: a ?q= parameter ends up in the response |
| DOM-based | the page’s own JavaScript inserts data via innerHTML |
Encoding depends on context: HTML — HtmlEncoder, JavaScript — JavaScriptEncoder, URL — UrlEncoder. Data for scripts is safer in a data-* attribute read through dataset.
How it works: CORS
- The browser adds an
Originheader to a cross-origin request. - A simple request —
GET,HEADorPOSTwith a form ortext/plain— is sent immediately. The browser only decides whether to hand the response to the script. - Everything else —
PUT,DELETE,application/json, custom headers — first goes out as a preflightOPTIONSrequest. Without permission the actual request isn’t sent.
Pitfalls
- ASP.NET Core forbids
AllowAnyOrigin()together withAllowCredentials()by throwing.SetIsOriginAllowed(_ => true)bypasses that ban and opens the API, cookies included, to any site. - Put
UseCorsafterUseRoutingand before authentication and authorization: preflight requests carry no credentials. - CORS doesn’t stop CSRF: a form from another site is a simple request. Antiforgery is covered in MVC, Razor and SignalR.
Takeaway: XSS is closed by context-aware encoding, while CORS is a browser access setting that doesn’t protect the server.
Under the hood
- Razor encodes
@using HTML attribute rules: they’re stricter than HTML and suit both contexts. The default encoders only pass Basic Latin, so Cyrillic in markup becomes&#x...;until you widenHtmlEncoderwithUnicodeRanges. HtmlString,Html.RawandMarkupStringin Blazor turn encoding off. User-supplied HTML goes through a sanitizer such as HtmlSanitizer, or you accept Markdown without HTML.
Defense in depth
- CSP.
script-src 'nonce-...' 'strict-dynamic',object-src 'none'andbase-uri 'none'stop an injected inline script from running. There’s no built-in CSP middleware: you set the header with your own middleware or a package like NetEscapades.AspNetCore.SecurityHeaders. Roll it out withContent-Security-Policy-Report-Onlyfirst to collect violations. - Cookies.
HttpOnlyhides the cookie fromdocument.cookie,Securekeeps it off HTTP. The__Host-prefix requiresSecureandPath=/and forbidsDomain, so a subdomain can’t overwrite it.
Trade-offs
- A token in
localStorageis convenient for a SPA but leaks immediately under XSS. A script can’t read anHttpOnlycookie, but it can still send requests as the user while the page is open. SetPreflightMaxAgecaches the preflight and saves a round trip. Browsers cap the duration: Chromium at two hours.
How to check in production
- CSP reports show both attack attempts and legitimate scripts you broke.
- A DAST scanner checks CSP, cookie flags and how the API responds to a foreign
Origin.
Takeaway: encoding is the main defense against XSS, CSP and cookie flags limit the damage, and CORS is configured with an exact list of origins.
using Microsoft.AspNetCore.CookiePolicy;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCors(options => options.AddPolicy("spa", policy => policy .WithOrigins("https://shop.example") // an exact list, not "*" .WithMethods("GET", "POST") .WithHeaders("Content-Type") .AllowCredentials() // together with AllowAnyOrigin() — InvalidOperationException .SetPreflightMaxAge(TimeSpan.FromMinutes(10))));
var app = builder.Build();
app.Use(async (context, next) =>{ // Scripts only from our own origin, inline scripts are blocked context.Response.Headers.ContentSecurityPolicy = "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"; await next();});app.UseCookiePolicy(new CookiePolicyOptions{ HttpOnly = HttpOnlyPolicy.Always, // invisible to document.cookie Secure = CookieSecurePolicy.Always, // HTTPS only MinimumSameSitePolicy = SameSiteMode.Lax});app.UseRouting();app.UseCors();
app.MapPost("/api/cart", () => new { items = 3 }).RequireCors("spa");
app.Run();OPTIONS /api/cart HTTP/1.1Origin: https://shop.exampleAccess-Control-Request-Method: POSTAccess-Control-Request-Headers: content-type
HTTP/1.1 204 No ContentAccess-Control-Allow-Origin: https://shop.exampleAccess-Control-Allow-Credentials: trueAccess-Control-Allow-Methods: GET,POSTAccess-Control-Allow-Headers: Content-TypeAccess-Control-Max-Age: 600If the Origin isn’t on the list, the server still returns 204 but without the Access-Control-Allow-* headers, and the browser won’t send the actual request.
- How does stored XSS differ from DOM-based? — Stored XSS comes from the server’s database; DOM-based XSS is created by the page’s own JavaScript via
innerHTMLoreval, and server-side encoding doesn’t help with it. - Does
HttpOnlyprotect against XSS? — No, it only hides the cookie from scripts; the script can still send requests as the user. - When does the browser send a preflight? — For methods other than
GET,HEADandPOST, for aContent-Typelikeapplication/json, and for custom headers. - Why can’t you use
*with credentials? — Otherwise any site could read responses made with the user’s cookies; the spec and ASP.NET Core both forbid it. - Does CORS protect against CSRF? — No, a simple cross-site POST still runs; you need antiforgery tokens and
SameSite.
How do you defend against injection, and how should passwords and secrets be stored?
Section titled “How do you defend against injection, and how should passwords and secrets be stored?”What it is
- SQL injection. The query is built by concatenation, and the user types
' OR 1=1 --into the login field. The condition becomes always true, and the password check is skipped. - Password hash — a one-way transformation. On sign-in you hash the entered password and compare the hashes.
How to use
- In EF Core write LINQ or
FromSql($"... {name}"): interpolation becomes a parameter. - In Dapper pass
new { name }, in ADO.NET —SqlParameter. - Store passwords through ASP.NET Core Identity or
PasswordHasher<TUser>. - Connection strings and API keys go to User Secrets locally and to a secret store on the server; see Configuration and logging.
Common mistake
Storing SHA256(password). The hash is fast and unsalted: identical passwords give identical hashes, and a dictionary attack takes minutes.
Takeaway: data goes in as parameters, passwords are stored as a slow salted hash, secrets live outside the code.
How it works: injection
| Where | Dangerous | Safe |
|---|---|---|
| EF Core | FromSqlRaw("..." + name) |
LINQ, FromSql($"...{name}") |
| Dapper, ADO.NET | concatenation in the command text | @name parameters |
| Processes | cmd.exe /c with a user-supplied string |
ArgumentList without a shell |
| Files | Path.Combine(root, userPath) |
Path.GetFullPath and a check that the path is inside root |
Command injection is the same trick against a shell: & or ; in the data appends a command. Table and column names can’t be parameters — pick them from an allowlist.
How it works: passwords
- A random salt is generated for each user. Identical passwords give different hashes, and precomputed tables are useless.
- A slow function (KDF) runs the password and salt through tens or hundreds of thousands of iterations.
- The algorithm, parameters, salt and hash are stored as one string.
- On sign-in the hash is recomputed with the stored parameters and compared in constant time.
Pitfalls
Path.Combinedropsrootif the user passes an absolute path.- Encrypting passwords instead of hashing them: if the key leaks, every password leaks.
Takeaway: code and data are separated with parameters and allowlists, and a password becomes a slow salted hash.
Under the hood
PasswordHasherformat V3 since .NET 7: PBKDF2-HMAC-SHA512, a 128-bit salt, a 256-bit hash, 100,000 iterations. The iteration count lives in the hash itself, so you can raisePasswordHasherOptions.IterationCount: old hashes returnSuccessRehashNeeded, andUserManagerrehashes them on sign-in.- OWASP recommends Argon2id (19 MiB of memory, 2 iterations), scrypt, bcrypt (cost 10 or more) or PBKDF2-HMAC-SHA512 with 220,000 iterations. Argon2 and bcrypt are third-party packages in .NET; bcrypt only uses the first 72 bytes of a password.
- For your own PBKDF2 in .NET 10 use the static
Rfc2898DeriveBytes.Pbkdf2: the class constructors are obsolete (SYSLIB0060).
Trade-offs
- A slow hash is a deliberate CPU load on every sign-in. Without rate limiting and account lockout the login form becomes a DoS target.
- Insecure deserialization:
BinaryFormatterin .NET 9+ always throwsPlatformNotSupportedException, and Newtonsoft.Json withTypeNameHandling.AutoorAlllets the attacker choose the type.System.Text.Jsononly creates declared types, with polymorphism through a[JsonDerivedType]allowlist. - Data Protection rotates its key every 90 days and keeps old keys for decryption. A deleted key means lost data, and for indefinite storage Microsoft recommends tools with stricter key management.
How to check in production
- Identity hashes starting with
AQAAAAEAACcQuse the old format (SHA-256, 10,000 iterations),AQAAAAIAAYagthe current one. The share of old ones shows how many users haven’t signed in since the upgrade. - The
CA2100,CA3001,CA3006andCA2326analyzers in CI catch SQL built from strings, injection and dangerousTypeNameHandling.
Takeaway: injection is closed by structurally separating code and data, and a password hash carries its own parameters, which you raise over time.
using Microsoft.AspNetCore.DataProtection;using Microsoft.AspNetCore.Identity;using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddDataProtection().SetApplicationName("shop"); // same name on every farm nodevar app = builder.Build();
// The default is 100,000 iterations; OWASP recommends 220,000 for PBKDF2-HMAC-SHA512var hasher = new PasswordHasher<User>( Options.Create(new PasswordHasherOptions { IterationCount = 220_000 }));var user = new User("alice");var hash = hasher.HashPassword(user, "P@ssw0rd"); // AQAAAAIAA1tg...: format, iterations, salt, hashConsole.WriteLine(hasher.VerifyHashedPassword(user, hash, "P@ssw0rd")); // SuccessConsole.WriteLine(hasher.VerifyHashedPassword(user, hash, "wrong")); // Failed
// Data Protection: reversible encryption, the framework manages the keysvar protector = app.Services.GetRequiredService<IDataProtectionProvider>() .CreateProtector("Shop.PassportNumber.v1"); // the purpose isolates data of different featuresvar stored = protector.Protect("AB123456");Console.WriteLine(protector.Unprotect(stored)); // AB123456
app.Run();
public record User(string Name);// NuGet: Dapper, Microsoft.Data.SqlClientusing System.Diagnostics;using System.Net;using Dapper;using Microsoft.Data.SqlClient;
var builder = WebApplication.CreateBuilder(args);var app = builder.Build();
app.MapGet("/users", async (string name, IConfiguration config) =>{ await using var db = new SqlConnection(config.GetConnectionString("Main")); // Dangerous: "... WHERE Name = '" + name + "'" — with name = "' OR 1=1 --" returns everyone // Safe: the value goes as the @name parameter and never becomes part of the SQL return await db.QueryAsync<UserRow>("SELECT Id, Name FROM Users WHERE Name = @name", new { name });});
app.MapPost("/ping", (string host) =>{ // Format allowlist: an IP address only, no "-n 1000" and no "& del *" if (!IPAddress.TryParse(host, out _)) return Results.BadRequest(); // No shell: host is a single argument, & and ; mean nothing var psi = new ProcessStartInfo("ping") { UseShellExecute = false }; psi.ArgumentList.Add(host); using var process = Process.Start(psi); return Results.Accepted();});
app.Run();
public record UserRow(int Id, string Name);- Does EF Core fully protect against SQL injection? — LINQ and
FromSqlwith interpolation do;FromSqlRawandExecuteSqlRawwith concatenation don’t. - Why not salted SHA-256? — A salt defeats precomputed tables but not speed: a GPU tries billions of SHA-256 hashes per second.
- How do you move to a stronger hash without resetting passwords? — Store the version and parameters in the hash and rehash on successful sign-in, like
SuccessRehashNeededin Identity. - What is a pepper? — A shared secret kept outside the database and added to the password; a leaked database alone isn’t enough to brute-force.
- How does Data Protection differ from hashing? — Encryption is reversible and meant for data you’ll need to read; you never need to read a password, so you hash it.
How do HTTPS and TLS work, and what do you configure in production?
Section titled “How do HTTPS and TLS work, and what do you configure in production?”What it is
- TLS — a protocol over TCP that encrypts data, protects it from tampering and proves the server’s identity.
- Certificate — the server’s public key and domain name, signed by a certificate authority (CA) the browser trusts.
Why
Without HTTPS, passwords, cookies and tokens travel in plain text. Any hop on the way can read and modify them, such as public Wi-Fi.
How to use
app.UseHttpsRedirection()answers an HTTP request with a redirect to HTTPS.app.UseHsts()outside Development adds theStrict-Transport-Securityheader, and the browser remembers the site is HTTPS-only.- The developer certificate is installed with
dotnet dev-certs https --trust.
Common mistake
Relying on the redirect without HSTS. The first HTTP request can be intercepted and kept from ever reaching HTTPS — that’s SSL stripping.
Takeaway: TLS encrypts and proves the server, the redirect leads to HTTPS, and HSTS stops the browser from going back to HTTP.
How it works: the TLS 1.3 handshake
- ClientHello: versions, ciphers, a key share for the Diffie-Hellman exchange and the site name (SNI).
- ServerHello: the chosen cipher and the server’s key share. Both sides compute the shared secret; everything after that is encrypted.
- The server sends its certificate with intermediates, a signature over the handshake made with its private key (CertificateVerify), and Finished.
- The client verifies the chain and signature, replies with Finished and immediately sends the HTTP request. One round trip instead of two in TLS 1.2.
Chain of trust
The site’s certificate is signed by an intermediate CA, which is signed by a root from the OS or browser store. The client verifies the signatures up to the root, the validity period and that the host matches a name in the SAN.
Pitfalls
- Behind a proxy that terminates TLS the app sees
http. Without forwarded headersUseHttpsRedirectionloops andUseHstsstays silent: the header is only sent in HTTPS responses. - If no HTTPS port is found, the log says “Failed to determine the https port for redirect” and nothing is redirected. Set the port with
HttpsPortorASPNETCORE_HTTPS_PORT. - Under IIS, IIS itself decrypts TLS and the app sees
httpswith no extra setup. Redirect and HSTS can be enabled in IIS too (IIS 10 version 1709 and later) — then don’t duplicate them in code.
Takeaway: TLS 1.3 gives encryption in one round trip, and the app’s job is to learn the request’s real scheme behind a proxy.
Under the hood
- TLS 1.3 key exchange is ephemeral only — (EC)DHE — and the certificate key just signs the handshake. A stolen private key can’t decrypt traffic recorded earlier.
- Kestrel encrypts through
SslStream, which uses SChannel on Windows and OpenSSL on Linux. TLS 1.3 on Windows starts with Windows Server 2022.
Trade-offs
| Where to decrypt | Pro | Con |
|---|---|---|
| At the proxy | certificates in one place, app CPU freed up | traffic is unencrypted inside the network |
| At the proxy and again to Kestrel | encryption along the whole path | a second certificate and handshake |
| Only in Kestrel (L4 proxy) | the proxy never sees the traffic | no WAF or HTTP routing at the proxy |
- HSTS preload. A
max-ageof at least a year,includeSubDomainsandpreloadlet you join the list built into browsers and protect even the first visit. Removal takes months, and any subdomain without HTTPS becomes unreachable. - mTLS.
ClientCertificateMode.RequireCertificatein Kestrel plusAddCertificate()from the Microsoft.AspNetCore.Authentication.Certificate package. Behind a proxy the certificate is passed in a header and read withUseCertificateForwarding. - Certificate lifetime. Since March 15, 2026 public certificates are issued for at most 200 days, and from 2029 for 47. Rotation has to be automated: ACME, Key Vault.
How to check in production
kestrel.tls_handshake.durationwith thetls.protocol.versionattribute shows handshake cost and the share of TLS 1.2.openssl s_client -connect host:443 -showcertsshows whether the server sends its intermediate certificates.
Takeaway: TLS in production is a set of infrastructure decisions: where to decrypt, how to rotate certificates and how irreversibly to enforce HSTS.
using Microsoft.AspNetCore.Connections.Features;using Microsoft.AspNetCore.Server.Kestrel.Https;
var builder = WebApplication.CreateBuilder(args);
// The certificate lives in configuration (Kestrel:Certificates:Default); protocols are left to the OSbuilder.WebHost.ConfigureKestrel(kestrel => kestrel.ConfigureHttpsDefaults(https => https.ClientCertificateMode = ClientCertificateMode.AllowCertificate)); // mTLS if the client wants it
builder.Services.AddHsts(options =>{ options.MaxAge = TimeSpan.FromDays(365); // the default is 30 days options.IncludeSubDomains = true; options.Preload = true; // only once every subdomain is on HTTPS});builder.Services.AddHttpsRedirection(options =>{ options.RedirectStatusCode = StatusCodes.Status308PermanentRedirect; // the default is 307 options.HttpsPort = 443;});
var app = builder.Build();
if (!app.Environment.IsDevelopment()) app.UseHsts(); // Strict-Transport-Security: max-age=31536000; includeSubDomains; preloadapp.UseHttpsRedirection();
app.MapGet("/", (HttpContext ctx) => ctx.Features.Get<ITlsHandshakeFeature>()?.Protocol.ToString()); // "Tls13"
app.Run();{ "Kestrel": { "Certificates": { "Default": { "Path": "/certs/shop.crt", "KeyPath": "/certs/shop.key" } } }}- Why is TLS 1.3 faster than TLS 1.2? — A full handshake takes one round trip instead of two; resumption allows 0-RTT, but that data can be replayed.
- What is forward secrecy? — Session keys come from a one-time Diffie-Hellman exchange, so stealing the certificate key doesn’t expose past traffic.
- Why doesn’t HSTS protect the first visit? — The browser hasn’t seen the header yet; only the preload list closes that gap.
- Why shouldn’t an API redirect to HTTPS? — The client has already sent the data and token over HTTP; an API should not listen on HTTP or should answer 400.
- How do you set up mTLS between services? — Kestrel with
ClientCertificateMode.RequireCertificateandAddCertificate(), or a service mesh that does mTLS for the app.