IIS Security Headers with web.config

This guide is part of the Server & Platform Implementation Guides reference and covers the complete way IIS emits, strips and rewrites HTTP security headers through web.config: the <httpProtocol><customHeaders> collection, requestFiltering removeServerHeader, the <remove> element for the X-Powered-By banner, and URL Rewrite outbound rules for conditional headers. IIS is unusual among web servers in that headers are managed declaratively across a layered configuration hierarchy — applicationHost.config, a site web.config, and every sub-application web.config merge together — so the failure modes are duplication and inheritance, not the silent drop you fight on other stacks.

Threat Model & Protocol Mechanics

HTTP security headers are server-issued instructions the browser enforces on the client. IIS attaches them at the tail of its response pipeline; the browser applies each one according to its own enforcement model — HSTS is cached as a policy for the whole max-age window, while Content-Security-Policy is re-evaluated per response. IIS’s job is mechanical: add the right header lines to every response, remove the banners it leaks by default, and avoid emitting the same header twice.

Two classes of exposure dominate an IIS hardening pass, and the second is specific to the platform.

1. Missing enforcement headers. A default IIS site ships without HSTS, CSP, X-Content-Type-Options, X-Frame-Options, Referrer-Policy or Permissions-Policy. Every one of those is an unmitigated attack surface: protocol downgrade, injected script, MIME sniffing, clickjacking, referrer leakage and unused-feature abuse respectively.

2. Information disclosure through response banners. IIS advertises itself. By default it emits Server: Microsoft-IIS/10.0, ASP.NET adds X-Powered-By: ASP.NET, and ASP.NET MVC adds X-AspNet-Version and X-AspNetMvc-Version. Server is a SHOULD-level field in RFC 9110 §10.2.4 precisely because a precise version string hands an attacker a fingerprint to match against known CVEs. None of these banners has any operational value to a browser; all of them shorten an attacker’s reconnaissance.

Unlike Nginx and Apache, IIS has no per-directive always flag. The <customHeaders> collection is applied to responses regardless of status code, so a hardened 404 or 500 is the default rather than an opt-in — the equivalent concern on IIS is the opposite one: the same collection is inherited by every child application and can therefore be emitted twice.

Mapping the headers to the attacks they neutralise:

Threat Category Primary Attack Vector Header Mitigation
Protocol downgrade / SSL stripping MITM forces HTTP, intercepts plaintext Strict-Transport-Security
Cross-site scripting / injection Injected inline or remote script executes Content-Security-Policy
Clickjacking / UI redressing Page framed inside attacker iframe X-Frame-Options + CSP frame-ancestors
MIME sniffing / drive-by Browser reinterprets response content type X-Content-Type-Options: nosniff
Referrer leakage URL path/query sent to third parties Referrer-Policy
Feature attack surface Camera, mic, geolocation abused by injected code Permissions-Policy
Server fingerprinting Version banner matched to known CVEs Server removal + X-Powered-By removal

The diagram below shows where each IIS mechanism acts inside the response pipeline: request filtering removes the Server banner early, the module pipeline runs your handler, customHeaders injects the static baseline, and URL Rewrite outbound rules perform any conditional or last-mile rewrite before the bytes leave the box.

IIS response pipeline and where each header mechanism acts A left-to-right pipeline showing request filtering removing the Server banner, the module pipeline, customHeaders injecting the baseline, and URL Rewrite outbound rules rewriting headers before the response is sent. IIS response pipeline (per response) Request Filtering Module pipeline customHeaders inject baseline URL Rewrite outbound rules removeServerHeader strips Server: banner ASP.NET adds X-Powered-By here HSTS, CSP, nosniff, XFO, Referrer-Policy conditional / blank RESPONSE_* rewrite Response leaves the worker process with the merged header set
Each IIS mechanism owns a distinct stage: filtering strips banners, customHeaders injects the static baseline, URL Rewrite handles conditional rewrites last.

Directive Syntax & Spec

IIS declares headers as XML elements inside web.config, not as flat directives. The static baseline lives in the <add> collection under <httpProtocol>:

<system.webServer>
  <httpProtocol>
    <customHeaders>
      <add name="<field-name>" value="<field-value>" />
      <remove name="<field-name>" />
    </customHeaders>
  </httpProtocol>
</system.webServer>

<add> appends a header to every response; <remove> deletes a header that a parent configuration or a module already set. Because collections merge down the hierarchy, the safe idempotent pattern is <remove> immediately followed by <add> for any header you want to guarantee exactly once. The <clear /> element wipes the entire inherited collection when a child application must not inherit anything.

Element / attribute Type Default in IIS Security impact When to deviate
<add name value> collection append none emits the header on every status code this is the primary tool
<remove name> collection delete none drops a leaked or inherited header pair with <add> for idempotency
<clear /> collection reset none stops all inheritance into a child app sub-app must not inherit parent headers
requestFiltering removeServerHeader server flag false strips the Server version banner requires IIS 10 / Server 2016+
httpRuntime enableVersionHeader ASP.NET flag true removes X-AspNet-Version when false set false in every production app
<outboundRules> rewrite URL Rewrite none conditional or blank-value header edit HSTS only on HTTPS, blank Server
Strict-Transport-Security server-cached policy absent enforces HTTPS for max-age never send over plain HTTP
Content-Security-Policy per-response policy absent blocks injected script/frames stage with -Report-Only

Two malformed-syntax gotchas are specific to IIS. First, a value containing an XML special character — <, >, &, or a straight quote inside a CSP — must be entity-escaped (&lt;, &amp;) or the whole web.config fails to parse and the site returns 500.19. Second, adding a header with <add> when a parent already added it produces a duplicate header line rather than an overwrite; IIS does not deduplicate the customHeaders collection across scopes for you.

Platform-Specific Implementation

The four sub-sections below are the four mechanisms an IIS hardening pass touches, each with an exact web.config block and a one-line verification. For the equivalents on other stacks, see the Nginx security headers configuration and the Apache .htaccess & VirtualHost hardening guides; the reverse-proxy notes at the end also apply to fronting IIS with Cloudflare.

httpProtocol customHeaders — the static baseline

The <customHeaders> collection is the correct home for every unconditional header. Because IIS applies it to all responses including 4xx and 5xx, this single block hardens error pages automatically — no per-status flag exists or is needed. The leading <remove> on each line makes the block safe to re-apply and safe to inherit.

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <system.webServer>
    <httpProtocol>
      <customHeaders>
        <!-- HSTS — one year incl. subdomains; only meaningful over HTTPS, so serve it on the TLS binding only. -->
        <remove name="Strict-Transport-Security" />
        <add name="Strict-Transport-Security" value="max-age=31536000; includeSubDomains" />

        <!-- CSP — XML-escape any '&' inside the value; restricts script/frame origins. -->
        <remove name="Content-Security-Policy" />
        <add name="Content-Security-Policy" value="default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'" />

        <!-- Clickjacking guard for legacy UAs that ignore frame-ancestors. -->
        <remove name="X-Frame-Options" />
        <add name="X-Frame-Options" value="SAMEORIGIN" />

        <!-- Disable MIME sniffing on every response body, including error pages. -->
        <remove name="X-Content-Type-Options" />
        <add name="X-Content-Type-Options" value="nosniff" />

        <!-- Trim the Referer on cross-origin navigation. -->
        <remove name="Referrer-Policy" />
        <add name="Referrer-Policy" value="strict-origin-when-cross-origin" />

        <!-- Disable unused browser features to shrink attack surface. -->
        <remove name="Permissions-Policy" />
        <add name="Permissions-Policy" value="camera=(), microphone=(), geolocation=(), payment=()" />
      </customHeaders>
    </httpProtocol>
  </system.webServer>
</configuration>

Verify: curl -sI https://your-domain.com | findstr /i "Strict-Transport Content-Security X-Content-Type".

Removing the Server and X-Powered-By banners

The Server banner is removed at the request-filtering layer on IIS 10 and Windows Server 2016 or newer. X-Powered-By: ASP.NET is removed by deleting it from the customHeaders collection, and the ASP.NET version header is disabled in system.web.

<configuration>
  <system.webServer>
    <security>
      <!-- Strips the Server: Microsoft-IIS/10.0 banner globally (IIS 10 / Server 2016+). -->
      <requestFiltering removeServerHeader="true" />
    </security>
    <httpProtocol>
      <customHeaders>
        <!-- Deletes X-Powered-By: ASP.NET injected by the framework. -->
        <remove name="X-Powered-By" />
      </customHeaders>
    </httpProtocol>
  </system.webServer>
  <system.web>
    <!-- Removes X-AspNet-Version from responses. -->
    <httpRuntime enableVersionHeader="false" />
  </system.web>
</configuration>

Verify: curl -sI https://your-domain.com | findstr /i "Server X-Powered X-AspNet" should return nothing.

The state machine below shows how to remove the Server banner across IIS versions and proxy topologies, because removeServerHeader is only available from IIS 10 onward and a fronting proxy re-adds its own banner.

Decision tree for removing the Server banner by IIS version and topology A decision tree branching on IIS version and whether a reverse proxy fronts the server, leading to requestFiltering, a URL Rewrite outbound rule, or configuring the proxy node. Remove Server banner which IIS version? IIS 10 / 2016+ IIS 7.5–8.5 requestFiltering removeServerHeader="true" URL Rewrite outbound blank RESPONSE_Server Reverse proxy in front? ARR / Cloudflare / other Also strip / rewrite the banner on the edge node
The mechanism to remove the Server banner depends on IIS version, and a fronting proxy must strip its own banner independently.

URL Rewrite outbound rules — conditional headers

URL Rewrite outbound rules edit response headers with logic the static collection cannot express: emit HSTS only on HTTPS, or blank the Server banner on IIS versions before 10. A RESPONSE_<Header-Name> server variable maps to a response header, with underscores replacing hyphens.

<configuration>
  <system.webServer>
    <rewrite>
      <outboundRules>
        <!-- HSTS only when the request arrived over TLS. -->
        <rule name="Add HSTS when HTTPS" preCondition="IsHTTPS">
          <match serverVariable="RESPONSE_Strict_Transport_Security" pattern=".*" />
          <action type="Rewrite" value="max-age=31536000; includeSubDomains" />
        </rule>
        <!-- Blank the Server banner on IIS < 10 where removeServerHeader is unavailable. -->
        <rule name="Remove Server header">
          <match serverVariable="RESPONSE_Server" pattern=".+" />
          <action type="Rewrite" value="" />
        </rule>
        <preConditions>
          <preCondition name="IsHTTPS">
            <add input="{HTTPS}" pattern="^on$" />
          </preCondition>
        </preConditions>
      </outboundRules>
    </rewrite>
  </system.webServer>
</configuration>

Verify: curl -sI http://your-domain.com | findstr /i Strict-Transport should return nothing over plain HTTP, while the HTTPS request returns the header. Blanking RESPONSE_Server requires allowedServerVariables to include RESPONSE_Server in applicationHost.config.

ARR / reverse-proxy topologies

When IIS runs Application Request Routing (ARR) as a reverse proxy, the origin’s headers pass through to the client and ARR can add its own. If both the origin and the ARR edge set the same security header, the client receives it twice — browsers then pick one unpredictably, and a permissive duplicate can override a strict one. The rule is single ownership: set each security header on exactly one node. The sequence below traces where duplication is introduced and where an outbound rule removes it.

Header duplication in an IIS ARR reverse-proxy chain A sequence diagram showing a client request passing through an IIS ARR edge to an origin, the origin returning headers, ARR adding a duplicate, and an outbound rule deduplicating before the response reaches the client. Client IIS + ARR edge Origin app GET / forward request 200 + CSP (origin sets it) ARR also adds CSP → duplicate header outbound rule dedupes single CSP delivered
In an ARR chain, origin and edge can each set the same header; assign single ownership or dedupe with an outbound rule before the client sees a duplicate.

To keep single ownership on an ARR edge, strip the inherited value before re-adding your canonical one, and disable the ARR-injected X-Powered-By in the ARR server proxy settings:

<system.webServer>
  <httpProtocol>
    <customHeaders>
      <remove name="Content-Security-Policy" />
      <add name="Content-Security-Policy" value="default-src 'self'; frame-ancestors 'none'" />
    </customHeaders>
  </httpProtocol>
</system.webServer>

Verify: curl -sI https://edge.your-domain.com | findstr /i Content-Security-Policy should print the header exactly once.

Verification & Diagnostic Workflows

Every change is proven on the wire, not by reading web.config. Use curl on Windows (curl.exe ships with Windows 10/Server 2019+) or PowerShell’s Invoke-WebRequest.

Inspect the full header set on a success response:

curl -sI https://your-domain.com

Expected output after applying the baseline (banners absent, security headers present):

HTTP/2 200
strict-transport-security: max-age=31536000; includeSubDomains
content-security-policy: default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'
x-frame-options: SAMEORIGIN
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin
permissions-policy: camera=(), microphone=(), geolocation=(), payment=()

Confirm error responses are hardened too — IIS applies customHeaders to every status, so a forced 404 must carry the same headers:

curl -sI https://your-domain.com/this-path-does-not-exist

Confirm the banners are gone and no header is duplicated. grep -c counts occurrences; anything above 1 is a duplication bug:

curl -sI https://your-domain.com | grep -ci "content-security-policy"   # expect 1
curl -sI https://your-domain.com | grep -ci "^server:"                   # expect 0

PowerShell equivalent when curl is unavailable:

(Invoke-WebRequest -Uri https://your-domain.com -Method Head).Headers

Wire a regression check into CI so a future web.config edit cannot silently drop a header. This script exits non-zero on any missing header or leaked banner:

#!/usr/bin/env bash
set -euo pipefail
URL="https://your-domain.com"
H="$(curl -sI "$URL")"
for want in "strict-transport-security" "content-security-policy" "x-content-type-options" "x-frame-options"; do
  echo "$H" | grep -qi "$want" || { echo "MISSING: $want"; exit 1; }
done
echo "$H" | grep -qi "^server:" && { echo "LEAK: Server banner present"; exit 1; }
echo "$H" | grep -qi "x-powered-by" && { echo "LEAK: X-Powered-By present"; exit 1; }
echo "all header assertions passed"

Troubleshooting, Misconfigurations & Safe Rollback

Symptom-to-fix mapping for the failures that actually occur on IIS:

Safe rollback: web.config changes are transactional and hot — IIS re-reads the file on the next request with no worker recycle needed, so reverting is instant. Keep the prior file and restore it, or wrap a suspect block so it is inert:

<!-- Rolled back 2026-07-24: CSP broke the payments iframe. Restore after allow-listing the origin.
<add name="Content-Security-Policy" value="default-src 'self'" />
-->

Because an XML comment removes the element entirely, the previous inherited value (if any) takes effect on the very next request. For staged CSP rollout, ship Content-Security-Policy-Report-Only first, collect violations, then swap the header name to the enforcing form once the report stream is clean.

Frequently Asked Questions

Does IIS need an always flag like Nginx to protect error pages? No. The <httpProtocol><customHeaders> collection is applied to every response regardless of status code, so 4xx and 5xx pages already carry the headers. The IIS-specific concern is the opposite: the same collection is inherited by child applications and can be emitted twice, which you prevent with <remove> or <clear />.

Why do my headers appear twice in the response? A parent web.config, applicationHost.config, or an ARR reverse-proxy edge set the header, and the child <add> appended a second copy rather than overwriting. IIS does not deduplicate the customHeaders collection across scopes. Prefix each managed header with <remove name="..." /> so the result is exactly one line.

How do I remove the Server: Microsoft-IIS banner on older IIS? requestFiltering removeServerHeader="true" requires IIS 10 on Windows Server 2016 or newer. On IIS 7.5 through 8.5, use a URL Rewrite outbound rule that matches RESPONSE_Server with pattern .+ and rewrites it to an empty value, after adding RESPONSE_Server to allowedServerVariables.

Do I need URL Rewrite installed to set static security headers? No. The static baseline uses only the built-in <httpProtocol><customHeaders> collection, which is native to IIS. URL Rewrite is required only for conditional logic — sending HSTS solely over HTTPS, or blanking the Server banner on pre-IIS-10 servers.

Will a web.config change recycle the application pool? No. IIS re-reads web.config on the next request without recycling the worker process, so header changes and rollbacks take effect immediately. Editing applicationHost.config, by contrast, can trigger a broader reload.