IIS Clickjacking Protection
Setting X-Frame-Options and Content-Security-Policy frame-ancestors in IIS.
Quick config
Add this to your site's web.config file. IIS applies custom headers to every response by default — unlike NGINX, there is no separate "always" parameter to worry about.
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<system.webServer>
<httpProtocol>
<customHeaders>
<!-- Remove the ASP.NET version header (optional, good practice) -->
<remove name="X-Powered-By" />
<!-- Modern -- CSP frame-ancestors (all browsers since ~2018) -->
<add name="Content-Security-Policy" value="frame-ancestors 'none'" />
<!-- Fallback -- X-Frame-Options (older browser support) -->
<add name="X-Frame-Options" value="DENY" />
</customHeaders>
</httpProtocol>
</system.webServer>
</configuration>One header is enough. Both is fine — the browser uses CSP if it understands it, falls back to X-Frame-Options otherwise.
If you embed your own pages in iframes (dashboards, previews), replace 'none' with 'self' and DENY with SAMEORIGIN.
Where to put it
Custom headers can be applied at three levels in IIS. The same web.config XML works at every level:
- Site level — in the site's root
web.config. Applies to every response from that site. This is where most people add them. - Application level — in a sub-application's
web.config. Useful when a virtual directory or nested app needs a different framing policy. - Server level — in
applicationHost.config(%windir%\System32\inetsrv\config). Applies globally to every site on the server. Requires admin access.
The X-Powered-By trap
IIS includes a default header at the server level: X-Powered-By: ASP.NET. This header is defined in applicationHost.config and inherited by every site. The example above includes a <remove name="X-Powered-By" /> to strip it — this is optional, but it is good practice to remove headers that advertise your server version.
The remove element can also strip headers inherited from parent configuration levels. If a header is set in applicationHost.config and you want to override it at the site level, use remove first, then add:
<httpProtocol>
<customHeaders>
<!-- Strip the inherited policy first -->
<remove name="X-Frame-Options" />
<remove name="Content-Security-Policy" />
<!-- Then add our own -->
<add name="X-Frame-Options" value="DENY" />
<add name="Content-Security-Policy" value="frame-ancestors 'none'" />
</customHeaders>
</httpProtocol>always) or Apache (where you need Header always), IIS custom headers appear on every response: 200, 301, 404, 500 — all of them. This makes IIS one of the simpler stacks to secure against clickjacking.Method: IIS Manager (GUI)
If you prefer clicking to editing XML:
- Open IIS Manager.
- In the Connections pane, select your site, application, or directory.
- Double-click HTTP Response Headers in the Features view.
- In the Actions pane, click Add.
- Set the name to
X-Frame-Optionsand the value toDENY. - Click Add again and set the name to
Content-Security-Policyand the value toframe-ancestors 'none'.
IIS Manager writes these to your site's web.config automatically. You can verify by opening the file afterward — it will contain the same <customHeaders> block shown above.
Method: AppCmd (CLI)
AppCmd is IIS's command-line tool, located at %windir%\system32\inetsrv\appcmd.exe. Useful for scripting and automated deployments.
Add X-Frame-Options (straightforward — no special characters):
appcmd.exe set config "Default Web Site" -section:system.webServer/httpProtocol /+"customHeaders.[name='X-Frame-Options',value='DENY']"Content-Security-Policy is trickier because the value contains single quotes. On Windows, double the single quotes so the shell does not split the command:
appcmd.exe set config "Default Web Site" -section:system.webServer/httpProtocol /+"customHeaders.[name='Content-Security-Policy',value='frame-ancestors ''none''']"Replace "Default Web Site" with your site's name. Run appcmd list site to see all site names.
Method: ASP.NET Core
If you are running ASP.NET Core on IIS, you have two options. The simpler one is to add headers in web.config — same as classic ASP.NET — and skip touching application code.
If you prefer to set headers in the application itself, use middleware:
// Program.cs — add middleware early in the pipeline
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.Use(async (context, next) =>
{
context.Response.Headers.Append("X-Frame-Options", "DENY");
context.Response.Headers.Append("Content-Security-Policy", "frame-ancestors 'none'");
await next();
});
app.MapGet("/", () => "Hello World");
app.Run();ASP.NET Core 9.0+ also supports setting default headers via the hosting model in web.config when using the ASP.NET Core Module (ANCM). The customHeaders approach works at the IIS level regardless of which framework version you are on, so it is usually the safer bet.
Method: URL Rewrite outbound rules
If you need conditional header logic — for example, setting different X-Frame-Options values based on the requested URL or the response status code — use the URL Rewrite Module 2.0. It lets you set response headers with rule-based conditions.
URL Rewrite 2.0 is a separate download from iis.net. It does not ship with IIS by default.
applicationHost.config. Without this step, the outbound rule fails with an "unallowed server variable" error.In IIS Manager:
- Select your server node (not the site) in the Connections pane.
- Double-click URL Rewrite.
- In the Actions pane, click View Server Variables.
- Click Add and enter
RESPONSE_X_Frame_Options. - Repeat for
RESPONSE_Content_Security_Policy.
Or edit applicationHost.config directly (requires admin):
<system.webServer>
<rewrite>
<allowedServerVariables>
<add name="RESPONSE_X_Frame_Options" />
<add name="RESPONSE_Content_Security_Policy" />
</allowedServerVariables>
</rewrite>
</system.webServer>Once the server variables are allowed, add the outbound rules to your site's web.config:
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<system.webServer>
<rewrite>
<outboundRules>
<preConditions>
<preCondition name="IsHTML" logicalGrouping="MatchAll">
<add input="{RESPONSE_CONTENT_TYPE}" pattern="^text/html" />
</preCondition>
</preConditions>
<rule name="Add X-Frame-Options" preCondition="IsHTML">
<match serverVariable="RESPONSE_X_Frame_Options" pattern="^$" />
<action type="Rewrite" value="DENY" />
</rule>
<rule name="Add CSP frame-ancestors" preCondition="IsHTML">
<match serverVariable="RESPONSE_Content_Security_Policy" pattern="^$" />
<action type="Rewrite" value="frame-ancestors 'none'" />
</rule>
</outboundRules>
</rewrite>
</system.webServer>
</configuration>How this works:
preCondition="IsHTML"limits the rules to HTML responses. No point setting framing headers on images or CSS files.pattern="^$"matches an empty header value — the header is only added when it does not already exist. This prevents overwriting headers set elsewhere in the pipeline.- The
serverVariablenames use underscores:RESPONSE_X_Frame_Optionsmaps to theX-Frame-Optionsresponse header (underscores become dashes,RESPONSE_prefix is stripped).
Verify it is working
After deploying the changes, check that the headers are present:
curl -I https://yoursite.comLook for X-Frame-Options: DENY or Content-Security-Policy: frame-ancestors 'none' in the output.
Also test an error page — a URL that returns 404 — to confirm headers appear on non-200 responses:
curl -I https://yoursite.com/nonexistentIIS applies custom headers to all responses by default, so this should pass without additional configuration.
Then run your site through ClickJack Test to confirm.
Notes on ALLOW-FROM
The X-Frame-Options: ALLOW-FROM https://example.com directive is obsolete. It was never supported in Chrome or Safari, and modern Firefox versions treat it the same as SAMEORIGIN. Use CSP frame-ancestors https://example.com if you need to allow framing from a specific origin.