Drupal Security Hardening Checklist for Production Sites

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 filter module — 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=31536000 and includeSubDomains.
  • 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:security in CI.
  • Set settings.php to mode 440; the files/ directory to 755; everything else to 644/755.
  • Configure trusted_host_patterns in settings.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/tfa and enable TFA for all privileged roles.
  • Disable the update and php filter 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.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.