Skip to content

How-to guideFor module developers, site buildersDrupal 11

Security checklist

This is the production hardening checklist for a Drupal 11 site: the settings and habits that close the holes attackers actually use. Each item is grounded in a specific core setting, file, or process you can check today. Work through it before a site takes public traffic, and revisit it on every deploy.

Reach a state where the running site rejects spoofed requests, refuses to execute uploaded code, runs over HTTPS, grants no permission it does not need, and stays patched against published advisories. This page is the operational checklist. The mechanics of who can do what inside the application are on the access concept page; here the concern is the perimeter and the operating environment.

  • Shell and file-system access to the production environment.
  • The ability to edit settings.php on that environment.
  • An administrator account on the site.
  • Composer-managed code, per Anatomy of a Drupal project, so updates are a Composer operation.

Each step is a checklist item: the risk, the core mechanism, and how to confirm it.

Configure which Host header values the site will answer to, in settings.php.

settings.php
$settings['trusted_host_patterns'] = [
'^www\.example\.com$',
'^example\.com$',
];

Without this, an attacker can send a request with a forged Host header, and Drupal will use that host when building absolute URLs: password-reset links, cached page URLs, and redirects then point at the attacker’s domain. The patterns are anchored regular expressions, without delimiters, matched against the incoming host. Core treats a missing trusted_host_patterns as a security error: \Drupal\Core\Site\Settings exposes the setting, and the status report flags its absence as a red error line. List every hostname the site legitimately serves, including bare and www variants and every domain on a multisite. Verify at Administer → Reports → Status report/admin/reports/status: the Trusted Host Settings line reports the configured patterns instead of a warning.

Block PHP execution in the files directory

Section titled “Block PHP execution in the files directory”

The public files directory holds user uploads. If the web server will execute a .php file uploaded there, a single image-upload bug becomes remote code execution. Core writes a protective .htaccess into the files directory that turns off script handling and disables the PHP engine.

# sites/default/files/.htaccess (written by core)
Options -Indexes -ExecCGI -Includes -MultiViews
SetHandler Drupal_Security_Do_Not_Remove_See_SA_2006_006
<IfModule mod_php.c>
php_flag engine off
</IfModule>

\Drupal\Core\File\HtaccessWriter writes and maintains this file on Apache. On nginx, .htaccess does nothing: you must replicate the rule in the server configuration, denying PHP execution under the files path so uploaded scripts are served as plain text or not at all. Confirm by requesting an uploaded test file with a .php extension and checking it is not executed.

The web server must not be able to write to code, and settings.php must not be writable once the site is installed.

  • settings.php and the site directory (sites/default) are read-only to the web server after installation. Core hardens these at the end of install and warns on the status report if they stay writable, unless $settings['skip_permissions_hardening'] is set (which belongs only in local development).
  • Code (core/, modules/, themes/, vendor/) is owned by the deploy user and not writable by the web server, so a compromise cannot rewrite executables.
  • The files directories (sites/default/files, the private files directory) are the only writable trees, and they carry the script-blocking rules above.

Confirm settings.php is not writable:

Terminal window
test -w web/sites/default/settings.php && echo WRITABLE || echo "read-only (good)"

Store the private files directory outside the web root

Section titled “Store the private files directory outside the web root”

If a directory is reachable by URL, its access rules are the only thing standing between an attacker and its contents. Put the private files directory outside the document root entirely, then point Drupal at it.

settings.php
$settings['file_private_path'] = '/var/www/private';

A path outside the web root cannot be requested directly at all; access runs through Drupal’s file-download access checks instead of relying solely on a .htaccess deny rule. Keep the configuration sync directory outside the web root for the same reason. The default scaffold does not: with the line commented out, DDEV and the installer fall back to a directory under sites/default/files. Set it explicitly in settings.php:

settings.php
$settings['config_sync_directory'] = '../config/sync';

Confirm with vendor/bin/drush status --field=config, which prints ../config/sync.

Terminate TLS at the proxy or web server and redirect all HTTP to HTTPS, so credentials and session cookies never cross the network in clear text. Drupal then secures the session cookie on its own: \Drupal\Core\Session\SessionConfiguration sets the cookie_secure flag from $request->isSecure() and issues a distinct SSESS cookie over HTTPS, so a secure request never reuses an insecure cookie. The condition for all of that is that Drupal sees the request as secure.

When the site sits behind a TLS-terminating proxy, set the reverse-proxy settings from Performance and caching so Drupal detects HTTPS from the forwarded headers; otherwise it sees plain HTTP behind an https:// front door, builds http:// URLs, and marks the cookie insecure. Add an HSTS (HTTP Strict Transport Security) response header at the proxy to stop protocol-downgrade attempts.

Permissions are how a logged-in account turns into damage. Keep the grants minimal.

  • Grant administer site configuration, administer permissions, administer modules, and similar only to roles that truly administer the site. Any one of them is close to total control.
  • Treat permissions the status report marks as security-sensitive as such: administer filters and the ability to use a text format that allows arbitrary HTML or PHP-like markup let a user inject script. Restrict full-HTML formats to trusted roles.
  • Keep the anonymous and authenticated roles thin. A permission on the authenticated role is a permission for every account that can register.
  • Audit grants on the production site: vendor/bin/drush role:list shows each role’s permissions, and the access concept page explains how core resolves them.

Unpatched published vulnerabilities are the most common way Drupal sites are compromised, because the advisory tells the attacker exactly what to exploit. Staying current is the single highest-value habit on this list.

The Drupal Security Team coordinates this process. Researchers report issues privately; the team works with maintainers on a fix, assigns a Security Advisory (SA) with a risk score, and publishes core advisories on a predictable schedule (a Security Release Window on Wednesdays) so site teams can plan. Advisories cover Drupal core and contributed projects that opt into security-advisory coverage; a project’s coverage status is shown on its drupal.org page, and the Update Status module surfaces available security updates on your own site.

Apply updates with Composer in your local environment, the same way you manage all code, commit the updated composer.lock, then deploy it through the deployment workflow:

Terminal window
ddev composer update "drupal/core-*" --with-all-dependencies
ddev composer update drupal/pathauto

Check what needs updating from Administer → Reports → Available updates/admin/reports/updates, where the Update Status module lists installed projects with outstanding security releases. From the command line, ddev composer audit reports installed packages with known published advisories.

It is recommended to plan the deploy around the advisory’s risk score: critical and highly critical fixes the same day they are published, lower-risk updates with your next regular deploy.

  • Administer → Reports → Status report/admin/reports/status shows no security errors: Trusted Host Settings configured, configuration and settings.php not writable, no pending security updates.
  • ddev composer audit reports no packages with known advisories, and Available updates lists no security releases.
  • A .php file placed in the files directory is not executed when requested.
  • The site redirects http:// to https://, and settings.php is not writable by the web server.
  • vendor/bin/drush role:list shows no unexpected administrative permission on the anonymous or authenticated role.

On a multisite install, trusted_host_patterns must list every domain across every site, since the setting is evaluated before the site is selected. Anchor each pattern so a subdomain wildcard (^.+\.example\.com$) does not accidentally trust a domain you do not control.

Apache reads the .htaccess rules core writes; nginx ignores them. On nginx, deny direct access to the private path and to .php files under the public files path in the server block, and route private downloads through Drupal’s /system/files path. Test both: a direct request to a private file must fail, and an authorized download through Drupal must succeed.

Was this page helpful?

Drupal is a registered trademark of Dries Buytaert.