HTTPS, HSTS and Certificates

Enforce HTTPS with UseHttpsRedirection and UseHsts, decide where TLS terminates, and give Kestrel a certificate only when it must.

Syntaxbuilder.Services.AddHsts(o => { o.MaxAge = TimeSpan.FromDays(365); o.IncludeSubDomains = true; }); if (!app.Environment.IsDevelopment()) app.UseHsts(); app.UseHttpsRedirection();

Three separate mechanisms share this topic. UseHttpsRedirection answers a plain HTTP request with a redirect to the HTTPS URL. UseHsts adds the Strict-Transport-Security header, which tells a browser to use HTTPS for that host for a period without trying HTTP first. The certificate belongs to whatever terminates TLS: Kestrel, or a load balancer, ingress controller or mesh sidecar in front of it.

HTTPS redirection

The middleware redirects with 307 by default; set RedirectStatusCode to 308 in production so clients cache it. It needs the HTTPS port: HttpsRedirectionOptions.HttpsPort, the HTTPS_PORT or ASPNETCORE_HTTPS_PORT setting, or an HTTPS address the server listens on. With none of these, the normal case for a container listening on 8080, it logs Failed to determine the https port for redirect and serves the request over HTTP.

For an API a redirect is a weak control. By the time the client follows it, the bearer token has already crossed the network in clear text, and many clients do not follow redirects for POST. Do not expose HTTP publicly at all, or reject plain HTTP with 400. Keep redirection for hosts that serve browsers.

HSTS

UseHsts writes the header only on HTTPS responses and never for ExcludedHosts, which default to localhost, 127.0.0.1 and [::1]; that is why it seems to do nothing on a dev machine. Defaults: max-age 30 days, no includeSubDomains, no preload. Templates still call it only outside Development: a browser caches the header for the whole max-age, and a dev host reached by machine name or a custom hostname such as orders.test is not excluded, so that browser would refuse plain HTTP to it for a year. Only browsers honour HSTS; HttpClient and mobile apps ignore it.

Production values are usually 365 days with IncludeSubDomains. Add Preload only when every subdomain serves HTTPS: leaving the browsers' preload list takes months, and an HTTP-only intranet host under the same domain stops working in browsers.

Where TLS terminates

OptionWho holds the certificateWhat the app does
Load balancer or ingress (most common)The proxy; cert-manager or the cloud renews itListens on HTTP 8080 and runs UseForwardedHeaders so Request.IsHttps reflects X-Forwarded-Proto
KestrelThe app, from Kestrel:Endpoints or codeMounts certificate and key, handles renewal
Service mesh mTLSThe sidecarUsually plain HTTP inside the pod

dotnet dev-certs https --trust creates and trusts a localhost certificate that dotnet run picks up. It is not in your container image, so an image configured for an HTTPS endpoint without a certificate fails at start-up with No server certificate was specified, and the default developer certificate could not be found.

Production failure modes

  • Redirect loop. The proxy terminates TLS, the app sees http and redirects to https, and the proxy forwards it as http again. Fix forwarded headers; do not delete the middleware.
  • Expired certificate when Kestrel terminates TLS and nobody owns renewal. Alert on expiry dates, or let the ingress own certificates.
  • includeSubDomains with an HTTP-only subdomain, which breaks for every browser that visited the main site.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Stop a 307 redirect loop after moving TLS termination from Kestrel to the cloud load balancer in front of the orders API.
  • Send Strict-Transport-Security with a one-year max-age on the customer portal while keeping it off localhost during development.
  • Serve HTTPS directly from Kestrel with a PEM certificate mounted from a Kubernetes TLS secret on an edge node with no ingress.

More examples

Kestrel terminating TLS with a PEM certificate

Kestrel reads PEM files directly, so a <code>kubernetes.io/tls</code> secret mounted as files works without converting to PFX. <code>WebApplication.CreateBuilder</code> binds this section; <code>CreateSlimBuilder</code> ignores HTTPS endpoint configuration until you call <code>builder.WebHost.UseKestrelHttpsConfiguration()</code>. The top-level <code>https_port</code> key (the same setting as <code>ASPNETCORE_HTTPS_PORT</code>) sets the redirect port to 443, the public port, not the 8443 the container listens on, because the redirect goes to the client. A nested <code>HttpsRedirection:HttpsPort</code> section does nothing unless you bind it to <code>HttpsRedirectionOptions</code> yourself.

Example · json
{
  // Only when nothing in front of the app terminates TLS.
  // The certificate and key are mounted from a Kubernetes TLS secret.
  "Kestrel": {
    "Endpoints": {
      "Https": {
        "Url": "https://*:8443",
        "Certificate": {
          "Path": "/etc/orders-api/tls/tls.crt",
          "KeyPath": "/etc/orders-api/tls/tls.key"
        }
      }
    }
  },
  // Read by UseHttpsRedirection; a "HttpsRedirection" section is never bound.
  "https_port": 443
}

Rejecting plain HTTP in an API instead of redirecting

A 400 makes a misconfigured client fail loudly in testing, while a redirect hides the mistake and leaks the credential on every call. The check depends on <code>UseForwardedHeaders</code> trusting the ingress network; without that, every request through the ingress looks like HTTP and is rejected. Health probes bypass the ingress, so the path exemption keeps the pod from being marked unhealthy.

Example · csharp
using System.Net;
using Microsoft.AspNetCore.HttpOverrides;

var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
    options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("10.244.0.0/16")); // ingress controller pods
});
builder.Services.AddHsts(options =>
{
    options.MaxAge = TimeSpan.FromDays(365);
    options.IncludeSubDomains = true;
});

var app = builder.Build();

app.UseForwardedHeaders(); // must run first so IsHttps reflects X-Forwarded-Proto
if (!app.Environment.IsDevelopment())
{
    app.UseHsts();
}

app.Use(async (context, next) =>
{
    // Kubelet probes call the pod directly over HTTP, so they are exempt.
    if (!context.Request.IsHttps && !context.Request.Path.StartsWithSegments("/healthz"))
    {
        context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
        await context.Response.WriteAsync("HTTPS is required.");
        return;
    }
    await next(context);
});

app.MapGet("/healthz", () => TypedResults.Ok("healthy"));
app.MapGet("/orders/{id:int}", (int id) => TypedResults.Ok(new { id, status = "shipped" }));

app.Run();

TLS terminated at the ingress

This is the common production shape: cert-manager obtains and renews the certificate into <code>orders-example-com-tls</code>, the ingress terminates TLS, and the pod serves plain HTTP on 8080. The app then needs forwarded headers but no certificate and no redirection of its own. <code>ssl-redirect</code> is off because this is an API host: API clients should reach only 443, so close port 80 at the load balancer or let the 400 check from the previous example reject plain HTTP. Turn <code>ssl-redirect</code> on only for browser-facing hosts. Rotation becomes an ingress concern, which removes the most common cause of expired-certificate outages.

Example · yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: orders-api
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    nginx.ingress.kubernetes.io/ssl-redirect: "false" # API host: do not redirect, see below
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - orders.example.com
      secretName: orders-example-com-tls
  rules:
    - host: orders.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: orders-api
                port:
                  number: 8080

Discussion

  • Be the first to comment on this lesson.