wp-config.php is the first PHP file WordPress loads on every request. It runs before any plugin, before any theme, before the database connection is even established. Anything you set here applies to the entire site and is impossible for a plugin to override (which is the point, both as a feature and as a security property). The defaults from the WordPress installer are minimal; the hardened defaults take about five minutes to apply and close most of the attack surface that lives below the plugin layer.
This article is the annotated reference template I drop into every production WordPress site I work on. Every directive is documented inline: what it does, why it's set the way it is, when you would deviate, and the trade-off. The file works on WordPress 4.x, 5.x, 6.x, and the current 6.9 line; settings that depend on specific versions are flagged inline.
I first wrote this template in early 2019 after a string of cleanups where the same five preventable issues kept coming up: file editing enabled in the admin (so a compromised admin account could write PHP through the browser), mixed-content HTTPS issues (so cookies were being sent over HTTP somewhere in the flow), default salts (so stolen sessions stayed valid forever), debug output exposed (so plugin source code paths leaked to attackers), and predictable database table prefixes (so SQL injection payloads worked out of the box). All five are one-line fixes in wp-config.php.
The complete template
Copy this into your wp-config.php, fill in the credentials and salts, and read the inline comments to understand each choice. The order matters: directives that affect path resolution and constants have to come before the require_once ABSPATH . 'wp-settings.php' line at the bottom.
<?php
/**
* The base configuration for WordPress.
*
* Hardened defaults applied:
* - File editing disabled in admin (no PHP write-back path through the UI)
* - Auto-updates enabled for core minor releases (security patches arrive on time)
* - HTTPS forced for admin and login (cookies never traverse HTTP)
* - Salts rotated (every existing session becomes invalid)
* - Debug logging on, debug display off (errors are logged but not shown to visitors)
* - Limited post revisions (database stays sane)
* - Empty trash quickly (deleted posts don't linger as recoverable history)
* - Custom $table_prefix (defeats SQL injection payloads that assume wp_)
* - Cron managed by system cron, not page loads (more reliable, more secure)
*
* For the bigger-picture hardening this fits into, see:
* https://techearl.com/wordpress-malware-removal
* https://techearl.com/wordpress-file-integrity-monitoring-server-side
*/
// ============================================================
// Database settings
// ============================================================
/**
* Database name, user, password, host.
*
* The user should have ONLY the privileges WordPress needs on THIS database:
* GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER,
* CREATE TEMPORARY TABLES, LOCK TABLES, REFERENCES
* ON `your_db_name`.* TO 'your_db_user'@'localhost';
*
* Do NOT grant ALL PRIVILEGES, FILE, PROCESS, SUPER, or RELOAD. A SQL injection
* with FILE privilege can read /etc/passwd; with PROCESS it can see other tenants;
* with SUPER it can persist beyond WordPress entirely.
*/
define( 'DB_NAME', 'your_db_name' );
define( 'DB_USER', 'your_db_user' );
define( 'DB_PASSWORD', 'your_db_password' );
define( 'DB_HOST', 'localhost' );
define( 'DB_CHARSET', 'utf8mb4' );
define( 'DB_COLLATE', '' );
/**
* Custom table prefix. The default 'wp_' is documented in every SQL injection
* payload ever written; a custom prefix turns most of them into no-ops at the
* query level. The prefix must match what's actually in your database; if you're
* migrating an existing site, do NOT change this without also renaming all the
* tables (use the WP-CLI command `wp db search-replace wp_ mysite_ --all-tables`
* on a fresh export, then restore).
*
* Pick something short, 3-8 characters, lowercase, ending with underscore.
*/
$table_prefix = 'mysite_';
// ============================================================
// Authentication unique keys and salts
// ============================================================
/**
* WordPress uses these 8 constants to hash login cookies, nonces, and other
* secret material. They MUST be unique per site and MUST NOT be the placeholder
* values from the sample file. Generate fresh ones every time you set up a site
* AND every time you suspect compromise (rotating these invalidates every
* existing session, which is exactly what you want during incident response).
*
* Generate at: https://api.wordpress.org/secret-key/1.1/salt/
*
* Replace this entire block with the output of the above URL.
*/
define( 'AUTH_KEY', 'PASTE_FRESH_VALUE_HERE' );
define( 'SECURE_AUTH_KEY', 'PASTE_FRESH_VALUE_HERE' );
define( 'LOGGED_IN_KEY', 'PASTE_FRESH_VALUE_HERE' );
define( 'NONCE_KEY', 'PASTE_FRESH_VALUE_HERE' );
define( 'AUTH_SALT', 'PASTE_FRESH_VALUE_HERE' );
define( 'SECURE_AUTH_SALT', 'PASTE_FRESH_VALUE_HERE' );
define( 'LOGGED_IN_SALT', 'PASTE_FRESH_VALUE_HERE' );
define( 'NONCE_SALT', 'PASTE_FRESH_VALUE_HERE' );
// ============================================================
// File system and updates
// ============================================================
/**
* Disable the in-admin file editor.
*
* Without this, an administrator (or anyone who took over an admin account)
* can edit theme and plugin PHP files directly through Appearance > Theme File
* Editor and Plugins > Plugin File Editor. That writes PHP to disk through the
* browser, which is the primary way stolen admin credentials get turned into
* persistent backdoors. Disabling the editor doesn't stop legitimate updates;
* it only stops the manual through-the-UI edit path.
*
* This is one of the two single most important hardening directives. The other
* is FORCE_SSL_ADMIN below.
*/
define( 'DISALLOW_FILE_EDIT', true );
/**
* Disable plugin and theme installation, updates, and deletion through the admin.
*
* Stronger than DISALLOW_FILE_EDIT alone, this prevents an admin from installing
* a new plugin entirely. Combined with DISALLOW_FILE_EDIT, the only way to
* modify or add code to the site is through SSH/SFTP plus deployment process.
* For sites with a real release process, that's correct. For sites where
* admins legitimately install plugins, this is too restrictive.
*
* Set true if your deployment is git-based or automated; leave false if admins
* legitimately install plugins through the admin.
*/
define( 'DISALLOW_FILE_MODS', false );
/**
* How WordPress writes files. 'direct' uses standard file functions (fastest
* and works when the web user owns the WordPress directory); 'ssh2' uses SSH
* (requires the ssh2 PHP extension); 'ftpext' and 'ftpsockets' use FTP.
*
* 'direct' is correct for any deployment where the web user owns wp-content/.
* Anything else means files are getting written by a different user, which
* leads to permission confusion and is usually a sign of misconfigured hosting.
*/
define( 'FS_METHOD', 'direct' );
/**
* Auto-update WordPress core minor releases (security patches).
*
* Possible values:
* true Auto-update everything (major + minor + dev + translations)
* false Auto-update nothing
* 'minor' Auto-update minor releases only (default since WordPress 3.7)
*
* 'minor' is the right answer. Security patches arrive without intervention;
* major version upgrades remain manual (so you can test and roll back if a
* theme or plugin breaks). The default-since-3.7 is already 'minor' implicitly;
* setting it explicitly documents the choice.
*/
define( 'WP_AUTO_UPDATE_CORE', 'minor' );
// ============================================================
// HTTPS and admin access
// ============================================================
/**
* Force SSL for the admin area and the login form.
*
* Without this, an admin who happens to load /wp-admin/ over HTTP sends their
* authentication cookie unencrypted. On any shared WiFi, that's an immediate
* session hijack. This directive redirects HTTP admin/login requests to HTTPS
* before any cookie is read.
*
* Requires that your site has a working HTTPS certificate (Let's Encrypt is
* free and automated). Don't enable this on a site that doesn't have HTTPS yet
* or admins will be locked out.
*/
define( 'FORCE_SSL_ADMIN', true );
/**
* Set the canonical site URLs explicitly. Without this, WordPress derives the
* URL from the HTTP_HOST header on each request, which is attacker-influenced.
* Setting these constants pins the URLs to known-good values regardless of
* what the request claims.
*
* Update both lines to match your real site. They should always start with
* https://, never http://, on a hardened site.
*/
define( 'WP_HOME', 'https://yoursite.com' );
define( 'WP_SITEURL', 'https://yoursite.com' );
/**
* Trust the X-Forwarded-Proto header from your reverse proxy.
*
* If WordPress sits behind Cloudflare, Nginx, or any reverse proxy that
* terminates SSL and forwards to WordPress over HTTP, this lets WordPress
* detect that the original request was HTTPS and generate correct https://
* URLs in the output.
*
* Comment out (or leave commented) if WordPress receives requests directly
* over HTTPS without a proxy.
*/
// if ( isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] )
// && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https' ) {
// $_SERVER['HTTPS'] = 'on';
// }
// ============================================================
// Cron
// ============================================================
/**
* Disable WP-Cron's page-load trigger.
*
* By default, every request to WordPress checks scheduled events and runs any
* that are due. This is unreliable (events fire only when traffic happens; a
* low-traffic site can miss scheduled jobs entirely) and exposed (anyone can
* trigger cron by hitting /wp-cron.php, which DoS-attackers sometimes abuse).
*
* Set this to true, then add a system cron entry that hits wp-cron.php every
* 5 or 15 minutes:
*
* */5 * * * * curl -s https://yoursite.com/wp-cron.php?doing_wp_cron > /dev/null
*
* Or, more cleanly, with WP-CLI:
*
* */5 * * * * wp cron event run --due-now --path=/var/www/wordpress > /dev/null
*/
define( 'DISABLE_WP_CRON', true );
/**
* Wp-Cron lock timeout. If a cron event takes longer than 60 seconds and a
* second request comes in, the second wp_cron call would normally try to run
* the same events again. The lock timeout prevents that. Default is 60 in
* recent WordPress versions; setting it explicitly is documentation.
*/
define( 'WP_CRON_LOCK_TIMEOUT', 60 );
// ============================================================
// Debug and error handling
// ============================================================
/**
* Enable debug logging on, debug display off.
*
* WP_DEBUG=true tells WordPress to surface errors and warnings.
* WP_DEBUG_LOG=true writes them to wp-content/debug.log.
* WP_DEBUG_DISPLAY=false suppresses the output in the page, so visitors don't
* see "Notice: Undefined index ..." (which leaks information about your code
* structure and is sometimes useful for attackers fingerprinting plugin
* versions).
*
* In production, you want errors logged where you can review them but never
* shown to visitors. SCRIPT_DEBUG and SAVEQUERIES stay false in production;
* they expand log volume and add overhead.
*/
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false );
@ini_set( 'display_errors', '0' );
define( 'SCRIPT_DEBUG', false );
define( 'SAVEQUERIES', false );
/**
* Custom path for the debug log. The default location (wp-content/debug.log)
* is web-accessible if your web server is misconfigured. Putting it outside
* the document root, in a path the web server can write but not serve, removes
* that risk.
*
* Adjust the path to match your server layout. Make sure the directory is
* writable by the web user (chmod 750, chown www-data:www-data).
*/
define( 'WP_DEBUG_LOG', '/var/log/wordpress/debug.log' );
// ============================================================
// Content limits
// ============================================================
/**
* Limit the number of post revisions stored per post.
*
* Default is unlimited. On a site that gets edited often, this bloats the
* database significantly. A reasonable cap is 10 revisions per post; that's
* enough to roll back recent edits but not enough to balloon storage.
*
* Set to false to disable revisions entirely, or any integer for a cap.
*/
define( 'WP_POST_REVISIONS', 10 );
/**
* Auto-save interval in seconds. Default 60 (one save per minute). On a slow
* MySQL or shared host, 60 seconds is too aggressive and contributes to
* Database is unable to keep up errors. 120 or 180 is gentler.
*/
define( 'AUTOSAVE_INTERVAL', 120 );
/**
* Trash retention. Default 30 days. Shorter is better for security (deleted
* content stays gone) but means a finger-slipped delete is unrecoverable.
* 7 days is a reasonable compromise.
*/
define( 'EMPTY_TRASH_DAYS', 7 );
// ============================================================
// Memory and limits
// ============================================================
/**
* PHP memory limit for the public site. 256M is enough for almost every site.
* Increase if a specific plugin (page builders, large image processing, bulk
* imports) needs more, but treat any limit above 512M as a red flag worth
* profiling. See https://techearl.com/how-to-increase-php-memory-limit for the
* cases where this matters and how to diagnose memory pressure.
*/
define( 'WP_MEMORY_LIMIT', '256M' );
/**
* Separate limit for the admin area. Bulk operations (plugin updates, theme
* customization, large media uploads) sometimes need more memory than the
* public site. 384M for admin is reasonable.
*/
define( 'WP_MAX_MEMORY_LIMIT', '384M' );
// ============================================================
// Multisite (only if applicable)
// ============================================================
/**
* Uncomment if this is a multisite install. Leave commented for single sites.
*
* The full multisite block requires several more constants; see
* https://wordpress.org/documentation/article/create-a-network/
*/
// define( 'WP_ALLOW_MULTISITE', true );
// define( 'MULTISITE', true );
// define( 'SUBDOMAIN_INSTALL', false );
// define( 'DOMAIN_CURRENT_SITE', 'yoursite.com' );
// define( 'PATH_CURRENT_SITE', '/' );
// define( 'SITE_ID_CURRENT_SITE', 1 );
// define( 'BLOG_ID_CURRENT_SITE', 1 );
// ============================================================
// End of custom settings
// ============================================================
/** Absolute path to the WordPress directory. */
if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' );
}
/** Sets up WordPress vars and included files. */
require_once ABSPATH . 'wp-settings.php';File permissions for wp-config.php itself
After applying the template, set the file's permissions so the web server can read it but nothing else on the system can. Run as root or via sudo:
# Owner = the web user; group = the web group; permissions = 600
sudo chown www-data:www-data /var/www/wordpress/wp-config.php
sudo chmod 600 /var/www/wordpress/wp-config.php600 means the file is readable and writable only by the owner (www-data). Other users on the server (and any non-www-data PHP process, if you have per-site PHP-FPM pools, see Cross-Site Contamination) cannot read the file at all. WordPress runs as www-data so it reads the file normally; an attacker who compromises a different site on the same server, running as a different user, cannot read your DB password.
Some hosting providers and chmod 600 setups break legitimate scenarios (cPanel's "PHP Selector" sometimes needs different permissions; certain hosts run PHP as a different user than the file owner). If 600 breaks the site, try 640 (owner read+write, group read), confirming the group is the web server's group. Don't drop below 644; broader read permissions defeat the purpose.
What's NOT in the template (and why)
Several directives appear in other "hardened wp-config" templates but I leave out:
define( 'WP_HTTP_BLOCK_EXTERNAL', true ) blocks WordPress from making any outbound HTTP request. It's intended to stop malware from phoning home, but it also breaks: plugin updates, theme updates, WordPress core update checks, Gravatar avatars, embed previews (oEmbed), Akismet, and most analytics. The legitimate use cases for blocking outbound traffic are far narrower than the breakage. Set this only on an air-gapped install where outbound is genuinely undesirable.
define( 'CONCATENATE_SCRIPTS', false ) is sometimes recommended because the concatenated wp-admin/load-scripts.php endpoint has historically had performance issues. The performance issue is real but minor on modern hosts; concatenation reduces request count significantly, which matters more for Core Web Vitals. Leave at the default.
define( 'COOKIE_DOMAIN', '...' ) is occasionally needed for multisite with custom domain mapping. For single sites, leaving it unset is correct; WordPress derives the right value from the request.
define( 'DISALLOW_UNFILTERED_HTML', true ) removes the unfiltered_html capability from admins and editors. It prevents stored-XSS via post content but also prevents legitimate use cases (embedding YouTube via <iframe>, custom HTML widgets). Worth turning on if your editor team doesn't legitimately need raw HTML; leave off if they do.
define( 'FORCE_SSL_LOGIN', true ) is the older directive that FORCE_SSL_ADMIN superseded as of WordPress 4.0. Setting both is redundant; setting only FORCE_SSL_ADMIN is correct.
Common mistakes
The patterns I see in cleanups where the wp-config hardening was attempted but missed something important:
Leaving placeholder salts. The WordPress installer generates random salts when you install through the web installer, but only because of a specific code path that triggers the salt API. If you manually copied wp-config-sample.php to wp-config.php and filled in the credentials but left the salts as 'put your unique phrase here', every site cookie is signed with that public phrase. Anyone can forge a valid admin cookie. Generate fresh salts immediately.
Setting DISALLOW_FILE_EDIT to true but allowing DISALLOW_FILE_MODS to stay false. This is the most common configuration and it's a reasonable middle ground: admins can't edit code through the UI, but they can still install legitimate plugins through the admin. The harder default (both true) requires every code change to come through deployment, which most teams find too restrictive.
Forcing HTTPS without a valid certificate. FORCE_SSL_ADMIN = true on a site without working HTTPS locks admins out completely. Confirm Let's Encrypt or your cert provider is renewing before turning this on.
Setting WP_DEBUG_LOG true without setting WP_DEBUG_DISPLAY false. The log gets created but errors are also shown in the page output, including in production. Errors visible to visitors leak path info, plugin versions, sometimes SQL fragments. Both flags have to be set together.
Storing the debug log under wp-content/. The default. If the web server is misconfigured (Apache without <Files debug.log> blocking, Nginx without an explicit deny), the file becomes web-accessible. Move the log outside the document root with WP_DEBUG_LOG = '/var/log/wordpress/debug.log' or similar.
Changing $table_prefix after install. Will break the site immediately; WordPress queries the wp_ prefixed tables and they don't exist. Change the prefix only by exporting the database, search-replacing every table name in the export, importing the renamed tables, then updating $table_prefix. Or use the WP-CLI command wp config set table_prefix newprefix_ && wp db rename-table-prefix wp_ newprefix_.
Setting DB_USER to a user with ALL PRIVILEGES. The user only needs the specific privileges to run WordPress. Granting ALL includes FILE, PROCESS, SUPER, RELOAD, SHUTDOWN, and others that a SQL-injected query can abuse for far more than reading your data. The correct grant is the minimal set:
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER,
CREATE TEMPORARY TABLES, LOCK TABLES, REFERENCES
ON `your_database`.* TO 'your_user'@'localhost';
FLUSH PRIVILEGES;Forgetting to chmod 600 the file. All the hardening above is in the file. If the file is world-readable (644), anything on the server, including other websites' PHP processes on shared hosting, can read your DB password.
After applying the template
A few follow-ups that make the hardening hold:
- Generate fresh salts. Visit https://api.wordpress.org/secret-key/1.1/salt/ and replace the 8
define(...)lines with the output. - Tighten the MySQL grant to the minimal privileges shown above.
- Set the file permissions:
chmod 600 wp-config.php && chown www-data:www-data wp-config.php. - Test the site loads, the admin works, plugins update, and HTTPS redirects correctly.
- Disable WP-Cron's page-load trigger (the directive above) and add a real system cron line:
*/5 * * * * curl -s https://yoursite.com/wp-cron.php?doing_wp_cron > /dev/nullin the web user's crontab. - Set up server-side FIM that monitors
wp-config.phpfor change (see the FIM article). The hardened file should never change without a deliberate action.
The hardening reduces the blast radius of every other category of compromise. A stolen admin password without FORCE_SSL_ADMIN = true is a session-hijack; with it, the cookie was never exposed. A plugin CVE without DISALLOW_FILE_EDIT = true lets the attacker write PHP through the admin UI; with it, the same compromise can't write through that path. Each directive is a single line that closes a specific attack technique.
Frequently asked questions
See also
- How to Remove WordPress Malware: The Practitioner's Playbook: the broader cleanup methodology. wp-config hardening is what reduces the cost of every future cleanup.
- Why WordPress Malware Keeps Coming Back: Persistence Mechanisms: the article that documents the specific wp-config injection patterns hardening defends against.
- How to Remove gsyndication.com Malware from WordPress: a specific 2020s family that exemplifies the wp-config attack surface.
- WordPress File Integrity Monitoring That Can't Be Disabled: the monitoring layer that catches unauthorized changes to your hardened wp-config.
- Off-Server WordPress Backups: 3-2-1 with Verification: the recovery layer. Hardened wp-config makes cleanups cheaper; backups make recoveries possible.
- How to Change a WordPress Password: the credential rotation step that pairs with salt rotation in this template.
- How to Increase PHP Memory Limit: the related performance tuning, with the diagnostic process for when 256M isn't enough.
External references: the WordPress Hardening guide is the official baseline. WordPress's Editing wp-config.php documentation is the canonical reference for every constant the file accepts.





