Drupal has an excellent security track record at the core level, but production breaches consistently come from misconfigurations, outdated contributed modules, overly permissive file system settings, and missing server-level controls. This checklist covers every layer: Drupal application, file system, database, Nginx/PHP-FPM, and monitoring. Apply these in order; each section builds on the last.
1. Keep Core and Contributed Modules Updated
Security advisories for Drupal core and contrib modules are published on drupal.org/security every Wednesday. Subscribe to the mailing list or set up automated checks:
# Check for available updates
drush pm:security
# Update core
composer update drupal/core --with-all-dependencies
drush updb -y && drush cr
# Check which modules have known vulnerabilities right now
drush pm:security --format=json | jq '.[] | {name, severity, link}'
In CI, add a security check step:
# .github/workflows/security.yml
- name: Check Drupal security advisories
run: vendor/bin/drush pm:security --no-interaction
2. File System Permissions
Drupal's recommended permissions prevent web-accessible writes to PHP files:
# Drupal root — web server reads but never writes
find /var/www/drupal -type d -exec chmod 755 {} \;
find /var/www/drupal -type f -exec chmod 644 {} \;
# settings.php — no world read; PHP-FPM user reads it
chmod 440 /var/www/drupal/web/sites/default/settings.php
chmod 440 /var/www/drupal/web/sites/default/services.yml
# The public files directory — web server needs to write
chmod 755 /var/www/drupal/web/sites/default/files
# Private files directory — NOT web-accessible
mkdir -p /var/www/drupal/private
chmod 750 /var/www/drupal/private
chown www-data:www-data /var/www/drupal/private
Configure the private file path in settings.php:
$settings['file_private_path'] = '/var/www/drupal/private';
3. settings.php Hardening
<?php
// settings.php — production hardening
// Disable the PHP error display (log instead)
ini_set('display_errors', '0');
ini_set('log_errors', '1');
// Trusted host patterns — prevent HTTP Host header attacks
$settings['trusted_host_patterns'] = [
'^example\.com$',
'^www\.example\.com$',
];
// Hash salt — generate once with: drush eval "echo Drupal\Component\Utility\Crypt::randomBytesBase64(55)"
$settings['hash_salt'] = 'YOUR_UNIQUE_RANDOM_HASH_SALT';
// Disable access to update.php from web
$settings['update_free_access'] = FALSE;
// Aggregate CSS/JS in production
$config['system.performance']['css']['preprocess'] = TRUE;
$config['system.performance']['js']['preprocess'] = TRUE;
// Reverse proxy headers (if behind Nginx/Varnish)
$settings['reverse_proxy'] = TRUE;
$settings['reverse_proxy_addresses'] = ['127.0.0.1'];
// Disable Twig debugging in production (default off, but be explicit)
$config['system.logging']['error_level'] = 'hide';
4. Nginx Configuration for Drupal Security
server {
listen 443 ssl;
http2 on;
server_name example.com;
root /var/www/drupal/web;
# Block access to hidden files and directories
location ~ /\. {
deny all;
return 404;
}
# Block access to sensitive Drupal files
location ~* \.(engine|inc|install|make|module|profile|po|sh|.*sql|theme|twig|tpl(\.php)?|xtmpl|yml)$ {
deny all;
return 404;
}
# Block access to vendor directory
location ~ ^/vendor/ {
deny all;
return 404;
}
# Block access to Composer files
location ~ (composer\.(json|lock)|\.env)$ {
deny all;
return 404;
}
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# Content Security Policy — adjust sources for your needs
add_header Content-Security-Policy
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'self';"
always;
location / {
try_files $uri /index.php?$query_string;
}
location ~ '\.php$|^/update.php' {
# Only allow PHP from within Drupal's web root
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_param HTTP_PROXY ""; # Prevent httpoxy attack
}
}
5. Database User Privileges
Drupal's database user should not have CREATE, DROP, or ALTER privileges on production. Grant those only temporarily during deployments:
-- Production database user (minimal privileges)
CREATE USER 'drupal_prod'@'localhost' IDENTIFIED BY 'strong_random_password';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE TEMPORARY TABLES, LOCK TABLES
ON drupal_prod.* TO 'drupal_prod'@'localhost';
FLUSH PRIVILEGES;
-- Temporary deploy user (used only during drush updb)
CREATE USER 'drupal_deploy'@'localhost' IDENTIFIED BY 'deploy_password';
GRANT ALL PRIVILEGES ON drupal_prod.* TO 'drupal_deploy'@'localhost';
FLUSH PRIVILEGES;
6. Drupal Permissions and Roles
- Review permissions at
/admin/people/permissions. No anonymous or authenticated role should have Administer permissions. - Separate content editor, moderator, and administrator roles with the principle of least privilege.
- Restrict
PHP filtermodule — if enabled, only administrators should have access, and ideally it should be uninstalled entirely. - Check who has
use PHP for settings— in Drupal 11 this is restricted to UIDs with role ID 1 by default, but verify after any contrib module install.
# List users with administrator role
drush eval "
\$query = \Drupal::entityQuery('user')
->condition('roles', 'administrator')
->accessCheck(FALSE)
->execute();
\$users = \Drupal\user\Entity\User::loadMultiple(\$query);
foreach (\$users as \$u) { print \$u->getEmail() . PHP_EOL; }
"
7. Two-Factor Authentication
Two-factor authentication requires installing the drupal/tfa contributed module — it is not included in Drupal core. Install and enable it:
composer require drupal/tfa
drush en tfa -y
In the TFA settings at /admin/config/people/tfa:
- Enable TOTP (app-based authenticator) as the primary method.
- Set required roles to at minimum Administrator and Editor.
- Allow a recovery code as a fallback.
- Set flood protection: maximum 5 TFA attempts before lockout.
8. Disable Unused Modules and Routes
# Disable the update module on production (use drush/composer for updates)
drush pmu update -y
# Disable devel if somehow enabled in production
drush pmu devel -y
# Audit enabled modules — look for anything unexpected
drush pm:list --status=enabled --format=json | jq '.[] | .name'
The update module exposes an admin interface for updates — there is no reason for it to be enabled when you use Composer. Disabling it removes a potential attack surface.
9. Content Security Policy and Security Kit Module
drush en seckit -y
Configure at /admin/config/system/seckit:
- Enable HTTP Strict Transport Security with
max-age=31536000andincludeSubDomains. - Enable X-Content-Type-Options: nosniff.
- Enable X-Frame-Options: SAMEORIGIN.
- Configure Content Security Policy — start in report-only mode with a reporting endpoint, then switch to enforcement once you've captured violations.
10. Flood Control and Login Protection
// In settings.php — tighten login flood defaults
$config['user.flood']['ip_limit'] = 50; // default 50 attempts per IP
$config['user.flood']['ip_window'] = 3600; // per hour
$config['user.flood']['user_limit'] = 5; // per account
$config['user.flood']['user_window'] = 21600; // per 6 hours
For additional protection, configure Fail2ban on the server to read Drupal's watchdog log via syslog or the dblog module and ban IPs after repeated failed logins. See the Fail2ban + Nginx guide for the filter syntax.
11. Automated Security Monitoring
# Cron job: daily security advisory check, email on findings
0 7 * * * cd /var/www/drupal && vendor/bin/drush pm:security \
--format=json 2>&1 | mail -s "Drupal Security Report: $(hostname)" admin@example.com
Also enable the Site Audit module (drupal/site_audit) to run automated checks:
drush audit:best-practices
drush audit:security
Summary Checklist
- Subscribe to Drupal security advisories and automate
drush pm:securityin CI. - Set
settings.phpto mode 440; thefiles/directory to 755; everything else to 644/755. - Configure
trusted_host_patternsinsettings.php. - Block access to
.php,.yml,.env, and vendor files at the Nginx level. - Add full security headers (HSTS, CSP, X-Frame-Options, Referrer-Policy).
- Grant the production database user SELECT/INSERT/UPDATE/DELETE only — no schema changes.
- Install
drupal/tfaand enable TFA for all privileged roles. - Disable the
updateandphpfilter modules in production. - Use Security Kit module for CSP enforcement (start in report-only mode).
- Configure flood control limits and integrate Fail2ban for repeated login failures.