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.
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>
.* 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.
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
- Compressed responses break content rewriting, not header rewriting. Outbound rules that rewrite the response body (
filterByTags) receive gzip/br-compressed bytes and silently no-op. Header rewrites onRESPONSE_*variables are unaffected because they run against the header block, not the body — so keep header rules separate from any body-rewriting rule and never gate a header rule on decompressed body content. rewriteBeforeCacheand the output cache. With the IIS output cache enabled andrewriteBeforeCache="false"(the default), the response is cached before your rule runs, then served from cache without re-running the rule — so the header is missing on cache hits and present only on the first miss. SetrewriteBeforeCache="true"on<outboundRules>whenever output caching is active, as shown above.customHeadersplus an outbound rule stacks into a duplicate. If a header is already set in<httpProtocol><customHeaders>and a.*outbound rule also writes it, some pipelines emit two lines. For a single-valued security header like CSP, a browser enforces the intersection of all policies and silently blocks resources. Own each header in exactly one place: usecustomHeadersfor unconditional headers, outbound rules for conditional ones, never both for the same name. This mirrors the merge trap covered in Apache Header set vs append vs add for CSP.- Case and mapping typos fail silently. A
serverVariablewith a wrong underscore (RESPONSE_XFrame_Options) is valid XML and passesweb.configparsing, but never matches, so the header simply never appears. Verify withcurlon every deploy rather than trusting a clean config load. - Safe rollback. Delete or comment the
<rule>element (XML comments<!-- ... -->are legal inside<outboundRules>); savingweb.configrecycles the app instantly with noiisreset. Keepweb.configin version control so a bad rule is reverted withgit checkoutand a save. If a rewrite strips a header a dependency needs, an empty-value rule is reversed by removing that single rule — no other rule is affected.
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.