Drupal Security Hardening

This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production.

This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages.

A Drupal site is attacked in the same ways as any PHP application: through its accounts, through the text that visitors and editors submit, through the code it runs, through the files it writes and through the server around it. Drupal core has a good security record and a dedicated security team, and most successful attacks on Drupal sites come from missed updates, over-generous permissions and misconfigured servers rather than from unknown flaws. This page is the production hardening reference of the sub-section: every control is explained, shown with a working configuration for the Meridian Museum site (the module meridian_collection, the theme meridian) and linked to the official documentation it derives from. It was checked on 2026-10-30 against Drupal 11.4.x, Drush 13 and 14 and PHP 8.3 or later. Drupal 10 reaches its end of life on 2026-12-09 and Drupal 12.0.0 is planned for the week of 2026-12-07, so both are discussed as they affect hardening, and a new site should not be started on Drupal 10.

The page is organized from the outside in: the threat model and the layers, the web server and HTTPS, the file system, the settings that live in settings.php and services.yml, accounts and authentication, content and text formats, response headers, and finally the operating routine of advisories, auditing and incident response. The companion pages cover the files in detail (settings.php, services.yml and environments), the permission system (Users, roles and permissions), Composer-based updates (Updates, Composer and security advisories), private files in code (File system and private files) and backups (Backups and disaster recovery).

Six layers of defense for a Drupal site, from network and TLS to operations

Threat model and what Drupal already does for you

Hardening is only useful against specific risks, so it helps to name them for the Meridian Museum site:

  • Account takeover. An attacker guesses or phishes the password of the Content Editor, Curator or Administrator, or attacks the account created during installation (user 1), which holds every permission.

  • Injected content. A visitor or low-privilege editor stores script in a node, comment, webform submission or field, which then runs in an administrator’s browser (cross-site scripting) and acts with the administrator’s session.

  • Code execution. An outdated module, an uploaded .php file or a writable code directory lets the attacker run PHP.

  • Information disclosure. Backups, composer.json, exported configuration, error messages or private files are reachable by URL.

  • Host header and cache attacks. A forged Host header makes Drupal build password-reset links that point to the attacker’s domain.

  • Downtime and data loss. Brute force, denial of service or a destructive intruder; the answer is mostly backups, which are covered elsewhere.

Drupal core already defends against several of these by default, and a hardening review starts by knowing which defaults exist and not undoing them:

  • The text format system escapes or filters output. Twig autoescaping is on, so {{ variable }} is escaped, and |raw on user-controlled data is an XSS risk (see Twig templates and debugging).

  • Form API tokens protect forms against cross-site request forgery. Since Drupal 10.3, /user/logout is also protected by a token, so a hostile image tag can no longer log a user out.

  • New installations set account registration to "Administrators only" (since Drupal 11.1), instead of allowing visitors to register themselves.

  • Passwords are stored with a one-way salted hash, never in clear text, and session identifiers are regenerated on login.

  • The Drupal-shipped .htaccess files deny access to configuration, source and backup files and disable script execution in the public files directory (details below).

  • Core has brute-force protection on login (the flood control settings in user.flood), and the Flood Control module adds a user interface for it.

  • Entity queries made without an explicit accessCheck() call throw an exception, so a developer cannot silently bypass access control (see Database API and entity queries).

Official guidance is collected under Security in Drupal, whose sub-pages are cited throughout this page.

Keep the platform supported

No setting compensates for software that no longer receives fixes. Drupal follows semantic versioning: a feature minor release ships every six months, in June and December, with bugfix releases usually on the first Wednesday of a month and security releases usually on the third. Security advisories cover only the two most recent minors of the current major. Release notes of Drupal 11.4.11 (published on 2026-10-21) state that Drupal 11.4.x is covered for security until June 2027.

  • Drupal 10 ends on 2026-12-09. Drupal 10.6 is the last minor, and from 10.6 the upgrade target is Drupal 11.3 or later.

  • Drupal 12.0.0 is planned for the week of 2026-12-07. It does not accept an upgrade from 11.2 or earlier, so anything below 11.3 must first move to Drupal 11.3 or later (11.4 or 11.5 are recommended starting points). See Preparing for Drupal 12 and Versions, release cycle and support.

  • Contributed projects are covered by the same advisory process only when they are marked as stable and have the "covered by the security advisory policy" badge on their project page. A module with a development or alpha release only is not covered, and a module that is no longer maintained is a liability even when it still works.

  • PHP, the web server and the database need their own updates. Hosting guidance is in Requirements and hosting. Drupal 11 removed support for IIS (in 11.0) and deprecated Windows as a production platform (in 11.3). Drupal 12 requires PHP 8.5.

Older books such as Drupal 10 Development Cookbook (2023) and Drupal 10 Masterclass (2023) were written for Drupal 10.0 and 10.1 on PHP 8.1 and treat Drupal 7 as still supported. PHP 8.1 and Drupal 7 no longer receive security fixes, and neither book covers the hardening changes described below (the super user parameter, argon2id, zend.assertions, the removal of in-browser module installation). For a security checklist the current official documentation is the reference.

The web server

Document root and the files that must not be served

A Composer project (see Project layout and local development) keeps vendor/, composer.json, composer.lock, the configuration sync directory and private files outside the web/ document root. That layout is itself the strongest protection of this section: a file that is not under the document root cannot be requested at all, whatever the web server configuration says. The server must point at web/, never at the project folder. A project created from the abandoned drupal/legacy-project template, with the document root in the repository root, exposes vendor/ and composer.json, and should be moved to the recommended layout.

The official page Moving all PHP files out of the docroot describes going further and keeping only index.php and static assets in the served folder. Most projects do not need this, because the scaffold already makes index.php the only PHP entry point that matters. The entry points that deserve attention are index.php, /update.php and /core/install.php, discussed below.

What the Apache .htaccess files do

On Apache, the .htaccess file that Drupal’s scaffold writes into web/ carries the main protections. The scaffold copies it from core/assets/scaffold/files/htaccess, and it is regenerated by composer install and composer update, so edits belong in the Composer scaffold configuration and not in the generated file. Its main duties are:

  • deny direct requests for source and configuration files by extension (.module, .install, .theme, .twig, .yml, .sql, .sh and similar, including editor leftovers such as ~, .bak and .swp);

  • deny composer.json, composer.lock, package.json, yarn.lock, web.config and version-control metadata at the document root;

  • disable directory listing (Options -Indexes) and set secure defaults for content types and character sets;

  • turn off PHP assertions and a few legacy PHP flags for mod_php (see the section on zend.assertions below);

  • send requests for missing files to index.php (clean URLs) and add the Vary: Accept-Encoding handling for compressed assets.

Two things are easy to forget. First, .htaccess files are honored only when the virtual host sets AllowOverride All (or at least AllowOverride FileInfo Options AuthConfig Limit for the document root). If it is None, all of these protections silently disappear and no error is shown. Second, the automatically generated .htaccess file inside sites/default/files/ is rewritten by Drupal whenever the file system is checked and protects the public files folder; it must stay in place and, as described in the file permissions section, must not be owned by the web server user.

Preventing execution of uploaded PHP

Content editors upload files into sites/default/files/. The directory is writable by the web server by design, so the web server must never execute anything from it. The .htaccess file that Drupal creates in the public files directory contains these rules:

# sites/default/files/.htaccess (created by Drupal, keep it read-only)
# Turn off all options we don't need.
Options -Indexes -ExecCGI -Includes -MultiViews

# Set the catch-all handler to prevent scripts from being executed.
SetHandler Drupal_Security_Do_Not_Remove_See_SA_2006_006
<Files *>
  # Override the handler again if we're run later in the evaluation list.
  SetHandler Drupal_Security_Do_Not_Remove_See_SA_2013_003
</Files>

# If we know how to do it safely, disable the PHP engine entirely.
<IfModule mod_php.c>
  php_flag engine off
</IfModule>

With PHP-FPM, avoid a blanket ProxyPassMatch for .php files, because it takes precedence over .htaccess handlers and defeats the rules above. Use a FilesMatch handler instead, which .htaccess can still override:

<FilesMatch \.php$>
   SetHandler "proxy:fcgi://127.0.0.1:9000"
</FilesMatch>

The official page documents Apache only, so nginx and other servers need an explicit equivalent (shown next).

The nginx equivalents

nginx ignores .htaccess completely. Every protection above therefore has to be reproduced in the server configuration, and it is the single most common reason that nginx-hosted Drupal sites expose files that Apache sites do not. The following server block for the Meridian Museum denies the same categories of files, blocks PHP in the files directories, and passes only the front controller and the two administrative scripts to PHP-FPM:

server {
    listen 443 ssl;
    http2 on;
    server_name www.meridian-museum.example;
    root /var/www/meridian/web;
    index index.php;

    # TLS certificate paths are managed by the host (for example certbot).
    ssl_certificate     /etc/ssl/meridian/fullchain.pem;
    ssl_certificate_key /etc/ssl/meridian/privkey.pem;

    # Never reveal the server version.
    server_tokens off;

    # Allow ACME and other well-known URLs, deny every other hidden file or directory.
    location ^~ /.well-known/ { allow all; }
    location ~ (^|/)\. { return 403; }

    # Source, configuration and backup files.
    location ~* \.(engine|inc|install|make|module|profile|po|sh|sql|theme|twig|tpl(\.php)?|xtmpl|yml)(~|\.sw[op]|\.bak|\.orig|\.save)?$ {
        return 404;
    }
    location ~ ^/(composer\.(json|lock)|package\.json|yarn\.lock|web\.config)$ { return 404; }

    # Never execute PHP from the files directories or from vendored code.
    location ~ ^/sites/[^/]+/files/.*\.php$ { deny all; }
    location ~ ^/sites/[^/]+/private/ { return 403; }
    location ~ /vendor/.*\.php$ { deny all; return 404; }

    location / {
        try_files $uri /index.php?$query_string;
    }

    # Image styles are generated on first request.
    location ~ ^/sites/[^/]+/files/styles/ {
        try_files $uri /index.php?$query_string;
    }

    # Only these PHP scripts are entry points.
    location ~ ^/(index|update)\.php$ {
        fastcgi_split_path_info ^(.+?\.php)(|/.*)$;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    }
    location = /core/install.php { return 404; }
    location ~ \.php$ { return 404; }
}

Source: Preventing execution of untrusted PHP (the nginx directives are an adaptation of the Apache rules; test them with nginx -t and the curl smoke test at the end of this section before relying on them).

Two details in this configuration deserve explanation. try_files $uri /index.php?$query_string is what makes clean URLs work and sends every non-file request to Drupal, and the final location ~ \.php$ returns 404 for any other .php path, which neutralizes the "upload a shell, then request it" attack even if a script reaches a public folder. Returning 404 for /core/install.php after the site is installed removes a script that otherwise tells visitors the site is already installed. Leave update.php reachable only if you run database updates in the browser; sites that deploy with drush deploy should remove it from that location pattern (see the section on not exposing update.php and install.php).

Behind a CDN, load balancer or proxy that terminates TLS, from Drupal 11.3 the proxy must pass the HTMX request headers (HX-Request, HX-Redirect and related ones) unchanged, because core uses htmx for parts of the administrative interface, and the proxy must be listed in reverse_proxy_addresses (next section) so that Drupal trusts its X-Forwarded-* headers.

A smoke test for the web server

A deployment is not hardened until it has been tested from outside. These commands, run from any machine, should all report the status that is shown in the comments:

BASE=https://www.meridian-museum.example

curl -s -o /dev/null -w '%{http_code}\n' "$BASE/core/install.php"            # 404 (or 403) on an installed site
curl -s -o /dev/null -w '%{http_code}\n' "$BASE/composer.json"               # 404
curl -s -o /dev/null -w '%{http_code}\n' "$BASE/vendor/autoload.php"         # 404
curl -s -o /dev/null -w '%{http_code}\n' "$BASE/sites/default/settings.php"  # 403 or 404, never 200
curl -s -o /dev/null -w '%{http_code}\n' "$BASE/sites/default/files/.htaccess"  # 403 or 404
curl -s -o /dev/null -w '%{http_code}\n' "$BASE/core/CHANGELOG.txt"          # 404 (hides the exact version)
curl -sI "$BASE/" | grep -iE 'strict-transport|x-content-type|x-frame|content-security|referrer|server|x-generator'
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: attacker.example' "$BASE/"  # 400 once trusted hosts are set

Hiding version clues is defence in depth, not a security control: it only slows down automated scanners. Drupal’s own documentation says as much, and the real protection is being up to date. Core sends an X-Generator header and a meta name="Generator" tag; both are on purpose and harmless, but a reverse proxy may remove them, and the CHANGELOG.txt files should not be served.

HTTPS and secure cookies

Redirecting to HTTPS and enabling HSTS

Everything on the Meridian site, including anonymous browsing, is served over HTTPS. The usual setup is a port 80 virtual host that does nothing except redirect, plus an HSTS header so that browsers refuse plain HTTP afterwards. The official page on Enabling HTTP Secure (HTTPS) describes the Apache virtual host redirect, the .htaccess RewriteRule for hosts where the server configuration is not editable, and a redirect in settings.php. A redirect that begins on plain HTTP is itself open to a man-in-the-middle attack on the first request, which is why the page recommends HSTS and, for public sites, submission to the browser preload list.

<VirtualHost *:80>
    ServerName www.meridian-museum.example
    Redirect permanent "/" "https://www.meridian-museum.example/"
</VirtualHost>

<VirtualHost *:443>
    ServerName www.meridian-museum.example
    DocumentRoot /var/www/meridian/web
    <Directory /var/www/meridian/web>
        AllowOverride All
        Require all granted
    </Directory>
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
</VirtualHost>

Start HSTS with a short max-age (for example one day, then one week) while testing, and add includeSubDomains and preload only after every subdomain, including the staging site, serves valid HTTPS: a long HSTS lifetime on a broken certificate locks visitors out and cannot be undone from the server. The Security Kit module (below) can send the same header from the Drupal side.

Reverse proxies and the HTTPS detection problem

When TLS ends at a load balancer, PHP sees an HTTP request on port 80. Symfony’s request object (and therefore Drupal) believes the connection is not secure, builds http:// URLs, never sets the secure flag on the session cookie and may fall into a redirect loop. The fix is to declare the proxy addresses so that Drupal trusts the forwarded headers:

// web/sites/default/settings.php (excerpt)
$settings['reverse_proxy'] = TRUE;
$settings['reverse_proxy_addresses'] = ['10.0.0.10', '10.0.0.11'];
// Trust only the headers the proxy really sets.
$settings['reverse_proxy_trusted_headers'] =
  \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_FOR |
  \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_HOST |
  \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_PORT |
  \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_PROTO;

Source: Enabling HTTP Secure (HTTPS) and the commented examples in default.settings.php.

Never set reverse_proxy to TRUE without the address list: any client could then forge X-Forwarded-For and fake its IP address, which defeats flood control, the log’s client addresses and IP-based restrictions. The address list is an allow-list of proxies, and the reverse proxy configuration reference is in settings.php, services.yml and environments.

Drupal sets the secure attribute of the session cookie automatically when the request is HTTPS, as the HTTPS page above states: the session cookie is then sent only over TLS, and the cookie name is prefixed with S so that an HTTP and an HTTPS variant of the same site cannot collide. The other attributes come from the session.storage.options parameter of services.yml:

# web/sites/default/services.yml
parameters:
  session.storage.options:
    # Lifetime of the session on the server, in seconds (about 2.3 days).
    gc_maxlifetime: 200000
    # 0 = until the browser is closed; the default keeps the cookie for about 23 days.
    cookie_lifetime: 0
    # Lax keeps one-time login links from e-mail working; Strict would break them.
    cookie_samesite: Lax

The cookie is HttpOnly so that JavaScript cannot read it. A cookie_lifetime of 0 is a good setting for a site with privileged editors, because the cookie then disappears when the browser closes, while the server-side gc_maxlifetime limits how long an abandoned session can be reused. Keep SameSite=Lax: it prevents the cookie from being sent on cross-site subrequests (a layer against cross-site request forgery) without blocking a visitor who follows a link in an e-mail. Strict would drop the session on that first navigation. The Secure Login module mentioned in older documentation is for mixed HTTP and HTTPS sites and is not needed on an HTTPS-only site.

File permissions and ownership

The principle from the official page Securing file permissions and ownership is that the web server should not be able to edit or write the files it executes. If PHP-FPM or Apache can rewrite a .php file, any code-execution flaw becomes a permanent backdoor. The page recommends this layout on Linux:

Item Mode Notes

Directories of the Drupal root

750

Owner is an administrative user (for example deploy); group is the web server group (www-data on Debian, apache on Red Hat families).

Files of the Drupal root

640

Read-only for the web server through the group; no access for others.

sites/*/files directories

770

The only place the web server needs to write, together with the temporary and private directories.

Files inside sites/*/files

660

Written by the web server.

.htaccess in the files directories

440

Read-only, and the owner must not be the web server user, or PHP could rewrite its own safeguards.

settings.php

640 or stricter

Contains the database credentials; remove read access for "other". The page does not name an exact mode, so 640 (or 440 after installation) is a sensible choice.

The ownership split is the point: the deploy user owns the code, the web server group can only read it, and others have no access (on shared hosts, where one compromised account must not read another site, the page advises giving the web server group access only, never "world" access). A script that applies the layout to the Meridian project looks like this:

#!/usr/bin/env bash
# Run from the project root (the folder that contains composer.json and web/).
set -euo pipefail
DEPLOY_USER=deploy
WEB_GROUP=www-data
ROOT=web

sudo chown -R "$DEPLOY_USER":"$WEB_GROUP" "$ROOT"
sudo find "$ROOT" -type d -exec chmod 750 {} +
sudo find "$ROOT" -type f -exec chmod 640 {} +

# The web server writes only to the files directories.
sudo find "$ROOT"/sites -type d -name files -exec chmod 770 {} +
sudo find "$ROOT"/sites -type d -name files -exec chmod -R ug=rwX,o= {} +
sudo find "$ROOT"/sites -type f -path '*/files/*' ! -name .htaccess -exec chmod 660 {} +

# Keep the protective .htaccess files read-only and not owned by the web server user.
sudo find "$ROOT"/sites -name .htaccess -path '*/files/*' -exec chmod 440 {} +

# Make settings.php read-only after installation.
sudo chmod 440 "$ROOT"/sites/default/settings.php
sudo chmod 550 "$ROOT"/sites/default

Notes on using this layout day to day:

  • Run the commands from the right directory. The official page warns that running them elsewhere changes the wrong files. set -u and a relative ROOT make the script fail in the wrong place, but always check pwd first.

  • sites/default and settings.php. During installation Drupal needs to write settings.php and create the files folder, so the installer asks for write permission on sites/default and then tries to make settings.php read-only. Restore the strict mode afterwards, and make sites/default non-writable (chmod 550) so that an attacker cannot replace the file. To change settings during maintenance, temporarily grant write access to the deploy user, not to the web server. Drupal’s status report warns when settings.php is writable.

  • file_chmod_directory and file_chmod_file. $settings['file_chmod_directory'] (documented as 0775) and $settings['file_chmod_file'] (documented as 0664) control the modes that Drupal gives to the directories and files it creates, overriding the process umask. Set them to 0770 and 0660 if no other group needs access.

  • New code. Set the permissions before adding a module or theme, not afterwards, so that there is never a window in which the new code is writable by the web server.

  • Windows. The page gives an equivalent ACL: SYSTEM read-only except on the files folders, the administrator with modify rights, and no other users. Windows production hosting is deprecated since Drupal 11.3.

Read-only code and Composer-based updates

A read-only codebase has a cost, and it should be a conscious one. Since Drupal 10.4/11.0 modules and themes can no longer be uploaded through the administrative interface, and since 11.2 core cannot be updated from it either, so on a conventional deployment nothing in the browser needs to write code at all: updates arrive through composer update on a build machine or in CI, as described in Deployment and CI/CD. The two exceptions are Automatic Updates and Project Browser. They use core’s Package Manager, which needs a writable codebase, so a site that enables them has intentionally relaxed this rule and should compensate with other controls (see Automatic Updates and Project Browser). Automatic Updates applies patch-level core updates only, does not support multisite and does not run unattended from cron. Its updates of contributed projects are marked as not ready for production, so it is a convenience for small sites, not a replacement for a deployment pipeline.

Generated PHP files and the Twig cache

Drupal writes some PHP to disk: the compiled Twig templates, the container cache and the optional PHP storage for Twig. The official page Security of generated PHP files explains the default protection: file names include a hash built from a secret and the directory’s modification time, and Drupal refuses to include a file that is newer than its directory, so an attacker who can overwrite a file but not change the secret cannot plant valid code. The secret lives in settings.php, and a leaked secret means the site is already compromised. The page also recommends generating the files on a staging machine and using a read-only storage on production, which fits a deployment where the web server cannot write code. In practice, keep the compiled-template directory outside the web root (the default is under the temporary directory or sites/default/files/php, which is protected by the same .htaccess rules) and make sure no other tenant on the machine can read it.

Private files

The public files directory is served directly by the web server, so every file in it is reachable by anyone who knows or guesses the URL. Anything that needs access control, such as the Museum’s loan agreements, curator documents or webform attachments, goes in the private file system. Private files are streamed through PHP after Drupal checks access, and the directory must be outside the document root:

// web/sites/default/settings.php (excerpt)
// Outside web/, writable by the web server, never reachable by URL.
$settings['file_private_path'] = '../private';
$settings['file_temp_path'] = '/tmp/meridian';

// Directories and files that Drupal creates are private to the web server group.
$settings['file_chmod_directory'] = 0770;
$settings['file_chmod_file'] = 0660;

With the setting in place, a file field can use the "Private files" storage in its field settings, and the URL becomes /system/files/.... Pitfalls and good practice:

  • Setting the path does not protect existing files. Changing a field’s storage from public to private moves nothing, and files that were public stay reachable at their old URL. Change the setting before the first upload or migrate the files and redirect the old paths.

  • The path must not be inside the document root. Drupal checks it in the status report ("Private file system" row) and also writes a .htaccess file to deny access, but a misconfigured nginx ignores that file. An nginx location that returns 403 for the private directory (shown in the previous section) is a second safety net.

  • Access is decided by hook_file_download(). Core grants access to a private file only if some module returns headers for it. File fields do this through the entity access system: a user can download a file when they can view an entity that references it. A custom module can add its own rule. In the Meridian Museum, loan agreements are only for the Curator role:

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Hook;

use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Session\AccountProxyInterface;

/**
 * Access rules for private files owned by the collection module.
 */
final class FileDownloadHooks {

  public function __construct(
    private readonly AccountProxyInterface $currentUser,
  ) {}

  /**
   * Implements hook_file_download().
   *
   * Return -1 to deny, an array of headers to allow, NULL to let other modules decide.
   */
  #[Hook('file_download')]
  public function fileDownload(string $uri): array|int|null {
    if (!str_starts_with($uri, 'private://loan-agreements/')) {
      return NULL;
    }
    if ($this->currentUser->hasPermission('view loan agreements')) {
      return ['Content-Type' => 'application/pdf', 'Cache-Control' => 'private'];
    }
    return -1;
  }

}

Object-oriented hook classes (autowired, in src/Hook) are supported from Drupal 11.1, and hook_file_download can be implemented that way. A site that must also run on Drupal 10.x adds a #[LegacyHook] wrapper (see Hooks and object-oriented hooks). Two warnings apply to any file serving code: never build a path from user input without validating the scheme, and set a safe Content-Type, as untrusted content served with an HTML type from the site’s own domain can run script with the site’s cookies.

Settings in settings.php and services.yml

A hardened settings.php

The following excerpt gathers the security-relevant settings. Secrets are read from the environment (the variable names are Meridian’s own), and the file is committed without any secret value:

// web/sites/default/settings.php (excerpt)

// Used in one-time login links, form tokens and signed values. Long, random, per environment.
$settings['hash_salt'] = getenv('MERIDIAN_HASH_SALT');

// Only answer to the host names the museum owns (see the next section).
$settings['trusted_host_patterns'] = [
  '^www\.meridian-museum\.example$',
  '^meridian-museum\.example$',
  '^staging\.meridian-museum\.example$',
];

// update.php must never be open to anonymous users.
$settings['update_free_access'] = FALSE;

// Loaded services.yml for the project (super user, password hashing and so on).
$settings['container_yamls'][] = $app_root . '/' . $site_path . '/services.yml';

// Hide error messages from visitors; send details to the log instead.
$config['system.logging']['error_level'] = 'hide';

// Configuration is exported outside the document root.
$settings['config_sync_directory'] = '../config/sync';
$settings['file_private_path'] = '../private';

// Extra environment-specific overrides, kept out of version control.
if (file_exists($app_root . '/' . $site_path . '/settings.local.php')) {
  include $app_root . '/' . $site_path . '/settings.local.php';
}

The options in this excerpt:

  • hash_salt is a private key used for one-time login links, form and CSRF tokens, image style tokens and other signed values. If it leaks, an attacker can forge them. It must be long, random, unique per environment and not committed; never copy it between production and staging. Generate one with drush eval 'echo Drupal\Component\Utility\Crypt::randomBytesBase64(55);' and store it in the secret store of the host (see settings.php, services.yml and environments). Changing it invalidates pending reset links and outstanding image style tokens.

  • update_free_access is FALSE by default. When it is TRUE, anyone can run /update.php without logging in. It is meant for broken sites that cannot log in, only for a few minutes, and it should never stay on.

  • error_level controls the display of PHP errors and Drupal messages. hide shows nothing to visitors, while the error details still go to the log; verbose should never be set on production, because stack traces reveal paths, queries and module names.

  • Database credentials come from the environment (getenv()) or from the host’s secret manager. Do not paste real values into a committed settings.php; an exported settings.php with credentials is the most common leak in public repositories.

Trusted host patterns

Drupal builds absolute URLs, such as the password-reset link e-mailed to a Curator, from the Host header of the request. An attacker who can send a forged header can make Drupal e-mail a link that points to the attacker’s own site, with a valid token. $settings['trusted_host_patterns'] is a list of regular expressions without delimiters. If the request’s host does not match any of them, Drupal answers with HTTP 400 and the message "The provided host name is not valid for this server." Pattern syntax is the usual one: ^ anchors the start, $ the end, and every literal dot must be escaped with a backslash. The official example for a single domain is '^www\.example\.com$'.

// Production: exact names only.
$settings['trusted_host_patterns'] = [
  '^www\.meridian-museum\.example$',
  '^meridian-museum\.example$',
];

// DDEV (settings.ddev.php normally handles this; shown for reference).
$settings['trusted_host_patterns'][] = '^meridian\.ddev\.site$';

// A host from the environment is useful when DEV, TEST and PROD differ.
if ($host = getenv('DRUPAL_TRUSTED_HOST')) {
  $settings['trusted_host_patterns'][] = '^' . preg_quote($host, '/') . '$';
}

Pitfalls that cause either outages or no protection at all:

  • An unescaped dot or a missing anchor. meridian-museum.example (no anchors, no escaping) matches meridian-museum.example.attacker.net. Always use ^...$ and \..

  • Health checks and CDNs. A load balancer that probes by IP address or by an internal name receives HTTP 400 and the instance is marked unhealthy. The official page notes this for Kubernetes health checks: either allow the probe’s host or configure the probe to send the real Host header.

  • Command line. Drush has no Host header, so trusted host checks apply only to web requests. Use the --uri option of Drush when a command builds URLs.

  • Absent setting. Drupal’s status report warns when the setting is missing; do not ignore the warning.

Disabling update.php access and not exposing installers

Two scripts change the site and therefore deserve extra care:

  • /core/install.php is the installer. Once the site is installed it only shows that the site is already installed, but returning 404 for it at the web server (as in the nginx example) removes a fingerprinting hint and an attack surface.

  • /update.php runs database updates. By default it requires the administrative permission "Administer software updates" (and user 1 satisfies that permission unless the super user is disabled), and it is open to everyone only when $settings['update_free_access'] is TRUE. On a site that deploys with drush deploy, which runs updatedb, config:import, cache:rebuild and the deploy hooks, nobody needs the browser-based script, so deny it at the web server (remove update from the location pattern above, or add location = /update.php { return 404; }) and let CI run Drush.

Neither script accepts input that gives an attacker code execution on a correctly configured site. The concern is availability and information disclosure, plus the case in which update_free_access was left TRUE for an emergency.

Accounts, roles and user 1

The Administrator role and least privilege

Roles in the Meridian site follow the people who use it: Content Editor, Curator, Reviewer, Member and Administrator (see Users, roles and permissions). The hardening rules are simple to state and easy to erode:

  • Few administrators. Assign the Administrator role to the minimum number of people. Daily work (editing exhibitions, moderating content) needs only an editorial role.

  • Permissions marked "Warning: Give to trusted roles only; this permission has security implications". Permissions such as "Administer permissions", "Administer modules", "Administer users", "Use the Full HTML text format" and "Bypass content access control" are not for editors. Review them with the permissions page at /admin/people/permissions.

  • Review roles from the command line. Drush lists and edits roles, which makes a review scriptable:

drush role:list
drush role:perm:remove anonymous 'access user profiles'
drush role:perm:add curator 'view loan agreements'
drush user:information curator1
  • The Administrator role is special. In the role settings (/admin/people/role-settings) one role is marked as the administrator role. Drupal automatically gives that role every permission, including those of modules enabled later, and a user registered while that role exists inherits all of it. Keep it for people, not for service accounts, and review it after every module addition.

  • Registration. New installations use "Administrators only" for account creation (Drupal 11.1 and later). Keep it unless members must self-register, and then require e-mail verification and an approval step.

Securing user 1

The account that the installer creates (user ID 1) has every permission and, unless the site disables the feature, ignores the permission system. The official page Securing the admin super user (#1) advises treating it like root:

  • use separate administrator accounts created through the Administrator role;

  • do not name the account admin or administrator;

  • block repeated login attempts (core’s flood control, or the Flood Control module for tuning);

  • block user 1 when Drush is available, because Drush can do the maintenance work;

  • give the account a long random password;

  • optionally lock it down with the Paranoia module and enforce rules with Password Policy;

  • restrict logins by IP address with a contributed module when administrators work from known networks;

  • disable the super user policy entirely (Drupal 10.3 and later).

Disabling the policy is the strongest choice. With the parameter below, user 1 becomes an ordinary account: it has only the permissions of its roles, so it must have the Administrator role to administer anything. The page notes that the file must be a real services.yml (copying default.services.yml is the usual start, and editing the default file has no effect), and that settings.php has to load it:

# web/sites/default/services.yml
parameters:
  # Drupal 10.3 and later: user 1 no longer bypasses permission checks.
  security.enable_super_user: false
// web/sites/default/settings.php
$settings['container_yamls'][] = $app_root . '/' . $site_path . '/services.yml';

After changing the parameter, remove the Administrator role from user 1 if it should not hold it, then rebuild caches (drush cr, or visit core/rebuild.php). If you lock yourself out, set the value back to true, rebuild caches and user 1 is a super user again. The same page shows how to block and unblock the account and how to make drush user:login use another administrator by default:

drush user:block admin       # replace with the real account name
drush user:unblock admin     # to re-enable it in an emergency
drush user:login --name=ops-admin   # one-time login link for a named administrator
# drush/drush.yml
command:
  user:
    login:
      options:
        name: ops-admin

Two related defaults help. The one-time login link generated by drush user:login expires and is single use, and it is built from the host that Drush is given, so pass --uri when running it from a server.

Passwords

How Drupal stores passwords

Drupal never stores a password; it stores a salted one-way hash and compares hashes on login. Before Drupal 12 the hashing service uses PHP’s default password algorithm (bcrypt). Hashes created by Drupal 7 ($S$ phpass hashes) are still verified and are silently re-hashed with the current algorithm on the user’s next login, which is why a migrated site becomes stronger as users return. A password hash stored today is rehashed whenever the configured algorithm changes: the next successful login replaces the old hash.

Drupal 12 changes the default to argon2id. The change record Default password hashing algorithm is now argon2id introduces two parameters in services.yml: password.algorithm, whose valid values are argon2i, argon2id and 2y (bcrypt), with null meaning PHP’s default, and password.options, which is passed to password_hash() and rarely needs to be set. Drupal 12 defaults to argon2id. Existing passwords are re-hashed with it automatically on each user’s next login. Drupal 12 requires PHP 8.5, so argon2 support is expected to be present, but a site can check its PHP build, and fall back to bcrypt if needed. The change record also notes that a site on the 11.4 line may opt in early by setting the parameter, and that the contributed PHP Password module (3.0.0 series) provided the feature earlier.

# Which algorithms does this PHP build support?
php -r 'echo implode("\n", password_algos()) . "\n";'
# web/sites/default/services.yml (Drupal 11.4 opt-in; the default from 12.0.0)
parameters:
  password.algorithm: argon2id
  # Leave password.options empty unless you have measured a need to tune the cost.
  password.options: []

Operational consequences of the switch: a stronger algorithm costs more memory and CPU per login, so load-test the login path on small hosting plans; an argon2 hash cannot be verified by software that lacks argon2 support, so a rollback to an older PHP build breaks logins until the setting is changed back; and because Drupal 12 only accepts upgrades from 11.3 or later, enabling the parameter on 11.4 early is a safe way to rehearse the change.

Password policy beyond hashing

Core does not enforce password strength beyond a visual meter. Common practice, backed by the NIST guidelines reviewed on US NIST Password Guidelines review, is to favor length over composition rules, to check against lists of leaked passwords, and not to force periodic changes. The Password Policy module adds length, character and history rules, and the Pwned Passwords module checks candidate passwords against a breach list. Authentication improvements are described in Authentication improvements. Credentials used by the site to talk to other systems (the collections-management API key, the SMTP password) are secrets of a different kind and follow Securing Authentication Credentials: keep them in the environment or in the Key module, never in exported configuration.

Two-factor authentication

A password is a single point of failure, so the Administrator, Curator and Content Editor roles of the Meridian site should use a second factor. Core has no two-factor feature; the established contributed module is Two-factor Authentication (TFA) (drupal/tfa). According to its project page it supports Drupal 10 and 11 in the stable 1.x series (1.13 on 2026-09-16), offers time-based one-time passwords (TOTP, the codes of authenticator apps) and HMAC-based ones (HOTP), a pluggable design with fallback methods, context-specific exceptions, flood control for one-time logins and role-based requirements, plus add-on projects for SMS, e-mail codes, Duo and a headless REST endpoint. The e-mail and Duo add-ons are not covered by the security advisory policy. The 2.x line was alpha at the time of checking and its latest alpha is marked "DO NOT USE" because of known security issues, so production sites stay on 1.x. The module notes that contributed modules that bypass the login form can bypass the second factor, which is one reason to keep the number of authentication extensions small.

composer require 'drupal/tfa:^1.13'
drush pm:install tfa

Configure the module at /admin/config/people/tfa: choose the validation plugins (TOTP), set which roles are required to use TFA, give users a number of skip logins while they enrol, and enable recovery codes so that a lost phone does not lock out an administrator. Encrypted data used by the module needs the PHP OpenSSL extension. Rehearse recovery before turning the requirement on for everyone: an administrator who loses the second factor must be recovered with Drush, and that requires shell access to the server. For a decoupled front end or API clients, the module has a headless plugin; see Authentication and OAuth for OAuth 2.0 with Simple OAuth, which replaced the removed implicit and password grants in its 6.x line.

Text formats and content injection

Text formats decide what HTML is kept when content is displayed, and they are one of the largest injection surfaces in Drupal. The official page Configuring text formats (aka input formats) for security lists the risks:

  • Full HTML for untrusted users. A visitor with access to Full HTML can store arbitrary script, for example a script that changes an administrator’s password when a privileged user views the page.

  • Dangerous tags in an allow-list. Adding APPLET, EMBED, FORM, IFRAME, INPUT, OBJECT, SCRIPT or STYLE to a format that untrusted people can use is dangerous, and so are structural or media tags used to mimic the interface.

  • Filter order. The "Limit allowed HTML tags and correct faulty HTML" filter must run after every filter that can emit markup, or content produced by a later filter bypasses it. In a format meant for untrusted users the HTML filter is last.

  • Over-broad permissions. The permission to use a format (use text format full_html) should be granted only to trusted roles; Anonymous and Member users get the restricted format, and Content Editors a Basic HTML format.

  • Convenience pressure. Editors want more tags and a WYSIWYG toolbar; the page warns that yielding to it for anonymous users produces an unsafe site.

The page predates CKEditor 5, and the advice must be translated to it: with CKEditor 5 the format’s "Limit allowed HTML tags" list is generated from the editor plugins that are enabled, and enabling "Source editing" with arbitrary tags widens the allow-list. Review the format after adding plugins, and read Text formats and CKEditor 5. In the Meridian site, "Restricted HTML" is the only format for anonymous comments, "Basic HTML" for editors and "Full HTML" for the Administrator and Curator only:

# Anonymous visitors and members never use Full HTML.
drush role:perm:remove anonymous 'use text format full_html'
drush role:perm:remove authenticated 'use text format full_html'
drush role:perm:add curator 'use text format full_html'
drush config:get filter.format.basic_html filters

Other places where untrusted content reaches pages need the same care. Twig autoescapes, so never mark user-controlled text safe (|raw, Markup::create(), #markup with user data); use #plain_text or Xss::filter() for small snippets (see Render API and cache metadata). Webform and Contact submissions are untrusted, even when they only appear in the administrator’s view (see Forms for visitors: Contact and Webform), and SQL must use placeholders and entity queries with accessCheck(), as taught in Database API and entity queries. The developer-oriented rules are in Writing secure code for Drupal.

PHP settings and the zend.assertions change

PHP’s own configuration is part of the hardening surface. For production the following php.ini values are standard:

; php.ini (FPM pool or CLI used by cron)
expose_php = Off
display_errors = Off
log_errors = On
; Assertions are compiled out in production. This cannot be changed at runtime.
zend.assertions = -1
session.use_strict_mode = 1
disable_functions = exec,passthru,shell_exec,system,proc_open,popen

disable_functions is optional and is a compromise: it limits what an exploited script can run, but breaks modules and tools that call external programs (image conversion, Drush when it runs through PHP-FPM).

The .htaccess change in 11.4.11. Drupal’s scaffolded .htaccess used to set assert.active for mod_php. PHP deprecated that setting, and the releases Drupal 11.4.11, 11.3.20 and 10.6.20 (published 2026-10-21) replaced it with zend.assertions. The 11.4.11 release notes say to replace php_value assert.active 0 with php_value zend.assertions 0 in the <IfModule mod_php.c> section of an .htaccess file that you manage yourself, or, better, to set zend.assertions = -1 in php.ini (the recommended production value) and then remove the section from .htaccess, because leaving it produces warnings in the log. The value -1 cannot be set at runtime, so .htaccess can only set 0 (assertions compiled in but inactive). Because the scaffold normally overwrites .htaccess, sites that customised the file by hand must apply the change manually; sites that use the scaffold pick it up with composer update. The release notes do not describe a specific security fix or advisory for this change. On nginx and PHP-FPM there is no .htaccess, so the php.ini (or FPM pool) setting is the only place it can be configured.

Related defaults tied to the 11.4 line: the robots.txt that the scaffold provides should be customised with Disallow: /search? for sites that keep the core Search module’s URLs out of search engines, and drupal/core-recommended no longer pins Guzzle, Twig or the Symfony polyfills, so a composer audit run reflects the versions actually installed.

Response headers and Security Kit

Browsers enforce several protections only when the server asks for them. The headers that matter are:

Header Purpose

Strict-Transport-Security

Forces HTTPS for the host for the given period.

X-Content-Type-Options: nosniff

Stops browsers from guessing a content type (core already sends it).

X-Frame-Options: SAMEORIGIN or a CSP frame-ancestors directive

Prevents other sites from framing yours (clickjacking).

Referrer-Policy: strict-origin-when-cross-origin

Limits the URL leaked to other sites.

Content-Security-Policy

Allow-list of script, style and media sources; the main mitigation of cross-site scripting.

Permissions-Policy

Disables browser features (camera, geolocation) the site does not use.

They can be sent from the web server, a CDN or a module. The Security Kit module (drupal/seckit) sends them from Drupal and keeps the settings in configuration. Its project page lists 2.0.3 (2024-09-11, a security release) as the current release for Drupal 9.5, 10 and 11, maintenance-only status, and these features: Content Security Policy (configurable, with violation reporting to the log), X-XSS-Protection control and nosniff, clickjacking protection through X-Frame-Options (with JavaScript fallbacks), HSTS, handling of the Origin request header against cross-site request forgery and a From-Origin header. It documents Referrer-Policy and Feature-Policy (the predecessor of Permissions-Policy) but does not list Permissions-Policy as a feature, so that header is better set at the web server. Because the module is in maintenance mode, and because a web-server header is applied to files that never reach Drupal (images, downloads), many production sites set headers in nginx or the CDN and use Security Kit only where the server cannot be edited.

composer require 'drupal/seckit:^2.0'
drush pm:install seckit
# Settings page: /admin/config/system/seckit

Source: Security Kit

The same headers in nginx for the Meridian site:

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Start CSP in report-only mode, read the violation reports, then enforce.
add_header Content-Security-Policy-Report-Only "default-src 'self'; img-src 'self' data: https://images.meridian-museum.example; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'" always;

A Content Security Policy is the header most likely to break a site: the administrative toolbar, CKEditor 5, Canvas and Layout Builder use inline styles and scripts, and a policy written for the public site can disable the editing tools. Use report-only mode first, apply a stricter policy to the public paths, and keep a more permissive one for /admin. Do not expect a CSP to save a site with unfiltered HTML: it is a second layer behind text-format hygiene.

Abuse, spam and anonymous forms

Public forms are the other front door. Contact and Webform submissions, comments and registration forms attract bots. Core has no CAPTCHA. The security documentation lists contributed options for spam control, among them the CAPTCHA and reCAPTCHA modules, Honeypot (hidden fields and time checks, with no user friction), blocklists for IP and e-mail addresses, and Login Security. A layered approach that works for the Meridian Museum is Honeypot on all public forms, a CAPTCHA only where Honeypot is not enough, flood limits on the contact form and private submission storage for the personal data. The layers and their order are drawn in Forms for visitors: Contact and Webform, and the options are listed in Spam control modules. Personal data collected by forms also falls under Privacy management.

Monitoring advisories and keeping up to date

Most compromises of Drupal sites follow the publication of a security advisory, because attackers read the same announcements as administrators and automate the exploitation of unpatched sites in hours or days. The routine for the Meridian Museum:

  1. Subscribe. Site administrators sign up for the security mailing list at https://www.drupal.org/security (or through the "My newsletters" tab of the drupal.org account). Advisories are also published as RSS feeds for core, contributed projects and public service announcements.

  2. Enable the Update Status module (named Update Manager in the UI of older versions) and give it a working e-mail address under Reports, Available updates, Settings. It lists missing security updates on the status report.

  3. Schedule composer audit. The security documentation shows a cron script that runs it and e-mails when it reports advisories for installed packages; this catches vulnerable PHP libraries as well as Drupal projects.

  4. Apply security updates on release day. Advisories for core and contributed projects normally appear on Wednesdays. Some are marked as highly critical or as having a known exploit in the wild: plan the work so an update can go out within hours.

  5. Keep a deployment pipeline that works. A fix is only fast if the site can be built, tested and deployed quickly (see Deployment and CI/CD).

#!/usr/bin/env bash
# /usr/local/bin/meridian-audit.sh -- run daily from cron or CI.
set -uo pipefail
cd /var/www/meridian || exit 1

if ! output=$(composer audit --locked --no-dev 2>&1); then
  printf '%s\n' "$output" | mail -s "Meridian: composer audit found advisories" webmaster@meridian-museum.example
fi
drush pm:security --no-ansi 2>&1 | grep -v 'No security' | mail -E -s "Meridian: Drupal security updates" webmaster@meridian-museum.example

(mail -E is the GNU mailx option to skip empty messages; adapt the mail command to the host.) A pipeline step that runs composer audit and fails the build when advisories exist is stronger than e-mail. Read the advisory text before updating: it says which versions are affected, whether the vulnerability needs a permission or a module configuration, and whether manual steps (such as editing .htaccess) are required. The 11.4.11 zend.assertions change is an example of a release whose .htaccess edit is not applied to a customised file by Composer. The detail of the update workflow (composer update, drush deploy, Package Manager) is in Updates, Composer and security advisories, and removed core modules whose contributed replacements must be installed (not uninstalled) before upgrading are in Removed core modules and replacements.

Logging and detection

An attack that nobody sees cannot be stopped. Log what matters and look at it:

  • Database log versus syslog. The Database Logging module writes to the watchdog table, which is convenient but consumes database space and is readable only by administrators who log in. On production, send logs to syslog or to a central log service and keep the web server’s access and error logs. See Logging, status report and troubleshooting.

  • Interesting events. Failed logins and flood blocks ("Login attempt failed"), "access denied" and "page not found" bursts, changes to roles and permissions, new administrator accounts, new modules enabled and configuration imports.

  • Status report. The report at /admin/reports/status flags a writable settings.php, a missing trusted host setting, a public private-files path, a failed cron and security updates that are missing. Treat red and orange rows as work items.

  • Cron health. Updates and queue-based clean-up depend on cron (see Cron, queues and maintenance).

  • File integrity. A read-only deployment can be verified by comparing the files on disk with the build artifact, which turns "someone edited a file" into a failed check.

Incident response

The official page My site was defaced ("hacked"). Now what? does not prescribe an ordered runbook; it stresses ruling out other attack vectors first and gives limited guidance on reporting. A practical runbook for the Meridian team, built on that guidance and on the backup practices of the sub-section, is shown below.

flowchart TD A[Suspicion or alert] --> B[Contain: maintenance page, block the entry point, keep logs] B --> C[Preserve evidence: copy logs, database dump, file listing] C --> D{Entry point found?} D -- Drupal or module --> E[Check advisories and installed versions] D -- Server or SSH or other app --> F[Rotate server credentials and patch host] D -- Unknown --> G[Assume full compromise] E --> H[Restore from clean backup or rebuild from Git and Composer] F --> H G --> H H --> I[Update everything and rotate hash_salt, passwords, API keys] I --> J[Review users, roles, permissions and modules] J --> K[Harden and monitor, then record lessons]

The steps, in more detail:

  1. Contain. Put the site in maintenance mode (drush state:set system.maintenance_mode 1) or take it offline at the proxy, block the attacker’s addresses and, if the cause is unknown, isolate the server. Do not delete anything yet.

  2. Preserve evidence. Copy the web server, PHP and database logs, a database dump and a listing of files with their modification times, because the attack’s timeline matters. Compare the codebase with the repository: with Composer and Git, git status, git diff and composer install --no-dev followed by a checksum comparison show modified code.

  3. Rule out other vectors. The attacker may not have used Drupal at all. Check weak administrator passwords and SSH credentials, the web server logs, other applications and tenants on the same server, and whether Drupal core and every contributed module were up to date at the time, comparing recent advisories with what happened. Hardening only helps if the entry point is closed.

  4. Rebuild, do not clean. A compromised server is not trustworthy, so rebuild it from known-good sources: restore the database from a backup taken before the intrusion (or review it manually), reinstall the code from Git and Composer, and restore uploaded files after scanning them. Backup and restore procedure is in Backups and disaster recovery.

  5. Rotate every secret. The hash_salt, database passwords, API keys of third-party services, OAuth secrets, SSH keys, and all user passwords (force a reset for privileged users). Review the users and roles tables for accounts the attacker created and the module list for modules that were not deployed.

  6. Close the gap and verify. Apply the missing update or fix the misconfiguration, rerun the smoke test above, and check the status report.

  7. Tell the right people. Notify the affected visitors if personal data was exposed (legal duties differ by country). The Drupal security team can be informed, but its page states that it looks for widespread patterns and does not provide individual support or recover the site; include the Drupal and module versions, the PHP and web server versions, how code is deployed and the output of ls -l in the Drupal directory. A suspected vulnerability in core or a contributed project is reported privately to the security team, never in a public issue queue.

Common errors and pitfalls

  • Trusting .htaccess on nginx. The files are ignored; every protection must be re-created in the server configuration.

  • AllowOverride None on Apache. All .htaccess safeguards, including the PHP block in the files directory, are then inactive without any message.

  • A web-writable code tree. It turns any upload or code flaw into persistent compromise, and relaxing it for Package Manager makes the other layers more important.

  • Trusted host patterns without anchors or with unescaped dots. They match hosts the owner does not control.

  • Private files inside the document root, or a private storage setting that was applied after the first upload.

  • Leaving update_free_access on after an emergency, or leaving update.php reachable on a deploy-by-Drush site.

  • An administrator account used for daily work, an account named admin and user 1 with a shared password.

  • Full HTML for anonymous or member roles, or an HTML filter that does not run last.

  • Customised .htaccess that scaffolding overwrites, losing hardening changes, or never receiving security edits such as the zend.assertions one.

  • hash_salt committed to Git, reused in several environments or placed in a screenshot or a ticket.

  • Verbose errors on production, which expose paths and database structure.

  • Treating headers as a substitute for patching. CSP, HSTS and version hiding reduce the impact of a bug; they do not fix it.

  • Skipping the restore test. A backup that was never restored is not a backup.

Version notes

Version Security-relevant change

10.3

/user/logout is protected by a CSRF token; the security.enable_super_user parameter can disable the user 1 super-user policy.

10.6

Drupal 10’s last minor; Drupal 10 ends on 2026-12-09.

11.0

IIS support removed; several modules removed from core, whose contributed versions must be installed before upgrading.

11.1

New installations default to administrator-only account registration; object-oriented hook classes with #[Hook].

11.2

Core updating from the user interface removed (module and theme upload went in 10.4/11.0).

11.3

HTMX headers must pass through proxies; Windows production hosting deprecated; PHPUnit 10 dropped.

11.4

Standard profile no longer creates the Article and Page content types; drupal/legacy-project abandoned; robots.txt should disallow /search?; drupal/core-recommended stops pinning Guzzle, Twig and the Symfony polyfills.

11.4.11, 11.3.20, 10.6.20

.htaccess uses zend.assertions instead of assert.active (2026-10-21).

12.0.0 (planned week of 2026-12-07)

argon2id is the default password hash; PHP 8.5 and Symfony 8 required; upgrades from 11.2 or earlier are refused; Claro, Olivero and Stable 9 are removed.

Older sources show things that no longer apply: assert.active in .htaccess, Drupal 7’s $conf['trusted_host_patterns'] (the setting is $settings[...] since Drupal 8), the module installation from the browser, the Statistics module and CKEditor 4. The cumulative list is in Where older books and tutorials are outdated.

A hardening checklist for the Meridian Museum

  1. Supported versions: Drupal 11.4.x (or later) on PHP 8.3 or later; plan Drupal 12.

  2. The document root is web/; vendor/, config/ and private/ are outside it.

  3. HTTPS everywhere with an HTTP redirect, HSTS, proxy addresses declared, session cookie Secure, HttpOnly, SameSite=Lax.

  4. trusted_host_patterns set with anchored, escaped names; hash_salt from the environment.

  5. Code read-only for the web server; only the files, private and temporary directories writable; settings.php is 440.

  6. Web server rules for dotfiles, source files, PHP in the files directories and install.php; update.php denied if you deploy with Drush.

  7. zend.assertions = -1 in php.ini; errors hidden; expose_php off.

  8. Private files for loan agreements and webform attachments, with access rules in a hook.

  9. Roles reviewed; few administrators; user 1 blocked or its super-user policy disabled; registration by administrators only.

  10. TFA for privileged roles with tested recovery; password policy; argon2id enabled when on 11.4 or later.

  11. Text formats: no Full HTML for anonymous or members, HTML filter last, CKEditor 5 plugins reviewed.

  12. Response headers (HSTS, nosniff, frame and referrer policies) and a CSP in report-only mode, then enforced.

  13. Security mailing list, Update Status, a daily composer audit and a same-day update routine.

  14. Logs to a central place; status report reviewed; backups restored in a test.

  15. A written incident-response runbook, tested once a year.