Custom error pages in ASP.NET MVC and PHP in IIS

To totally unlock this section you need to Log-in


If you're having problems setting up custom error pages in ASP.NET MVC you're not alone. It's surprisingly difficult to do this correctly, not helped by the fact that some errors are handled by ASP.NET and others by IIS.

Ideally (and we expect such is the case with some other frameworks/servers) we would just configure our custom error pages in one place and it would just work, no matter how/where the error was raised. Something like:

<customErrors mode="On">
    <error code="404" path="404.html" />
    <error code="500" path="500.html" />

Custom 404 error pages

When a resource does not exist (either static or dynamic) we should return a 404 HTTP status code. Ideally we should return something a little friendlier to our site visitors than the error pages built in to ASP.NET/IIS, perhaps offering some advice on why the resource may not exist or providing an option to search the site.

For the purposes of this blog post, our custom 404 page is very simple, but you can see some really nice examples here.

<!DOCTYPE html>
<html lang="en">
    <meta charset="utf-8"/>
    <title>404 Page Not Found</title>
    <h1>404 Page Not Found</h1>

Now we create a new ASP.NET MVC 5 application using the standard template in Visual Studio. If we run the site and try to navigate to a resource that does not exist e.g. /foo/bar, we'll get the standard ASP.NET 404 page with the following information:

Server Error in '/' Application.
The resource cannot be found.
Description: HTTP 404. The resource you are looking for (or one of its dependencies) could have been removed, had its name changed, or is temporarily unavailable.  Please review the following URL and make sure that it is spelled correctly. 
Requested URL: /foo/bar
Version Information: Microsoft .NET Framework Version:4.0.30319; ASP.NET Version:4.0.30319.33440

Not exactly friendly, is it? In this case the error was raised by ASP.NET MVC because it could not find a matching controller and/or action that matched the specified URL.

In order to set up a custom 404 error page add the following to web.config inside <system.web></system.web>:

<customerrors mode="On">
  <error statusCode="404" redirect="~/404.html">

We've set mode="On" so we can view the custom errors pages locally. Generally you would only want to display these in production so would set mode="RemoteOnly". Now if we navigate to /foo/bar once more we see our custom error page.

However, the URL is not /foo/bar as we expect. Instead ASP.NET issued a redirect to /404.html?aspxerrorpath=/foo/bar. Also if we check the HTTP status code of the response, it's 200 (OK).

This is very wrong indeed. Not only is is misleading as we're returning a 200 response when a resource does not exist, but it's also bad for SEO. Quite simply, if a resource does not exist at the specified URL you should return a 404 or redirect to a new location if the resource has moved.

To fix this we can change ASP.NET's default behaviour of redirecting to the custom error page to rewrite the response:

<customErrors mode="On" redirectMode="ResponseRewrite">
  <error statusCode="404" redirect="~/404.html"/>

Unfortunately this doesn't help us much. Although the original URL is now preserved, ASP.NET still returns a 200 response and furthermore displays our custom error page as plain text.

To fix the incorrect content type we have to return an ASP.NET page. So if you thought that you'd never have to deal with *.aspx pages again, I'm sorry to dissapoint you.

After renaming the error page to 404.aspx and updating web.config accordingly, the URL is preserved and we get the correct content type (text/html) in the response.

However, we still get a HTTP 200 response. This bug (and it is one) was logged with Microsoft who subsequently closed it and offered the following solution - to set the status code within your error page. We therefore need to add the following to the top of 404.aspx:

<% Response.StatusCode = 404 %>

We now get the correct status code, URL preserved and our custom error page. All done, right? Wrong.

If we navigate to a static resource (e.g. foo.html) or a URL that doesn't match our routing configuration (e.g. /foo/bar/foo/bar) we get the standard IIS 404 error page.

In the above scenarios ASP.NET is bypassed and IIS handles the request. Also if you happen to be returning HttpNotFound() from your controller actions you'll get the same result - this is because MVC simply sets the status code rather than throwing an exception, leaving IIS to do its thing.

In these cases we need to set up custom error pages in IIS (note that this only works in IIS 7+). In web.config add the following inside <system.webServer></system.webServer>:

<httpErrors errorMode="Custom">
<remove statusCode="404"/>
<error statusCode="404" path="/404.html" responseMode="ExecuteURL"/>

Similar to ASP.NET custom errors we've set errorMode="Custom" so we can test the error page locally. Normally you'd want this set to errorMode="DetailedLocalOnly".

Also note that we are using a html page again, not aspx. Ideally you should always use simple static files for your error pages. This way if there's something wrong with ASP.NET you should still be able to display your custom error pages.

If we navigate to a static file that does not exist we now get our custom error page instead of the default IIS one. However if we look at the response headers we get a 200 status code, not 404; just like the problem we had with ASP.NET's custom errors.

Fortunately IIS actually provides a built in solution to resolve this rather than having to rely on hacks. If you set responseMode="File" IIS will return your custom errors page without altering the original response headers:

<error statusCode="404" path="404.html" responseMode="File"/>

Does the trick.

Custom 500 error pages

Most of the issues addressed above relate to other error pages so if you use the same techniques you should be able to set up a custom "500 Server Error" page. There are however a few caveats.

The standard ASP.NET MVC template sets up the built in HandleErrorAttribute as a global filter. This captures any error thrown in the ASP.NET MVC pipeline and returns a custom "Error" view providing you have custom errors enabled in web.config. It will look for this view at ~/views/{controllerName}/error.cshtml or ~/views/shared/error.cshtml.

If you're using this filter you'll need to either update the existing view with your custom error page HTML or create the view if it doesn't already exist (best to do so in the views/shared directory).

We don't really see the value in this filter. Any exceptions thrown outside of the MVC pipeline will fall back to the standard ASP.NET error pages configuration. Since you're going to have to set those up anyway there is no real need to have the filter.

To do so add the following to the ASP.NET custom error pages configuration:

<customerrors mode="On" redirectMode="ResponseRewrite">
  <error statusCode="404" redirect="~/404.aspx">
  <error statusCode="500" redirect="~/500.aspx">

Like before we created an ASPX page that sets the response code:

<% Response.StatusCode = 500 %>

<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <title>500 Server Error </head> <body> <h1>500 Server Error</h1> </body> </html>

Unfortunately this won't capture every exception you're likely to encounter in your application. A fairly common error is produced by ASP.NET's request validation, for example requesting a URL with a dangerous path such as /foo/bar. This will actually produce a 400 (Bad Request) response so you can either add a specific error page for this or set up a default like so:

<customErrors mode="Off" redirectMode="ResponseRewrite" defaultRedirect="~/500.aspx">
 <error statusCode="404" redirect="~/404.aspx"/>
 <error statusCode="500" redirect="~/500.aspx"/>

Finally to capture any non-ASP.NET exceptions we can set up an IIS custom error page for server errors:

   <error statusCode="500" path="500.html" responseMode="File"/>

In Summary

Create the following pages in the root of your application:

  • 404.html - for IIS
  • 404.aspx - for ASP.NET
  • 500.html - for IIS
  • 500.aspx - for ASP.NET

Ensure you set the appropriate response status code within the ASPX pages.

Ditch the MVC HandleErrorAttribute global filter and configure ASP.NET's custom errors as below:

<customErrors mode="RemoteOnly" redirectMode="ResponseRewrite" defaultRedirect="~/500.aspx">
  <error statusCode="404" redirect="~/404.aspx"/>
  <error statusCode="500" redirect="~/500.aspx"/>

Configure IIS's custom errors as below:

<httpErrors errorMode="DetailedLocalOnly">
 <remove statusCode="404"/>
 <error statusCode="404" path="404.html" responseMode="File"/>
 <remove statusCode="500"/>
 <error statusCode="500" path="500.html" responseMode="File"/>