Index: django/conf/urls/defaults.py
===================================================================
--- django/conf/urls/defaults.py	(revision 6783)
+++ django/conf/urls/defaults.py	(working copy)
@@ -1,8 +1,9 @@
 from django.core.urlresolvers import RegexURLPattern, RegexURLResolver
 from django.core.exceptions import ImproperlyConfigured
 
-__all__ = ['handler404', 'handler500', 'include', 'patterns', 'url']
+__all__ = ['handler403', 'handler404', 'handler500', 'include', 'patterns', 'url']
 
+handler403 = 'django.views.defaults.permission_denied'
 handler404 = 'django.views.defaults.page_not_found'
 handler500 = 'django.views.defaults.server_error'
 
Index: django/core/urlresolvers.py
===================================================================
--- django/core/urlresolvers.py	(revision 6783)
+++ django/core/urlresolvers.py	(working copy)
@@ -268,6 +268,9 @@
         except (ImportError, AttributeError), e:
             raise ViewDoesNotExist, "Tried %s. Error was: %s" % (callback, str(e))
 
+    def resolve403(self):
+        return self._resolve_special('403')
+
     def resolve404(self):
         return self._resolve_special('404')
 
Index: django/core/handlers/base.py
===================================================================
--- django/core/handlers/base.py	(revision 6783)
+++ django/core/handlers/base.py	(working copy)
@@ -105,8 +105,10 @@
             else:
                 callback, param_dict = resolver.resolve404()
                 return callback(request, **param_dict)
-        except exceptions.PermissionDenied:
-            return http.HttpResponseForbidden('<h1>Permission denied</h1>')
+        except exceptions.PermissionDenied, e:
+            callback, param_dict = resolver.resolve403()
+            param_dict['reason'] = e.message
+            return callback(request, **param_dict)
         except SystemExit:
             pass # See http://code.djangoproject.com/ticket/1023
         except: # Handle everything else, including SuspiciousOperation, etc.
Index: django/core/exceptions.py
===================================================================
--- django/core/exceptions.py	(revision 6783)
+++ django/core/exceptions.py	(working copy)
@@ -10,7 +10,9 @@
 
 class PermissionDenied(Exception):
     "The user did not have permission to do that"
-    pass
+    def __init__(self, message=None):
+        Exception.__init__(self)
+        self.message = message
 
 class ViewDoesNotExist(Exception):
     "The requested view does not exist"
Index: django/views/defaults.py
===================================================================
--- django/views/defaults.py	(revision 6783)
+++ django/views/defaults.py	(working copy)
@@ -66,6 +66,22 @@
     else:
         return http.HttpResponseRedirect(absurl)
 
+def permission_denied(request, template_name='403.html', reason=''):
+    """
+    Default 403 handler.
+
+    Templates: `403.html`
+    Context:
+        request_path
+            The path of the requested URL (e.g., '/app/pages/form_page')    
+    """
+    context = {
+        'request_path': request.path,
+        'reason': reason,
+    }
+    t = loader.get_template(template_name) # You need to create a 403.html template.
+    return http.HttpResponseForbidden(t.render(RequestContext(request, context)))
+
 def page_not_found(request, template_name='404.html'):
     """
     Default 404 handler, which looks for the requested URL in the redirects
Index: django/contrib/csrf/middleware.py
===================================================================
--- django/contrib/csrf/middleware.py	(revision 6783)
+++ django/contrib/csrf/middleware.py	(working copy)
@@ -6,13 +6,14 @@
 
 """
 from django.conf import settings
+from django.core.exceptions import PermissionDenied
 from django.http import HttpResponseForbidden
 from django.utils.safestring import mark_safe
 import md5
 import re
 import itertools
 
-_ERROR_MSG = mark_safe('<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en"><body><h1>403 Forbidden</h1><p>Cross Site Request Forgery detected. Request aborted.</p></body></html>')
+_ERROR_MSG = "Cross Site Request Forgery detected."
 
 _POST_FORM_RE = \
     re.compile(r'(<form\W[^>]*\bmethod=(\'|"|)POST(\'|"|)\b[^>]*>)', re.IGNORECASE)
@@ -53,10 +54,10 @@
             try:
                 request_csrf_token = request.POST['csrfmiddlewaretoken']
             except KeyError:
-                return HttpResponseForbidden(_ERROR_MSG)
+                raise PermissionDenied(_ERROR_MSG)
             
             if request_csrf_token != csrf_token:
-                return HttpResponseForbidden(_ERROR_MSG)
+                raise PermissionDenied(_ERROR_MSG)
                 
         return None
 
Index: django/contrib/csrf/tests.py
===================================================================
--- django/contrib/csrf/tests.py	(revision 0)
+++ django/contrib/csrf/tests.py	(revision 0)
@@ -0,0 +1,33 @@
+r"""
+>>> from django.conf import settings
+>>> from django import http
+>>> from django.contrib.csrf.middleware import CsrfMiddleware, _make_token
+>>> csrf = CsrfMiddleware()
+>>> request = http.HttpRequest()
+>>> request.method = 'POST'
+
+If no session exists, returns None (check not required)
+>>> csrf.process_request(request)
+
+If token doesn't exist, raise PermissionDenied
+>>> request.COOKIES[settings.SESSION_COOKIE_NAME] = 'my_session_id'
+>>> csrf.process_request(request)
+Traceback (most recent call last):
+    ...
+PermissionDenied
+
+If token exists and does not match session id, raise PermissionDenied
+>>> request.POST['csrfmiddlewaretoken'] = 'hackers_session_id'
+>>> csrf.process_request(request)
+Traceback (most recent call last):
+    ...
+PermissionDenied
+
+>>> request.POST['csrfmiddlewaretoken'] = _make_token('my_session_id')
+>>> csrf.process_request(request)
+
+"""
+
+if __name__ == '__main__':
+    import doctest
+    doctest.testmod()
Index: django/contrib/csrf/models.py
===================================================================
Index: tests/regressiontests/views/tests/defaults.py
===================================================================
--- tests/regressiontests/views/tests/defaults.py	(revision 6783)
+++ tests/regressiontests/views/tests/defaults.py	(working copy)
@@ -25,6 +25,16 @@
             response = self.client.get(short_url)
             self.assertEquals(response.status_code, 404)
 
+    def test_permission_denied(self):
+        "A 403 status is returned by the permission_denied view"
+        response = self.client.get('/views/permission_denied_url/')
+        self.assertEquals(response.status_code, 403)
+
+    def test_permission_denied_with_reason(self):
+        "A 403 status can propagate the reason for denying to the permission_denied view"
+        response = self.client.get('/views/permission_denied_with_reason/')
+        self.assertContains(response, "Not allowed", status_code=403)
+
     def test_page_not_found(self):
         "A 404 status is returned by the page_not_found view"
         non_existing_urls = ['/views/non_existing_url/', # this is in urls.py
@@ -34,6 +44,6 @@
             self.assertEquals(response.status_code, 404)
 
     def test_server_error(self):
-        "The server_error view raises a 500 status"
+        "A 500 status is returned by the server_error view"
         response = self.client.get('/views/server_error/')
         self.assertEquals(response.status_code, 500)
Index: tests/regressiontests/views/views.py
===================================================================
--- tests/regressiontests/views/views.py	(revision 6783)
+++ tests/regressiontests/views/views.py	(working copy)
@@ -1,3 +1,4 @@
+from django.core.exceptions import PermissionDenied
 from django.http import HttpResponse
 from django.template import RequestContext
 
@@ -5,3 +6,6 @@
     """Dummy index page"""
     return HttpResponse('<html><body>Dummy page</body></html>')
 
+def generate_permission_denied_with_reason(request):
+    """Dummy page to test Permission Denied exception with reason"""
+    raise PermissionDenied("Not allowed")
Index: tests/regressiontests/views/urls.py
===================================================================
--- tests/regressiontests/views/urls.py	(revision 6783)
+++ tests/regressiontests/views/urls.py	(working copy)
@@ -25,6 +25,8 @@
     
     # Default views
     (r'^shortcut/(\d+)/(.*)/$', 'django.views.defaults.shortcut'),
+    (r'^permission_denied_url/', 'django.views.defaults.permission_denied'),
+    (r'^permission_denied_with_reason/', views.generate_permission_denied_with_reason),
     (r'^non_existing_url/', 'django.views.defaults.page_not_found'),
     (r'^server_error/', 'django.views.defaults.server_error'),
     
Index: tests/templates/403.html
===================================================================
--- tests/templates/403.html	(revision 0)
+++ tests/templates/403.html	(revision 0)
@@ -0,0 +1,2 @@
+Django Internal Tests: 403 Error
+{{ reason }}
