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.
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 (<, &) 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.
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.
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:
HTTP 500.19immediately after editingweb.config. The XML is malformed — most often an unescaped&or<inside a CSP value. Escape them as&/<or the parser rejects the whole file. Validate with%windir%\system32\inetsrv\appcmd.exe list config.- A header appears twice in the response. A parent
web.config,applicationHost.config, or an ARR edge already set it and the child<add>appended a second copy. Prefix every managed header with<remove name="..." />, or place<clear />at the top of the child’scustomHeaderscollection. Serverbanner still present on IIS 10.removeServerHeaderwas set in a siteweb.configbut the banner is added before the site scope on some paths; set it inapplicationHost.configunder<security><requestFiltering>so it applies server-wide, and confirm the OS is Server 2016+.- HSTS being sent over plain HTTP. The static
customHeadersblock emits it on both bindings. Move HSTS into a URL Rewrite outbound rule gated by theIsHTTPSprecondition, or bind the header only to the HTTPS site. - Outbound rewrite rule has no effect. The
RESPONSE_Serverserver variable is not in the allow-list. Add it under<serverRuntime>/allowedServerVariablesinapplicationHost.config; URL Rewrite refuses to write server variables that are not explicitly permitted. - Headers correct at the origin but wrong at the client behind ARR. The edge is adding or stripping headers. Assign single ownership of each header to one node and dedupe on the edge with an outbound rule.
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.
Related
-
Server & Platform Implementation Guides — the parent reference for all server hardening.
-
Nginx Security Headers Configuration — the
add_header alwaysequivalent and inheritance model. -
Apache .htaccess & VirtualHost Hardening — the
Header always setequivalent for Apache. -
IIS URL Rewrite outbound rules — conditional header rewriting.