HTTPS, HSTS and Secure Defaults
Objective
Three small pieces make an ASP.NET Core site "secure by default" at the transport
level: HTTPS redirection sends plain HTTP clients to HTTPS, HSTS tells browsers to
never try plain HTTP again, and a local-URL check stops your own redirects from
being turned into phishing links. Each one has a precondition that fails
silently. Redirection does nothing when it cannot work out the HTTPS port, HSTS
is deliberately off on localhost, and Results.Redirect follows whatever
string it is given. The goal is to know what each piece reads, what it sends,
and how to see that it works, using only curl against a running app.
Use Cases
- Moving a site from HTTP to HTTPS and choosing a safe first
max-age. - Debugging "the redirect does not happen" or "the redirect loops" behind a proxy.
- Handling
returnUrlafter a login without creating an open redirect. - Deciding what an API (not a website) should do with plain HTTP requests.
- Setting up the local development certificate without surprises in containers.
Deep Dive
What UseHttpsRedirection needs: a port
The middleware answers an HTTP request with a redirect to the HTTPS URL, so it
must know the HTTPS port. HttpsRedirectionOptions.HttpsPort is null by
default, and the docs list where it is resolved from: code, the https_port
host setting (also ASPNETCORE_HTTPS_PORT or a top-level "https_port" in
appsettings.json), an https:// entry in ASPNETCORE_URLS, the
launchSettings.json HTTPS URL, or an HTTPS endpoint of a public-facing Kestrel.
The docs do not state a precedence order, and
ASPNETCORE_HTTPS_PORT is not the same thing as ASPNETCORE_HTTPS_PORTS, which
configures Kestrel endpoints.
This app (a file-based app, SDK 10.0.100, with a throwaway self-signed
certificate loaded from a .pfx, so no machine-wide certificate changes) was run
in Production with both endpoints:
csharp#:sdk Microsoft.NET.Sdk.Web
using Microsoft.AspNetCore.Http.HttpResults;
var b = WebApplication.CreateBuilder(args);
b.WebHost.ConfigureKestrel(k => k.ConfigureHttpsDefaults(h =>
h.ServerCertificate = X509CertificateLoader.LoadPkcs12FromFile("c.pfx", "x")));
var app = b.Build();
app.UseHsts();
app.UseHttpsRedirection();
app.MapGet("/", (HttpContext c) => $"scheme={c.Request.Scheme}");
app.Run();
// ASPNETCORE_URLS="http://localhost:5300;https://localhost:5301"textcurl -i http://localhost:5300/ -> 307 Temporary Redirect, Location: https://localhost:5301/ curl -ik https://localhost:5301/ -> 200, "scheme=https"
Two observations. The status is 307, the documented default, because
permanent redirects get cached by browsers and make development painful. A
permanent 308 is opt-in (options.RedirectStatusCode) and the docs suggest
setting it only outside Development. Second, the port came from the
ASPNETCORE_URLS HTTPS entry with no extra code.
The failure mode is the interesting one. With only the HTTP URL configured, the
same app answered the plain request with 200 and logged
Failed to determine the https port for redirect. Nothing crashed, and nothing
was redirected. With ASPNETCORE_HTTPS_PORT=443 set, the redirect went to
https://localhost/ (the default port is omitted from the URL). That is the fix
behind a TLS-terminating proxy: Kestrel only sees HTTP, so you tell the app the
public port explicitly and make UseForwardedHeaders run first, as the hosting
concept explains.
HSTS: a header the browser remembers
Strict-Transport-Security tells a browser: for the next max-age seconds,
talk to this host only over HTTPS, even if the user types http://. That
removes the unprotected first hop that a redirect always leaves. The options
and their defaults, per the docs: MaxAge is 30 days, IncludeSubDomains and
Preload are off, and ExcludedHosts contains localhost, 127.0.0.1 and
[::1]. The templates call UseHsts only outside Development, because the
header is "highly cacheable by browsers" and one run on localhost would
otherwise pin your other local projects to HTTPS.
Observed on the app above (UseHsts before the redirect middleware):
texthttps://localhost:5301/ (default ExcludedHosts) -> no Strict-Transport-Security header https://localhost:5301/ (ExcludedHosts.Clear()) -> strict-transport-security: max-age=2592000 http://localhost:5300/ -> 307, and no HSTS header on the HTTP response
2592000 is 30 days in seconds. The header is only sent on HTTPS responses,
and browsers ignore it when it arrives over plain HTTP (RFC 6797).
Rolling it out is where the damage happens, because you cannot take the header
back. The docs recommend starting with a MaxAge of hours, a day at most, so
you can revert, and raising it (commonly to a year) once you are sure.
IncludeSubDomains commits every subdomain to HTTPS, including that forgotten
internal one. Preload is not part of RFC 6797; it opts you into the browser
preload lists, which is effectively permanent.
csharpbuilder.Services.AddHsts(o =>
{
o.MaxAge = TimeSpan.FromHours(6); // first rollout; raise later
o.IncludeSubDomains = false; // only when every subdomain serves HTTPS
});Redirect to a returnUrl: IsLocalUrl
A login page that does Results.Redirect(returnUrl) will happily redirect to
https://evil.com/phish if that is what the query string says. Observed:
textGET /go?returnUrl=https://evil.com/phish (Results.Redirect) -> 302, Location: https://evil.com/phish
ASP.NET Core 10 added RedirectHttpResult.IsLocalUrl(url) (documented in the
ASP.NET Core 10 release notes). A URL is local when it has no host or authority
section and has an absolute path; ~/ virtual paths count as local too. These
are real results of the helper:
text/home -> True /a/b?x=1 -> True ~/x -> True //evil.com -> False /\evil.com -> False https://evil.com -> False http://localhost:5300/x -> False "" and x/y -> False
Notice that an absolute URL to your own host is False too: the check is about
shape, not about which site you are on. Use it as a guard:
csharpapp.MapGet("/safe", (string? returnUrl) =>
RedirectHttpResult.IsLocalUrl(returnUrl)
? Results.LocalRedirect(returnUrl!)
: Results.Redirect("/"));/safe?returnUrl=https://evil.com/phish answered 302 to /, and
/safe?returnUrl=/dashboard answered 302 to /dashboard. The related
Results.LocalRedirect already enforces the rule by throwing; given
https://evil.com it produced a 500 with
InvalidOperationException: The supplied URL is not local. That is safe but
ugly, so check first and fall back to a default page.
APIs are different from websites
The docs say a web API should either not listen on HTTP at all or close the
connection with a 400 instead of serving the request. Redirecting an API
client is risky because clients may not follow the redirect, or may already have
sent the body over HTTP. The docs add that UseHttpsRedirection on an HTTP
endpoint causes ERR_INVALID_REDIRECT on CORS preflight requests, and that the
default API templates do not include HSTS because it is a browser-only
instruction. For a pure API, bind only HTTPS (ASPNETCORE_URLS or --urls).
The development certificate
dotnet dev-certs https manages the development certificate. Per the docs, the
first run installs it in the user store as not trusted, --trust is the one-time
trust step on Windows and macOS (Linux trust is distribution specific),
--clean removes it, and -ep <file> --format PEM exports it. Do not create it
in images or VMs that get redistributed; set
DOTNET_GENERATE_ASPNET_CERTIFICATE=false before the first CLI call. These
commands change the user's certificate store, so the experiments above avoided
them and used a throwaway certificate with curl -k. The -k flag is for local
experiments only.
Trade-offs
- Redirect without a port is a silent no-op. The app starts, serves HTTP,
and only a log line tells you.csharp
builder.Services.AddHttpsRedirection(o => o.HttpsPort = 443); // explicit behind a proxy - HSTS is a one-way door for
max-ageseconds. A mistake cannot be rolled back from the server; start with hours and raise it once HTTPS is proven for every host under the domain. PreloadandIncludeSubDomainswiden the commitment. They are right for a finished site and wrong for one with legacy HTTP subdomains.- A redirect still lets the first request travel in clear text. HSTS closes that window only after the browser has seen the header once; for APIs, refusing HTTP is stronger than redirecting.
IsLocalUrlchecks shape, not intent. It blocks external targets, but it will not stop a local path that leads to a page you did not want to expose; authorization still applies after the redirect.