How to Customise Drupal's 403 and 404 Error Pages with Twig
Drupal's default forbidden and not-found screens are functional, but they often expose the platform rather than the organisation. A custom Twig error page gives visitors a recognisable route back to useful content while keeping the correct HTTP response for search engines, monitoring and accessibility.
The safest approach is a small custom module with two controller routes and two Twig templates. It works on Drupal 10 and 11, survives theme upgrades, and gives site editors copy they can change without touching PHP. Core files should never be edited merely to change an error message.
For teams in Sydney, Melbourne and Brisbane, these pages often support government, education or membership services delivered by Australian agencies and Drupal consultancies. The Australian Government Digital Service Standard and WCAG 2.2 AA make clear focus states, heading structure and recovery links essential. Australian English spelling and local date formats belong in the copy.
Keep the original status codes intact: a forbidden resource must remain a 403, and an unknown URL must remain a 404. The examples below cover both pages, theme integration, testing and the operational details that often cause a polished design to fail in production.
Separate access denied from page not found
A 403 means Drupal found the route but the current visitor lacks permission. A 404 means the requested resource is unavailable to that visitor. Choosing the right page matters because a “forbidden” message on a deleted article is misleading, while hiding a genuinely restricted node behind “not found” can make access problems harder to diagnose.
| Scenario | HTTP status | Drupal meaning | Helpful response |
|---|---|---|---|
| The route exists, but the user lacks a permission | 403 Forbidden | Access denied | Offer sign-in, explain access, provide a contact route |
| The URL is wrong, or the content does not exist for this visitor | 404 Not Found | No matching published resource | Offer homepage, search and a way to report a broken link |
A 404 can be soft: Drupal may deliberately hide an unpublished node from anonymous visitors by treating it as missing. That protects content titles and editorial workflows. A hard 404 is reserved for a URL that genuinely cannot be resolved.
The replacement routes must be reachable by the people who receive the error. Give them a public access requirement, then let the controller return the proper 403 or 404 status. A direct request to the custom URL should therefore show that same status rather than an unrelated 200 page.
Build the custom error module
Create site_errors under web/modules/custom and add an site_errors.info.yml file. The module owns the behaviour; templates and styling remain separate.
name: 'Site errors'
type: module
description: 'Custom access-denied and not-found pages.'
package: Custom
core_version_requirement: ^10 || ^11
Define a route for each error in site_errors.routing.yml. Requirements of _access: 'TRUE' allow the error controller to handle visitors who cannot otherwise reach the protected route.
site_errors.access_denied:
path: '/errors/403'
defaults:
_controller: '\Drupal\site_errors\Controller\SiteErrorController::accessDenied'
requirements:
_access: 'TRUE'
site_errors.not_found:
path: '/errors/404'
defaults:
_controller: '\Drupal\site_errors\Controller\SiteErrorController::notFound'
requirements:
_access: 'TRUE'
Add src/Controller/SiteErrorController.php and site_errors.module. Registering the templates as theme hooks lets Drupal's renderer discover them, while the controller keeps markup separate from the response logic.
<?php
/**
* Implements hook_theme().
*/
function site_errors_theme(): array {
return [
'site_errors_error_403' => [
'variables' => ['status_code' => 403],
],
'site_errors_error_404' => [
'variables' => ['status_code' => 404],
],
];
}
<?php
namespace Drupal\site_errors\Controller;
use Drupal\Core\Controller\ControllerBase;
use Symfony\Component\HttpFoundation\Response;
final class SiteErrorController extends ControllerBase {
public function accessDenied(): Response {
return $this->errorResponse(403);
}
public function notFound(): Response {
return $this->errorResponse(404);
}
private function errorResponse(int $status): Response {
$template = $status === 403
? 'site_errors_error_403'
: 'site_errors_error_404';
$content = (string) $this->render($template, [
'status_code' => $status,
]);
$response = new Response($content);
$response->setStatusCode($status);
$response->headers->set('Cache-Control', 'private, no-store');
return $response;
}
}
Install the module, visit /admin/config/site-information, and set the default 403 and 404 pages to /errors/403 and /errors/404. These values live in system.site, so a deployment pipeline can export and import them with the rest of the site configuration.
Render distinct pages with Twig
Create templates/site-errors-error-403.html.twig for the forbidden page. Extending the active theme's html.html.twig keeps the site header, footer and assets while the content block supplies useful recovery copy.
{% extends "html.html.twig" %}
{% block content %}
<section class="site-error site-error--403" aria-labelledby="error-title">
<p class="site-error__code">{{ status_code }}</p>
<h1 id="error-title">{{ 'Access denied'|t }}</h1>
<p>{{ 'You do not have permission to view this page.'|t }}</p>
<p><a href="{{ path('user.login') }}">{{ 'Sign in'|t }}</a></p>
</section>
{% endblock %}
A separate templates/site-errors-error-404.html.twig should explain that the page may have moved without implying that a restricted resource exists. The homepage link remains useful even when the search module is not installed.
{% extends "html.html.twig" %}
{% block content %}
<section class="site-error site-error--404" aria-labelledby="error-title">
<p class="site-error__code">{{ status_code }}</p>
<h1 id="error-title">{{ 'Page not found'|t }}</h1>
<p>{{ 'The page may have moved or the address may be incorrect.'|t }}</p>
<p><a href="{{ path('<front>') }}">{{ 'Go to the homepage'|t }}</a></p>
</section>
{% endblock %}
Because both templates are registered theme hooks, an active theme can override the module defaults without changing the controller. Preprocess functions can also pass the site name, support contact or a search URL, while Twig's autoescaping continues to protect those values. The separation between detecting a failure and presenting a recovery path is also a concern in distributed systems: the failure detection strategies article discusses supervision for resilient actors, although Drupal handles HTTP exceptions rather than actors.
Match the error pages to the theme
Drupal's Twig layer can receive site-wide context through preprocessing, while a module library can carry the small amount of CSS needed for the layout. Keep the styling in the active theme when designers expect to change it; keep a fallback in the module when the same error module supports several sites.
.site-error {
max-width: 42rem;
margin-inline: auto;
padding: 3rem 1.25rem;
}
.site-error__code {
font-size: 0.875rem;
font-weight: 700;
letter-spacing: 0.08em;
}
A useful error page has one clear heading, descriptive link text and a predictable focus order. If the theme already supplies a <main> landmark, the section shown above is safer than adding another main element. Translation strings should remain translatable, and links to sign-in, search or support should appear only when those routes exist.
Do not reveal the requested URL, unpublished node title, permission name or stack trace on a public page. A generic recovery message is both safer and easier to maintain. Any optional support reference should come from configuration, not a hard-coded email address in Twig.
Test both status codes before deployment
Enable the module and rebuild caches on the local environment. Sites already using Drush can run the equivalent of drush en site_errors -y followed by drush cr; DDEV, Lando and Composer-based local stacks should run the commands inside their project container.
drush en site_errors -y
drush cr
curl -I http://localhost:8888/no-such-page
The final request should return 404 Not Found, not 200 OK. Test the 403 route with a real protected page while signed out, then repeat it as a user with fewer permissions. Drupal can return 403, a login redirect or a 404 depending on the route and visibility rules, so each access path needs a deliberate check.
Use browser developer tools as well as command-line requests. Confirm that the page title, assets and navigation render, the keyboard can reach every recovery link, and the response code remains correct after logging in. Clear caches after template or route changes, and repeat the checks against the staging hostname before promoting configuration to production.
Operate the pages as part of site maintenance
The sample controller uses private, no-store because a 403 can differ by account and may contain personal links. A generic 404 can often be cached publicly, but that decision belongs in the response headers, Drupal cache configuration and any reverse proxy. Purge cached 404 responses after a migration or campaign link change so visitors do not keep seeing an obsolete page.
Review access and broken-link logs separately. A rise in 403 responses can indicate a broken permission migration, while a rise in 404 responses can come from misspelled links, renamed campaigns or automated scanners. Useful alerts distinguish known route failures from broad crawls. For readers who want the publication's wider Drupal perspective, the Drupalwoo background describes its emphasis on practical implementation and administration.
Treat the error templates as maintained content: include them in accessibility reviews, translation checks and Drupal upgrade testing. Keep the controller, route and system.site settings under version control, and document who can change the recovery links. Once deployed, a missing URL produces a branded 404 and a restricted resource keeps its 403 status, both rendered through maintainable Twig templates rather than modified core files.