How to Set Security Headers in IIS: CSP, HSTS and web.config
If your site runs on Windows IIS, security headers such as Content-Security-Policy, Strict-Transport-Security and X-Frame-Options are set through the web.config file or IIS Manager, not through the nginx or Apache settings most guides describe. The method is simple once you know where the XML goes, but small details trip people up: a CSP value that breaks the XML, a header that appears twice because the application also sets it, or HSTS that never shows because it only applies to HTTPS or your IIS version lacks the setting. This guide shows how to add each header in web.config and IIS Manager, how to add HSTS properly, why HSTS may not show, and how to check what IIS really sends.
- How IIS adds custom response headers
- Adding security headers in web.config
- How to set Content-Security-Policy in web.config
- Three ways to add HSTS on IIS
- Why HSTS is not showing in IIS
- How to check the headers IIS sends
- The fix, step by step
- Edge cases worth knowing
- Keeping the headers in place
- Questions and answers
How IIS adds custom response headers
Windows IIS adds extra response headers through the customHeaders collection in the system.webServer/httpProtocol section of the configuration. You can set it in three places, and they stack from the top down:
- Server level, in
applicationHost.config, applies to every site on the machine. - Site level, in the
web.configfile in the site's root folder, applies to that site. - Folder or application level, in a
web.configinside a subfolder, applies below that folder.
Lower levels inherit from higher ones unless they clear the collection. When you add a header in IIS Manager under HTTP Response Headers, IIS Manager simply writes the same customHeaders entry into the config file for the level you selected. So whether you edit web.config by hand or click through the interface, the result is the same XML.
Custom headers are added to responses for static files and for applications IIS hosts, including ASP.NET and ASP.NET Core sites behind the ASP.NET Core Module. That makes web.config a good single place to own your security headers on a Windows server, as long as the application does not also set them.
web.config before editing it. A single XML mistake makes IIS return a 500.19 configuration error for the whole site, and restoring the copy is the quickest way back.Adding security headers in web.config
Open the web.config in the site root and add a httpProtocol block inside system.webServer. If the file already has a system.webServer element, add the block inside it rather than creating a second one.
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<system.webServer>
<httpProtocol>
<customHeaders>
<remove name="X-Powered-By" />
<add name="X-Content-Type-Options" value="nosniff" />
<add name="X-Frame-Options" value="SAMEORIGIN" />
<add name="Referrer-Policy" value="strict-origin-when-cross-origin" />
<add name="Permissions-Policy" value="camera=(), microphone=(), geolocation=()" />
<add name="Content-Security-Policy-Report-Only" value="default-src 'self'; img-src 'self' data:; frame-ancestors 'self'" />
</customHeaders>
</httpProtocol>
<security>
<requestFiltering removeServerHeader="true" />
</security>
</system.webServer>
</configuration>
The remove line drops the X-Powered-By: ASP.NET header that IIS adds by default, and removeServerHeader drops the Server header on IIS 10 version 1709 and later (Windows Server 2019 onwards). Neither is a security fix in itself, but scanners often flag them as information disclosure.
The same thing in IIS Manager
- Open IIS Manager, expand Sites and select your site.
- Double-click HTTP Response Headers.
- Click Add in the Actions pane, enter the header name and value, and click OK.
- Repeat for each header. To remove
X-Powered-By, select it and click Remove.
Changes apply straight away; you do not need to restart the site. For a full list of headers and valid values, see the missing security headers guide.
How to set Content-Security-Policy in web.config
A Content Security Policy goes in customHeaders like any other header, but the XML format adds three rules:
- Keep it on one line. The value is an XML attribute, so write the whole policy in one string, with directives separated by semicolons.
- Use single quotes inside the value. CSP keywords such as
'self'and'none'need single quotes, which sit safely inside the double-quoted XML attribute. - Escape special XML characters. An ampersand or a less-than sign in a URL inside the policy must be written as an XML entity, or IIS will reject the file.
<add name="Content-Security-Policy"
value="default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" />
Start with the header name Content-Security-Policy-Report-Only. The browser then reports what the policy would block without blocking it, so you can watch the DevTools console and your reports for a week or two, add the sources your site really uses, and switch the name to Content-Security-Policy when nothing legitimate is being reported. The CSP report-only guide covers reading the reports.
ASP.NET sites often rely on inline scripts, for example WebForms postback scripts, which a strict policy blocks. If you cannot remove them, you may need 'unsafe-inline' for scripts at first, then work towards nonces. Framework-specific advice is in the CSP and frameworks guide.
Three ways to add HSTS on IIS
Strict-Transport-Security should only be sent on HTTPS responses. Browsers ignore it over plain HTTP, and some scanners flag it there as invalid. That is why the simple customHeaders method is not ideal for HSTS: it adds the header to every response, HTTP included. There are better options.
| Method | Works on | Sends on HTTPS only |
|---|---|---|
| Native HSTS site setting | IIS 10.0 version 1709 and later (Windows Server 2019 onwards) | Yes |
| URL Rewrite outbound rule | Any IIS with the URL Rewrite module installed | Yes, with a condition |
customHeaders in web.config | Any IIS | No; also sent over HTTP |
Native HSTS
On a recent IIS, select the site in IIS Manager and look for HSTS in the Actions pane, or set it from an elevated command prompt:
%windir%\system32\inetsrv\appcmd.exe set config -section:system.applicationHost/sites "/[name='Example Site'].hsts.enabled:True" "/[name='Example Site'].hsts.max-age:31536000" "/[name='Example Site'].hsts.includeSubDomains:True" "/[name='Example Site'].hsts.redirectHttpToHttps:True" /commit:apphost
This writes to applicationHost.config, not web.config, so you will not see it in the site folder.
URL Rewrite outbound rule
<rewrite>
<outboundRules>
<rule name="Add HSTS on HTTPS">
<match serverVariable="RESPONSE_Strict_Transport_Security" pattern=".*" />
<conditions>
<add input="{HTTPS}" pattern="on" ignoreCase="true" />
</conditions>
<action type="Rewrite" value="max-age=31536000; includeSubDomains" />
</rule>
</outboundRules>
</rewrite>
This goes inside system.webServer. Start with a short max-age, such as 300, until you are sure every subdomain works over HTTPS; the disabling HSTS guide explains why a long one is hard to undo.
Why HSTS is not showing in IIS
If you have set HSTS and a scanner or curl still says it is missing, one of these is usually the reason:
| Cause | How to tell | Fix |
|---|---|---|
| You are testing the HTTP address | The test URL starts with http:// | Native HSTS and rewrite rules only send it on HTTPS; test the https URL |
| No HSTS option in IIS Manager | Older Windows Server, before IIS 10.0 version 1709 | Use the URL Rewrite rule instead |
| URL Rewrite not installed | The rewrite section causes a 500.19 error, or is ignored | Install the URL Rewrite module, then reload the site |
| Set on the wrong site or binding | Several sites share the server | Check the site name in appcmd list site and its https binding |
| A proxy or CDN in front | Responses show the proxy's headers | Set HSTS on the load balancer, Azure Front Door or CDN that terminates TLS |
ASP.NET Core UseHsts on localhost | Testing on the server itself | The middleware skips localhost by default; test the public host name |
A clear element in a lower web.config | Headers set at server level vanish for one site | Remove the clear or add the headers again at that level |
A certificate or binding problem can also stop HTTPS working at all, so no HTTPS response, and no HSTS, ever reaches the visitor. The IIS SSL bindings guide covers that case.
How to check the headers IIS sends
Windows 10 and later, and Windows Server 2019 and later, include curl.exe. Use it from Command Prompt or PowerShell, typing curl.exe rather than curl, because in Windows PowerShell 5.1 curl is an alias for a different command:
curl.exe -sI https://www.example.com/
curl.exe -sI http://www.example.com/
Or use PowerShell directly:
(Invoke-WebRequest -Uri https://www.example.com/ -Method Head -UseBasicParsing).Headers
To see what the configuration says, rather than what the response says, list the section for the site:
%windir%\system32\inetsrv\appcmd.exe list config "Example Site" -section:system.webServer/httpProtocol
If the config lists a header that the response lacks, something after IIS, such as a proxy, is removing it, or the application is overwriting it. If the response has a header twice, both IIS and the application are adding it; remove one. You can also run the site through our security audit to see every header on several pages at once.
The fix, step by step
Step 1
Back up web.config
Copy the site's web.config to a safe place before you change anything, and note the IIS version from IIS Manager's Help, About. The version decides whether native HSTS is available. Check whether the application or a proxy already sets any security headers by running curl.exe -sI on the HTTPS address, so you know your starting point and can spot duplicates after the change. Keep the curl output in a text file for comparison later.
Step 2
Add the basic headers
Inside system.webServer, add an httpProtocol block with a customHeaders collection, and add X-Content-Type-Options, X-Frame-Options, Referrer-Policy and Permissions-Policy. Save the file. If the site returns a 500.19 error, the XML is malformed or the section is locked, so restore the backup and check the syntax. Otherwise run curl.exe -sI on the HTTPS address and confirm each header appears exactly once. If you prefer the interface, add the same headers in IIS Manager under HTTP Response Headers, which writes identical entries to the file.
Step 3
Remove information headers
Add a remove element for X-Powered-By to the customHeaders collection and set removeServerHeader to true under security/requestFiltering on IIS 10 version 1709 or later. For ASP.NET sites, also set enableVersionHeader to false on httpRuntime in system.web. Reload the page with curl and confirm that X-Powered-By, Server and X-AspNet-Version no longer appear in the response headers. Scanners often report these as information disclosure, so removing them closes those findings, although it does not fix any weakness by itself.
Step 4
Add CSP in report-only mode
Add a Content-Security-Policy-Report-Only header with your starting policy on a single line, with keywords such as 'self' in single quotes. Browse the site with DevTools open and note every report in the console. Add the real sources your pages use, such as a CDN, analytics or payment provider, and keep the policy in report-only mode for one to two weeks so that rarely used pages are visited too.
Step 5
Switch CSP to enforcing
When the reports show only things you want blocked, rename the header to Content-Security-Policy, keeping the same value. Test the pages that matter most, such as checkout, contact forms, logins and embedded maps, with the browser console open, and look for any blocked resource messages. Keep a copy of the report-only version, so you can switch back quickly if a page breaks after the change goes live.
Step 6
Add HSTS for HTTPS only
On IIS 10.0 version 1709 or later, enable the site's native HSTS setting in IIS Manager or with appcmd, starting with a max-age of 300. On older servers, install URL Rewrite and add the outbound rule that sets RESPONSE_Strict_Transport_Security when {HTTPS} is on. Do not also add it in customHeaders, or HTTPS responses will carry it twice. Confirm the setting saved by listing it with appcmd.
Step 7
Test HTTPS and HTTP separately
Run curl.exe -sI on the https address and confirm Strict-Transport-Security appears with your value. Then run it on the http address: it should redirect to HTTPS, and it is normal for HSTS to be absent there. If HSTS is still missing on HTTPS, check the site name and binding, whether a proxy sits in front, and whether a lower web.config clears the headers for that folder.
Step 8
Raise max-age and rescan
After a few days with no problems, and once every subdomain is confirmed to work over HTTPS, raise max-age to 31536000 and add includeSubDomains if it suits. Run the original scanner again on the same URLs and confirm the findings are closed. Commit the final web.config to source control, so the next publish or server rebuild keeps your headers in place without anyone having to remember them.
Edge cases worth knowing
- Application headers win or duplicate. If ASP.NET code also sets
X-Frame-Optionsor a CSP, the response can carry two values. Pick one place and remove the other. - Error pages. Detailed IIS error pages and some 401 responses may not carry custom headers; check a 404 with curl.
- Web Deploy and publish overwrites. Publishing an ASP.NET Core app regenerates
web.config. Add your headers to the project's ownweb.configso they survive. - Azure App Service. Windows plans read
web.configthe same way, but Front Door or Application Gateway in front may need the headers too. - Shared hosting. If the host locks the
httpProtocolsection, you get a 500.19 lock error; ask them to unlock it or set the headers.
Keeping the headers in place
Keep the security header block in source control with the site, so every deployment carries it. After each Windows update, IIS change or publish, run curl.exe -sI on the home page, a deep page and a 404, and compare against your list. Rescan monthly, and keep the reports for clients or auditors. If the site sits behind a proxy, check the public address, not the server's own name, because that is what visitors and scanners see. Redirect rules affect HSTS too; see the HTTPS redirects guide.
1. How do I set Content Security Policy in IIS?
You set a Content Security Policy in IIS by adding a custom response header named Content-Security-Policy, either in IIS Manager under HTTP Response Headers or in web.config under system.webServer, httpProtocol and customHeaders. Write the whole policy on one line, with directives separated by semicolons and keywords such as self in single quotes. Start with the Content-Security-Policy-Report-Only header name, watch the browser console for a week or two, then switch to the enforcing name once only unwanted resources are reported.
2. How do I add a Content Security Policy header in web.config?
Add an add element inside the customHeaders collection, with name set to Content-Security-Policy and the policy as its value. The collection sits inside httpProtocol, which sits inside system.webServer. Keep the value on one line, use single quotes around CSP keywords, and escape any ampersand as an XML entity, because an invalid file makes IIS return a 500.19 error. Save the file and check the response with curl.exe -sI to confirm the header appears once and with the value you expect.
3. Why is HSTS not showing in IIS?
HSTS usually does not show in IIS because you are testing the http address, where native HSTS and rewrite rules deliberately do not send it, or because your IIS version is older than 10.0 version 1709 and has no HSTS option in IIS Manager. Other causes are a proxy or CDN in front of IIS that terminates TLS, the setting applied to a different site, ASP.NET Core skipping HSTS on localhost, and a clear element in a lower web.config.
4. Where is the HSTS setting in IIS Manager?
On IIS 10.0 version 1709 and later, which includes Windows Server 2019 and newer versions, select the site in IIS Manager and look for HSTS in the Actions pane on the right. There you can enable it, set max-age, include subdomains and redirect HTTP to HTTPS. On older versions the option does not exist, so use a URL Rewrite outbound rule that sets the header only when the HTTPS server variable is on, and test the result with curl.exe.
5. Should I add HSTS with customHeaders in web.config?
It works, but it is not the best method, because customHeaders adds the header to every response, including plain HTTP ones, where browsers ignore it and some scanners mark it as invalid. Native HSTS on IIS 10.0 version 1709 or later, or a URL Rewrite outbound rule with an HTTPS condition, sends it only over HTTPS. Use one method only, because using two at once gives duplicate headers on your HTTPS responses and can confuse scanners.
6. Why does my web.config change give a 500.19 error?
A 500.19 error after editing web.config means IIS cannot read the configuration, usually because of malformed XML or a locked section. Common causes are a missing closing tag, a second system.webServer element, an unescaped ampersand inside a header value, or a rewrite section on a server without URL Rewrite installed. On shared hosting, the httpProtocol section may be locked at server level. Restore your backup, then add the change again in small steps, testing after each one.
7. How do I remove the X-Powered-By and Server headers in IIS?
Remove X-Powered-By by adding a remove element with that name inside customHeaders in web.config, or by selecting it in HTTP Response Headers in IIS Manager and clicking Remove. On IIS 10 version 1709 and later (Windows Server 2019 onwards), remove the Server header by setting removeServerHeader to true under requestFiltering in the security section. For ASP.NET, also turn off the X-AspNet-Version header with enableVersionHeader set to false. These steps hide version details that scanners report as information disclosure, but they do not fix any weakness in the software itself, so keep IIS and Windows updated as well.
Our security audit fetches your pages over HTTPS and HTTP and lists each security header with its value, so you can see what IIS really sends and which ones are missing or invalid.
Open Security Audit Fixes โ