IIS has no .htaccess. Its equivalent is web.config, an XML file in the site's root folder that the folders below it inherit, and which a folder can override with a web.config of its own. Like .htaccess, a change takes effect on the next request with no restart.
This article is the IIS companion to our htaccess tips and tricks. It does the same jobs, in the same order, the IIS way, and adds a few that only IIS needs. Each section stands on its own.
Written for IIS 10 on Windows Server 2016 to 2025 and Windows 10 and 11.
Redirect and rewrite rules need the free URL Rewrite module from Microsoft, which is not part of IIS out of the box. Without it, a web.config containing a <rewrite> section stops the whole site with error 500.19. After installing, a URL Rewrite icon appears in IIS Manager.
Everything in this article goes inside system.webServer:
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<system.webServer>
<rewrite>
<rules>
<!-- rules, in order -->
</rules>
</rewrite>
<httpProtocol> ... headers ... </httpProtocol>
<staticContent> ... caching, MIME ... </staticContent>
</system.webServer>
</configuration>
Each section may appear only once. Two <rewrite> sections, or a typo in the XML, gives a 500.19 for every page, so keep a copy of the working file before every change.
In IIS Manager, open URL Rewrite and choose Import Rules. Paste mod_rewrite rules and IIS converts what it can. It is a good start, but check each rule: flags such as END and conditions on %{DOCUMENT_ROOT} do not translate directly.
Rules run from the top. stopProcessing="true" is the equivalent of the L flag: if the rule matches, no later rule runs. Unlike Apache, IIS does not run the rules again on the rewritten address, so there is no need for an END flag.
Two other differences catch people moving from Apache:
Matching is case-insensitive unless you add ignoreCase="false", and the query string is carried across to the target unless you add appendQueryString="false".
<rule name="Canonical host" stopProcessing="true">
<match url="(.*)" />
<conditions logicalGrouping="MatchAny">
<add input="{HTTPS}" pattern="^OFF$" />
<add input="{HTTP_HOST}" pattern="^www\." />
</conditions>
<action type="Redirect" url="https://example.com/{R:1}" redirectType="Permanent" />
</rule>
logicalGrouping="MatchAny" means either condition is enough: plain http, or a host starting with www. The domain is typed in because IIS cannot capture from one condition and use it when the other matched. The full reasoning, and the traps with certificates and caching, are in How to Redirect www to non-www; they apply equally to IIS.
The site must have bindings for every name and scheme it redirects from, or IIS never receives the request: http and https, with and without www.
<rule name="Second domain" stopProcessing="true">
<match url="(.*)" />
<conditions>
<add input="{HTTP_HOST}" pattern="^(www\.)?example\.nz$" />
</conditions>
<action type="Redirect" url="https://example.com/{R:1}" redirectType="Found" />
</rule>
Found is a 302; use Permanent for a 301 if the domain will only ever be an alias.
<rule name="Download pages" stopProcessing="true">
<match url="^(download-file|download-code)/([^/]+)$" />
<action type="Rewrite" url="{R:1}.php?name={R:2}" />
</rule>
The visitor sees /download-file/ping-log, the script receives download-file.php?name=ping-log. The same works for an ASP.NET page or a .aspx handler; only the target changes.
<rule name="whatsmyip" stopProcessing="true">
<match url="^whatsmyip$" />
<action type="Rewrite" url="developer-tools/internet/whats-my-ip.php"
appendQueryString="false" />
</rule>
A rewrite, so the address bar keeps the short form. Give the deep page a canonical link to its full address so only one is indexed.
<rule name="Add .php" stopProcessing="true">
<match url="^(?!.+\.\w{2,4}$)(.+)$" />
<conditions>
<add input="{REQUEST_FILENAME}.php" matchType="IsFile" />
</conditions>
<action type="Rewrite" url="{R:1}.php" />
</rule>
{REQUEST_FILENAME} is the physical path the address maps to, so the condition only rewrites when contact-us.php really exists beside the requested contact-us. Copy the rule with .aspx or .html for those pages. The lookahead skips addresses that already have an extension.
<rule name="No trailing slash" stopProcessing="true">
<match url="(.+)/$" />
<conditions>
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
</conditions>
<action type="Redirect" url="/{R:1}" redirectType="Permanent" />
</rule>
Real folders keep their slash; everything else loses it.
<rule name="Old blog gone" stopProcessing="true">
<match url="^blog/" />
<action type="CustomResponse" statusCode="410"
statusReason="Gone" statusDescription="This section has been removed." />
</rule>
Use statusCode="403" to forbid instead. type="AbortRequest" drops the connection without any reply, which is useful for obvious attack traffic.
<httpProtocol>
<customHeaders>
<remove name="X-Powered-By" />
<add name="Strict-Transport-Security" value="max-age=31536000; includeSubDomains" />
<add name="Referrer-Policy" value="strict-origin-when-cross-origin" />
<add name="X-Frame-Options" value="SAMEORIGIN" />
<add name="X-Content-Type-Options" value="nosniff" />
<add name="Permissions-Policy" value="geolocation=(), camera=(), microphone=()" />
</customHeaders>
</httpProtocol>
IIS also announces itself with a Server: Microsoft-IIS/10.0 header. On IIS 10 version 1709 and later, one setting removes it:
<security>
<requestFiltering removeServerHeader="true" />
</security>
A Content-Security-Policy is added the same way as the other headers. If it uses a nonce, the nonce must be new for every page view, so it has to come from the page code, not from web.config.
If a header is already set at server level, adding it again here gives a duplicate-entry error; put a <remove name="..." /> before the <add>.
For static files IIS sets Cache-Control itself, from clientCache. It applies to a folder and everything below, not to file types, so the tidy way is one setting for the site and a location block for each folder that differs:
<staticContent>
<clientCache cacheControlMode="UseMaxAge" cacheControlMaxAge="01:00:00" />
</staticContent>
And outside system.webServer, directly inside configuration:
<location path="img">
<system.webServer>
<staticContent>
<clientCache cacheControlMode="UseMaxAge" cacheControlMaxAge="30.00:00:00" />
</staticContent>
</system.webServer>
</location>
<location path="downloads">
<system.webServer>
<staticContent>
<clientCache cacheControlMode="DisableCache" />
</staticContent>
</system.webServer>
</location>
The time is days.hours:minutes:seconds. Pages produced by PHP or ASP.NET set their own headers in code. As with Apache, add a version to stylesheet and script links, as in main.css?017, so a long cache time does not hide a change.
<urlCompression doStaticCompression="true" doDynamicCompression="true" />
Static compression is installed with IIS. Dynamic Content Compression, for pages made by PHP or ASP.NET, is a separate Windows feature under Web Server, Performance; turn it on in Windows Features or Server Manager first. Which file types are compressed is set at server level in httpCompression, which a site's web.config cannot change by default.
Apache serves a file of unknown type anyway. IIS refuses it with 404.3, which looks exactly like a missing file. Newer formats and some download types need adding:
<staticContent>
<remove fileExtension=".webp" />
<mimeMap fileExtension=".webp" mimeType="image/webp" />
<remove fileExtension=".rar" />
<mimeMap fileExtension=".rar" mimeType="application/vnd.rar" />
<remove fileExtension=".webmanifest" />
<mimeMap fileExtension=".webmanifest" mimeType="application/manifest+json" />
</staticContent>
The remove before each mimeMap stops a 500.19 on a server where a later version of IIS already knows the type.
Directory browsing is off by default in IIS. To be sure:
<directoryBrowse enabled="false" />
To make a folder of include files or settings unreachable from the web, while scripts on the server can still read it, hide it with request filtering. Any request that has the folder name anywhere in its path gets a 404:
<security>
<requestFiltering removeServerHeader="true">
<hiddenSegments>
<add segment="lib" />
<add segment="logs" />
</hiddenSegments>
</requestFiltering>
</security>
bin, App_Data and web.config itself are hidden already. Note that the segment is matched in any position, so lib also hides /articles/lib/.
<httpErrors errorMode="Custom" existingResponse="Replace">
<remove statusCode="404" />
<error statusCode="404" path="/404.html" responseMode="ExecuteURL" />
<remove statusCode="500" />
<error statusCode="500" path="/500.html" responseMode="ExecuteURL" />
</httpErrors>
By default IIS shows detailed errors to a browser on the server itself and its own plain pages to everyone else, which is why custom pages can seem not to work when you test on the server. errorMode="Custom" shows yours everywhere. ExecuteURL keeps the real status code; Redirect would send a 302 to the error page, which search engines treat as a working page.
IIS rejects any request body over about 28.6 MB with 404.13, before PHP or ASP.NET sees it. Raise the limit, in bytes:
<security>
<requestFiltering>
<requestLimits maxAllowedContentLength="104857600" />
</requestFiltering>
</security>
That is 100 MB. PHP's upload_max_filesize and post_max_size, or ASP.NET's maxRequestLength, have to be raised to match. Keep only one requestFiltering element: merge this with the hidden segments above.
On Apache we keep a tiny PHP page under each retired address rather than a rule, so a rarely used redirect costs nothing on every other request. The same works on IIS, and IIS adds two choices of its own.
<?php
header ('Location: https://example.com/contact-us', true, 301);
exit;
Saved as contact.php, and with the extensionless rule above, /contact runs it. In ASP.NET the same is one line: Response.RedirectPermanent ("/contact-us");.
A web.config in the old folder can redirect everything in it without URL Rewrite. It needs the HTTP Redirection feature of IIS, under Common HTTP Features in Windows Features:
<configuration>
<system.webServer>
<httpRedirect enabled="true" destination="https://example.com/new-folder"
httpResponseStatus="Permanent" />
</system.webServer>
</configuration>
Without exactDestination="true", the rest of the path is added to the destination, so /old-folder/page goes to /new-folder/page.
URL Rewrite has something .htaccess lacks: a rewrite map, a lookup table used by a single rule. However many entries it holds, IIS does one lookup per request instead of testing a rule for each, so a long list of old addresses no longer slows every page:
<rewrite>
<rewriteMaps>
<rewriteMap name="Old Pages">
<add key="/contact" value="/contact-us" />
<add key="/credits" value="/attributions" />
<add key="/download" value="/downloads" />
</rewriteMap>
</rewriteMaps>
<rules>
<rule name="Old pages" stopProcessing="true">
<match url=".*" />
<conditions>
<add input="{Old Pages:{REQUEST_URI}}" pattern="(.+)" />
</conditions>
<action type="Redirect" url="{C:1}" redirectType="Permanent" />
</rule>
</rules>
</rewrite>
The map can be kept in a file of its own with <rewriteMaps configSource="rewritemaps.config" />, so adding an old address never means touching the rules. Put this rule near the top. For a handful of addresses, redirect pages are still the simplest; for dozens, the map wins.
| .htaccess | web.config |
|---|---|
RewriteRule ... [L] |
<rule stopProcessing="true"> |
$1 / %1 |
{R:1} / {C:1} |
[OR] |
logicalGrouping="MatchAny" |
[R=301] / [R=302] |
redirectType="Permanent" / "Found" |
-f / -d |
matchType="IsFile" / "IsDirectory" |
[G] / [F] |
type="CustomResponse" statusCode="410" / "403" |
Header set |
<customHeaders><add> |
Options -Indexes |
<directoryBrowse enabled="false"> |
ErrorDocument |
<httpErrors> |
AddType |
<mimeMap> |
| Redirect page | Redirect page, httpRedirect or a rewrite map |
Please chip in if helpful