IIS URL Rewrite Outbound Rules for Headers

This guide is part of the IIS Security Headers with web.config reference, which covers the static <httpProtocol><customHeaders> element. That element sets a fixed header on every response and cannot branch on content type, cannot read what your application already emitted, and cannot delete a header IIS injects downstream of your config. The URL Rewrite module’s <outboundRules> closes all three gaps: it rewrites response headers conditionally, keyed on preConditions, so you add a Content-Security-Policy only to HTML, tighten a Referrer-Policy your app sets too loosely, and strip a Server header that customHeaders cannot touch.

Configuration Syntax & Exact Values

An outbound rule matches against a response server variable and rewrites its value. Response headers are exposed as server variables by taking the header name, prefixing RESPONSE_, and replacing every - with _. So Content-Security-Policy becomes RESPONSE_Content_Security_Policy and X-Frame-Options becomes RESPONSE_X_Frame_Options.

<rule name="Add-CSP" preCondition="IsHTML">
  <!-- match the response variable; .* matches an absent header (empty string) -->
  <match serverVariable="RESPONSE_Content_Security_Policy" pattern=".*" />
  <!-- Rewrite CREATES the header if empty, REPLACES it if present -->
  <action type="Rewrite" value="default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'" />
</rule>

The parts, in the order the engine reads them:

Element / attribute Role The value that matters
preCondition="IsHTML" Gate: skip the rule unless the named <preCondition> passes Names a block defined once under <preConditions>
<match serverVariable> Which response header to test RESPONSE_ + header name with -_
pattern=".*" Regex the current value must match .* matches even an absent (empty) header, so the rule can create one; .+ requires the header to already exist
<action type="Rewrite" value> Writes the header Empty value="" deletes the header

The behaviour hinges on pattern versus what is already on the response. .* matches the empty string, so a rule with pattern=".*" fires whether or not the header exists — use it to add or overwrite. .+ requires at least one character, so it fires only when the app already emitted the header — use it when you mean “rewrite what is there, do not create”. Setting value="" on the action removes the header entirely.

Where outbound rules run in the IIS response pipeline A vertical flow showing a response leaving the handler, being intercepted by the URL Rewrite outbound engine, tested against a preCondition, matched on a RESPONSE_ server variable, and finally having its header value rewritten. App or static handler builds response headers + body assembled URL Rewrite outbound engine intercepts rewriteBeforeCache decides cache timing preCondition tested content-type match / header absent? match on RESPONSE_ server variable pattern .* creates, .+ rewrites only Rewrite action writes header value empty value deletes the header
Outbound rules run after the handler but before the response leaves IIS, so they can see and mutate every header.

Server-Side Configuration

Outbound rules require the URL Rewrite module (rewrite schema, Microsoft.Web.rewrite). It ships separately from IIS; a rule referencing <outboundRules> on a server without the module throws HTTP 500.19. Static, unconditional headers still belong in <customHeaders> — reserve outbound rules for the conditional cases below.

Verify the URL Rewrite module is installed

# From an elevated command prompt on the IIS host
%windir%\system32\inetsrv\appcmd.exe list modules /name:RewriteModule
# MODULE "RewriteModule" ( type:... )
# -> a line printed = module present; no output = install it first

Conditional CSP by content type, without duplicating an existing one

Define the preConditions once, then reference them by name. This rule adds a CSP only to HTML responses that do not already carry one, so an app route that sets its own nonce-based policy is left untouched.

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <system.webServer>
    <rewrite>
      <!-- rewriteBeforeCache="true": rules run before the output cache stores
           the response, so a cached HTML page keeps its rewritten headers -->
      <outboundRules rewriteBeforeCache="true">

        <preConditions>
          <preCondition name="IsHTML">
            <add input="{RESPONSE_CONTENT_TYPE}" pattern="^text/html" />
          </preCondition>
          <preCondition name="HtmlWithoutCSP">
            <add input="{RESPONSE_CONTENT_TYPE}" pattern="^text/html" />
            <add input="{RESPONSE_Content_Security_Policy}" pattern="^$" />
          </preCondition>
        </preConditions>

        <rule name="Add-CSP-when-absent" preCondition="HtmlWithoutCSP">
          <match serverVariable="RESPONSE_Content_Security_Policy" pattern=".*" />
          <action type="Rewrite"
            value="default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'" />
        </rule>

      </outboundRules>
    </rewrite>
  </system.webServer>
</configuration>

Multiple <add> inputs inside a <preCondition> are ANDed by default (logicalGrouping="MatchAll"), so HtmlWithoutCSP passes only when the content type is HTML and the CSP variable is empty (^$). The frame-ancestors directive here is the modern replacement for X-Frame-Options; pair or migrate as your policy dictates.

Rewrite a header the application emits with the wrong value

When a backend framework hard-codes a loose Referrer-Policy, you cannot fix it in <customHeaders> (that only adds, and would produce a duplicate). Match the existing value and overwrite it. Use pattern=".+" so the rule fires only when the app actually set the header.

<rule name="Tighten-Referrer-Policy">
  <match serverVariable="RESPONSE_Referrer_Policy" pattern=".+" />
  <action type="Rewrite" value="strict-origin-when-cross-origin" />
</rule>

The Referrer-Policy semantics live in the Referrer-Policy and Permissions-Policy reference.

Remove a header you cannot delete in web.config

<customHeaders> can <remove> only headers it added. Headers injected by IIS itself or by a downstream module — the Server banner is the classic case — survive <remove>. An outbound rule that rewrites the variable to an empty string deletes it from the wire.

<rule name="Strip-Server-Header">
  <!-- .+ so we only act when the banner is present -->
  <match serverVariable="RESPONSE_Server" pattern=".+" />
  <action type="Rewrite" value="" />
</rule>
Choosing the outbound rule shape for each goal A decision tree mapping three goals — add a missing header, tighten an existing one, or delete an undeletable one — to the matching pattern and Rewrite action. What must the rule do? to a response header Add when missing gate on preCondition ^$ Tighten wrong value app already emits it Delete undeletable Server / injected banner pattern=".*" Rewrite value="policy" creates the header pattern=".+" Rewrite value="new" replaces, never dups pattern=".+" Rewrite value="" removes the header
Three goals, three rule shapes: .* to create or overwrite, .+ to touch only what exists, empty value to delete.

The header-to-variable mapping is mechanical, and getting a single character wrong means the rule silently never matches. The transformation is always the same: prepend RESPONSE_, then convert each hyphen to an underscore.

Response header to serverVariable name mapping Three examples showing that a response header name maps to a serverVariable by prefixing RESPONSE_ and replacing hyphens with underscores. Response header Outbound serverVariable X-Frame-Options RESPONSE_X_Frame_Options Content-Security-Policy RESPONSE_Content_Security_Policy Server RESPONSE_Server Rule: prefix RESPONSE_ , replace every "-" with "_"
The mapping is purely mechanical — a mistyped underscore is why a syntactically valid rule never fires.

Diagnostic & Verification Steps

Reload is implicit: editing web.config recycles the application, so no iisreset is needed. Verify against a real HTML response and against a non-HTML asset to prove the preCondition gate works.

# HTML response: CSP must appear
curl.exe -sI https://your-site.example/ | findstr /I "content-security-policy"
# content-security-policy: default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'

# A CSS/JS asset: CSP must be ABSENT (proves IsHTML gating)
curl.exe -sI https://your-site.example/site.css | findstr /I "content-security-policy"
# (no output = correct — the rule was gated out by content type)

# Confirm the Server banner is gone
curl.exe -sI https://your-site.example/ | findstr /I "server"
# (no output = strip rule working; a "Server: Microsoft-IIS/10.0" line = rule not matching)

PowerShell exposes the same headers as a collection, which is convenient in a remote check:

powershell -Command "(Invoke-WebRequest -UseBasicParsing https://your-site.example/).Headers['Content-Security-Policy']"
# default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'

In Chrome or Edge DevTools, open the Network panel, click the document request, and read the Response Headers. Then click a stylesheet request and confirm the CSP is not listed there — DevTools is the fastest way to see the content-type gating live. Enable Failed Request Tracing in IIS (Logging → configure URL Rewrite provider) when a rule refuses to fire; the trace shows each rule, its preCondition result, and whether the pattern matched.

Edge Cases, Security Implications & Safe Rollback

Conclusion

Stage the outbound rules on a non-production IIS site or slot first, and roll a new CSP out with a short-lived Content-Security-Policy-Report-Only variant — set serverVariable="RESPONSE_Content_Security_Policy_Report_Only" — so violations are reported, not enforced, while you watch the reports. Once the report stream is clean, flip the rule to the enforcing RESPONSE_Content_Security_Policy variable and promote web.config to production, verifying with curl.exe -sI against both an HTML page and a static asset on the live host.

Frequently Asked Questions

Why does my outbound rule not fire even though web.config loads cleanly? The most common cause is a mistyped serverVariable name — the mapping is RESPONSE_ plus the header name with every hyphen turned into an underscore, and a wrong character parses fine but never matches. The second cause is a preCondition that excludes the response, for example an ^text/html content-type test on a response whose Content-Type includes a charset but a different media type. Enable Failed Request Tracing to see the match result per rule.

Can outbound rules add a header only when the application has not already set one? Yes. Add a second <add> input to the preCondition that tests the response variable for emptiness with pattern="^$", as in the HtmlWithoutCSP example. The rule then fires only when the header is absent, leaving app-set policies (such as a per-request nonce CSP) untouched.

Do I need the URL Rewrite module installed for outbound rules to work? Yes. <outboundRules> is defined by the URL Rewrite module schema, which is a separate download and is not part of a base IIS install. A web.config referencing it on a server without the module returns HTTP 500.19. Confirm with appcmd list modules /name:RewriteModule.

How do I remove the Server header that <remove> in customHeaders cannot delete? Write an outbound rule that matches RESPONSE_Server with pattern=".+" and a Rewrite action whose value is an empty string. Rewriting a response variable to "" deletes the header from the wire, which <customHeaders><remove> cannot do because it only removes headers that element itself added.

Should I move all my security headers into outbound rules? No. Unconditional single-valued headers belong in <httpProtocol><customHeaders>, which is simpler and needs no module. Reserve outbound rules for the three cases they alone handle: conditional headers keyed on content type, rewriting a value your app emits, and deleting a header you cannot remove otherwise. Setting the same header in both places risks a duplicate that browsers enforce as a policy intersection.