Skip to content
ClickJack Test

← Guides

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 sends custom headers on error responses too, so unlike NGINX there is no "always" flag to remember.

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <system.webServer>
    <httpProtocol>
      <customHeaders>
        <!-- Optional: drop the inherited X-Powered-By: ASP.NET header -->
        <remove name="X-Powered-By" />
        <!-- CSP frame-ancestors: supported by every current browser -->
        <add name="Content-Security-Policy" value="frame-ancestors 'none'" />
        <!-- X-Frame-Options: fallback for older browsers -->
        <add name="X-Frame-Options" value="DENY" />
      </customHeaders>
    </httpProtocol>
  </system.webServer>
</configuration>

Either header protects modern browsers on its own. Sending both is fine: a browser that sees a CSP frame-ancestors directive ignores X-Frame-Options, and older browsers fall back to it.

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 set at three levels, and child levels inherit from their parents. The same <httpProtocol> block works at each one:

  • Site level: the site's root web.config. Covers every response from that site. This is where most people put it.
  • Application or folder level: a web.config in a sub-application, virtual directory or folder. Use it when one part of the site needs a different framing policy.
  • Server level: applicationHost.config (%windir%\System32\inetsrv\config). Covers every site on the server. Requires admin rights.

The X-Powered-By trap

The default applicationHost.config adds X-Powered-By: ASP.NET at the server level, and every site inherits it. The <remove name="X-Powered-By" /> line in the example strips it. That is optional, but there is no reason to advertise your stack.

Inheritance is also the trap. If a parent level (the server, or a parent site) already adds a header with the same name, adding it again in a child web.config fails with HTTP 500.19 ("Cannot add duplicate collection entry"). To override an inherited header, remove it first, then add your own. Removing a header that is not inherited is harmless, so this pattern is safe to use everywhere:

<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>
IIS applies custom headers to error responses too. Unlike NGINX (which needs always) or Apache (which needs Header always), IIS adds them to 200, 301, 404 and 500 responses alike. The exception is errors that HTTP.sys sends before a request reaches IIS (a malformed request, or a 503 when the app pool is stopped). Those plain error pages carry no custom headers, but they have nothing to click.

Method: IIS Manager (GUI)

If you prefer clicking to editing XML:

  1. Open IIS Manager.
  2. In the Connections pane, select your site, application, or directory.
  3. Double-click HTTP Response Headers in the Home pane.
  4. In the Actions pane, click Add....
  5. In the Add Custom HTTP Response Header dialog, set the name to X-Frame-Options and the value to DENY, then click OK.
  6. Click Add... again: name Content-Security-Policy, value frame-ancestors 'none'.

IIS Manager writes these to the web.config of whatever you selected (or to applicationHost.config if you selected the server). Open the file afterward and you will see the same <customHeaders> block shown above.

Method: AppCmd (CLI)

AppCmd is IIS's command-line tool, located at %windir%\system32\inetsrv\appcmd.exe. It is not on the PATH by default. Run these from an elevated prompt:

%windir%\system32\inetsrv\appcmd.exe set config "Default Web Site" -section:system.webServer/httpProtocol /+"customHeaders.[name='X-Frame-Options',value='DENY']"

AppCmd wraps each value in single quotes, so a CSP value like frame-ancestors 'none' collides with its syntax. PowerShell's WebAdministration module avoids the problem:

Import-Module WebAdministration
Add-WebConfigurationProperty -PSPath 'MACHINE/WEBROOT/APPHOST/Default Web Site' `
  -Filter 'system.webServer/httpProtocol/customHeaders' -Name '.' `
  -Value @{ name = 'Content-Security-Policy'; value = "frame-ancestors 'none'" }

Replace Default Web Site with your site's name (appcmd list site lists them). Both commands write to the site's web.config, and both fail if a header with that name already exists, including one inherited from a parent level.

Method: ASP.NET Core

ASP.NET Core on IIS gives you two options. The simpler one is the web.config block above. IIS adds those headers itself, so it works for in-process and out-of-process hosting on any framework version, with no code changes.

To set headers in the application instead, use middleware:

// Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Register after UseExceptionHandler (it clears headers on errors)
// and before UseStaticFiles and your endpoints.
app.Use(async (context, next) =>
{
    context.Response.Headers["X-Frame-Options"] = "DENY";
    context.Response.Headers["Content-Security-Policy"] = "frame-ancestors 'none'";
    await next();
});

app.MapGet("/", () => "Hello World");
app.Run();

ASP.NET Core's antiforgery system (used by MVC and Razor Pages forms) adds X-Frame-Options: SAMEORIGIN by default when the app has not set the header. The middleware above sets it first, so antiforgery leaves it alone. With the web.config approach, IIS adds its value on top, so a response can carry both. Browsers treat conflicting values as a block, so you stay protected, but set SuppressXFrameOptionsHeader = true in the antiforgery options to keep one clean value.

Method: URL Rewrite outbound rules

If you need conditional logic, such as different framing policies per path or status code, use outbound rules in the URL Rewrite Module (current version 2.1). It is a separate download from iis.net and does not ship with IIS.

Outbound rules that set response headers do not need anything added to allowedServerVariables. That list only applies to inbound rules that set request headers or server variables. Add the 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, which is where clickjacking happens. Drop it if you also serve other documents that can be framed, such as PDFs or SVGs.
  • pattern="^$" matches an empty value, so the rule only adds the header when nothing else in the pipeline has set it. Use pattern=".*" to overwrite instead.
  • RESPONSE_X_Frame_Options maps to the X-Frame-Options response header: URL Rewrite strips the RESPONSE_ prefix and turns underscores into dashes.
customHeaders vs URL Rewrite: pick one. Configuring both can produce duplicate headers. customHeaders is simpler and covers most sites. Reach for URL Rewrite only when you need conditional logic.

Verify it is working

After deploying the changes, check that the headers are present:

curl -I https://yoursite.com

Look for X-Frame-Options: DENY or Content-Security-Policy: frame-ancestors 'none' in the output.

Then check a URL that returns 404 to confirm the headers appear on error responses:

curl -I https://yoursite.com/nonexistent

With customHeaders this should pass with no extra configuration. If it fails, check for a child web.config that removes the headers, or a URL Rewrite precondition that skips the response.

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. Chrome and Safari never supported it, and modern browsers ignore the whole header when they see it, which leaves the page frameable by anyone. Use CSP frame-ancestors https://example.com if you need to allow framing from a specific origin.

Check your headers

See whether a site sends X-Frame-Options or CSP frame-ancestors, and what browsers will do with them.