Most ASP.NET Core apps should redirect plain HTTP to HTTPS and collapse www versus apex into one canonical host. Doing that only inside middleware works until a static file, health probe, or mis-ordered reverse-proxy hop answers first. On Windows shared and VPS hosts, the durable place for those rules is still the IIS URL Rewrite module, evaluated before the ASP.NET Core Module hands the request to Kestrel in-process.

The failure mode admins see is not “redirect missing.” It is redirect loops, mixed content after a partial cutover, or double hops that tank Time To First Byte. The fix is a small, ordered set of rules that trust the right server variables and stop rewriting once the request is already correct.

#Install and verify the module

URL Rewrite 2.1 ships with modern Windows Server IIS roles on many images, but confirm it before you ship web.config that depends on it. On Server 2025 the module still appears as “URL Rewrite” under IIS → site → URL Rewrite. From an elevated session:

powershell
Get-WebGlobalModule | Where-Object { $_.Name -match 'Rewrite' }
# Expect: RewriteModule  →  rewrite.dll

If the module is absent, install the official IIS URL Rewrite package, recycle the app pool, and re-check. Rules in web.config are ignored (or throw 500.19) when the global module is missing.

#Canonical HTTPS + host in one pass

Prefer one rule that both upgrades the scheme and normalizes the host. Two separate 301s cost an extra round trip and confuse caches. Use {HTTPS} and {HTTP_HOST}, match case-insensitively, and redirect with a 301 only for safe methods you are willing to cache permanently.

xml
<rewrite>
  <rules>
    <rule name="Canonical HTTPS host" stopProcessing="true">
      <match url=".*" />
      <conditions logicalGrouping="MatchAny">
        <add input="{HTTPS}" pattern="^OFF$" />
        <add input="{HTTP_HOST}" pattern="^www\.contoso\.com$" ignoreCase="true" />
      </conditions>
      <action type="Redirect"
              url="https://contoso.com/{R:0}"
              redirectType="Permanent"
              appendQueryString="true" />
    </rule>
  </rules>
</rewrite>

stopProcessing="true" matters. Without it, later rules can fire on the already-corrected URL and create a loop—especially if a second rule rewrites based on a loose host pattern. Keep the canonical host literal in the action URL; do not bounce through another relative path that re-enters the same rule set.

#Behind SSL offload or ARR

If TLS terminates on a load balancer or Application Request Routing (ARR) and IIS sees HTTP on the back end, {HTTPS} is OFF even when the client used HTTPS. Redirecting on {HTTPS} alone then loops. In that topology, gate on the forwarded proto header the front end injects, and only after you control who can set it.

xml
<rule name="HTTPS from X-Forwarded-Proto" stopProcessing="true">
  <match url=".*" />
  <conditions logicalGrouping="MatchAll">
    <add input="{HTTP_X_FORWARDED_PROTO}" pattern="^http$" ignoreCase="true" />
    <add input="{HTTP_X_FORWARDED_PROTO}" pattern=".+" />
  </conditions>
  <action type="Redirect"
          url="https://contoso.com/{R:0}"
          redirectType="Permanent"
          appendQueryString="true" />
</rule>

Pair this with ARR or your edge stripping or overwriting X-Forwarded-* from the public internet. Never treat a client-supplied forwarded proto as authoritative on an IIS site that also accepts direct traffic. For ASP.NET Core, if you terminate TLS at IIS and use in-process hosting, prefer the simple {HTTPS} rule and let the ASP.NET Core Module present a secure request to the app—no forwarded header required.

#What to leave to the app

Use IIS for scheme and host. Leave path-level product redirects, locale prefixes, and authenticated-only routes to ASP.NET Core endpoint routing. Duplicating path rules in both layers drifts after the next deploy. Also skip rewrite-based HSTS; emit Strict-Transport-Security from the app or a stable custom header at the site level once HTTPS is proven, so you do not lock browsers onto a broken host during cutover.

  • Exclude health and warm-up paths if an external monitor still hits http:// on a dedicated probe hostname.
  • Keep HTTP bindings only long enough for the 301; remove dangling :80 host headers you no longer own.
  • Test with curl -I on both hosts and both schemes before flipping DNS, not only in a browser cache.
bash
curl -sI http://www.contoso.com/healthz
curl -sI https://contoso.com/healthz
# Expect a single 301 to https://contoso.com/... then 200 on the canonical URL.

#Deploy notes for multi-env hosts

Bake the rewrite section into the site web.config or a transform that Web Deploy applies per environment. Slot-specific host names (staging.example.com) need their own canonical target; a production-only rule on a staging site will strand testers on the wrong certificate name. On shared Windows hosts, confirm URL Rewrite is delegated so your site-level web.config is allowed—locked sections fail closed with configuration errors rather than silently skipping rules.

Practical takeaway: put one stopProcessing rule at the IIS edge that forces HTTPS and a single host, use {HTTPS} when IIS terminates TLS, switch to a trusted X-Forwarded-Proto only behind ARR or a balancer you control, and verify with curl -I before DNS cutover. That keeps ASP.NET Core middleware focused on application routes and stops the 3 a.m. redirect loop tickets.