Where to put your WordPress database password

Card reading Where to put your database password, over the line define DB_PASSWORD set to an empty string

Hiroshi Tokumaru made a point about wp-config.php back in 2015 that still hasn't been answered. Blocking the file with .htaccess does almost nothing, because a .php file never shows you its source anyway. And if another script on the box has a traversal bug, the block won't help - PHP can still read the file. It has to be able to. That's the whole problem with the database password, and ten years later the default is still plaintext.

The short version: on a VPS with a local database, use socket authentication - it is the only option here where no password exists to leak, and I tested it end to end below. On cloud infrastructure, use IAM tokens. On shared hosting you can have neither, so move the credentials to an include file outside the docroot, deny the backup-file patterns, and restrict the database user by host. Everything else on this list moves the same secret somewhere new.

In this post:

Why the password has to be readable

WordPress connects to MySQL on every request PHP actually handles. There's no daemon holding an open handle, no credential broker, no startup phase where a human types a passphrase. Each request loads wp-config.php, reads DB_PASSWORD, and hands it to the MySQL driver as-is.

Otto42 put it plainly on the support forums years ago: "WordPress doesn't encrypt that password, and it doesn't have any means to decrypt it. It sends that password to the MySQL server, as-is."

So any scheme where PHP decrypts the password needs the decryption key on the same box, readable by the same process. The secret has moved and it still exists. Snicco's secret management guide calls this turtles all the way down, and they're right.

There are exactly two escapes. Either the database stops asking for a password and checks who you are some other way, or the password gets minted fresh for each connection from an identity the host already has. Everything else on this list is relocation.

The eight ways it leaks

I graded every method against these. A grade only means something if you say what it's a grade against.

One disclosure before the grades, because it changes how much weight they carry: I ran the socket-auth method end to end, which is method 12. The other fifteen I researched rather than tested, from vendor documentation, core tickets and the handful of people who have written them up. Where I'm reasoning rather than reporting a measurement, I say so.

  1. Served as text. PHP handler breaks, or a sibling file like wp-config.php.bak, wp-config.php~ or .wp-config.php.swp gets served raw. Feross Aboukhadijeh measured this in 2011 and found 0.77% of CMS sites exposing config files. Scanners still enumerate those exact patterns.
  2. Committed to git. Public repo, a CI artifact, an accidental push.
  3. Backups and dumps. Backup plugins bundle wp-config.php by default. Migration plugins leave copies in the webroot.
  4. Another tenant. What changes is whether you can do anything about it. With a separate user per site, a world-readable config is the leak and permissions fix it. With one shared PHP user, nothing fixes it.
  5. Arbitrary file read. LFI, path traversal, php://filter/convert.base64-encode. Reads anything the PHP user can read, docroot or not.
  6. Debug output. phpinfo(), a $_SERVER dump, an error reporter shipping context to a third party, a plugin phoning environment home.
  7. RCE as the PHP user. Everything that process can read is gone.
  8. Root or host compromise. Everything loses.

Grades run 1 to 5. One is cosmetic. Two closes one real vector. Three closes several but adds a leak surface of its own, or needs root. Four means no long-lived usable secret sits on the filesystem. Five would mean the secret never exists and code running as the application can't reach the database either.

Nothing on this list is a 5, because on a single host the application process has to be able to reach the database by definition. The best available is 4.5.

The matrix: which method closes which leak

Most hardening guides give you a flat list of tips without saying which leak each tip closes. Snicco's guide is the exception I found, and it covers secret storage generally rather than this credential.

MethodServed as textGitBackupsNeighbourFile readDebug dumpRCE as PHP
Plaintext in wp-config (default)nonononononono
wp-config above docrootyesnopartlynononono
chmod 440, separate ownernononoyes*nonono
Deny rules for backup patternsyesnononononono
Separate include outside docrootyesyesmostlynonoyesno
Environment variablesyesyesyespartlynonono
.env file (Bedrock)riskyyespartlynononono
Env var holding a file pathyesyesyesyesnopath onlyno
systemd LoadCredentialyesyesyesyesnoyesno
TLS client certificateyesyesyesyesnoyesno
Socket authyesyesyesyesyesyesno
Cloud IAM tokensyesyesyesyesyesyesno
Secrets manager + drop-inyesyesyesyescached copyyesno
Encrypting wp-configkey-dependentkey-dependentkey-dependentnononono

"Yes" means the leak no longer gives up a usable credential. *Permissions only stop a neighbour when PHP runs as its own user per site. Under shared mod_php every site's PHP reads as the same user, so the permissions do nothing except break your site if you set them wrong.

Two limits on the debug column. It means environment-style dumps - phpinfo(), $_SERVER, a crash reporter's context block. In every row except socket auth the password is still a defined constant and a property on $wpdb while the request runs, so a reporter that serialises constants or an exception trace can still catch it. And "partly" on the environment row is there because I measured it: PHP-FPM ships pool.d/*.conf as 0644 root:root, so any local user can read a password you put in an env[] directive, and a systemd Environment= value is visible to anyone who can run systemctl show.

Look down the RCE column. Thirteen of fourteen rows say no.

Methods 1-4: hiding the file

1. Plaintext in wp-config.php

Ease: everyone, everywhere. It's what the installer writes.

Safety: 1. This survives entirely because Apache and nginx execute .php instead of printing it. Every vector on the list takes it.

2. Move wp-config.php above the docroot

Ease: site owner, no root. Core checks one directory above ABSPATH automatically. Useless if that parent is itself web-accessible, which it is on plenty of shared hosts.

Safety: 2. It closes the broken-handler case, and it moves your editor's stray .bak and ~ files out of the webroot too, which is the version of this that actually fires in the wild. Aaron Adams has the canonical war story: a Plesk bug reset his vhost to "no interpreters" for weeks, and his config was out of reach the whole time.

Ian Dunn argued against it and his objection holds up. If open_basedir is confining PHP to the docroot, moving the config out means widening open_basedir, which can expose more than you protected. And WordPress still needs read access, so a malicious script running as PHP still reads it.

3. File permissions and ownership

Ease: anyone with SSH for the simple version. The useful version - owner different from the PHP user, group set to the FPM pool, mode 440 - needs root or a cooperative host.

Safety: 2, or 3 on per-user FPM. Only ever meaningful against other local users, and only when PHP runs as its own user. The official docs suggest 400, which breaks the site outright if the file owner isn't the PHP user. Plugins that write constants into wp-config.php also break.

4. Deny rules in the web server

Ease: site owner with .htaccess, or whoever owns the nginx config. nginx never reads .htaccess, so there it's a sysadmin job.

Safety: 2. Tokumaru's point stands - denying wp-config.php itself buys you nearly nothing. The value is entirely in denying the patterns: ~, .bak, .sw[op], .save, .old, .sql. That kills the Feross class.

Do not add .zip and .tar.gz to that list without thinking. They are also how WooCommerce delivers paid downloads and how plugin and theme packages get served, so a blanket deny breaks real features. If you want archives blocked, scope the rule to the paths where backups actually land rather than to the extension everywhere.

And a script running as the PHP user can rewrite your .htaccess.

Methods 5-8: moving the secret out of the file

5. A separate include file

wp-config.php keeps the salts and the structure, then does require_once '/home/user/db-creds.php';.

Ease: site owner. Needs somewhere outside the docroot, which cPanel-style hosts give you.

Safety: 3. Now wp-config.php is credential-free, so a .bak of it, a git commit of it, or a plugin that displays it all leak nothing usable. Most backup plugins only grab the webroot, so being outside it drops you from those bundles too. Snicco uses this as their default.

The catch is that the path to the secret lives in wp-config.php, so a file read that gets the config learns exactly where to look next.

6. Environment variables

define('DB_PASSWORD', getenv('DB_PASSWORD')); with the value in the vhost, the FPM pool, or a systemd unit.

Ease: needs control of server config, so VPS and up. Native and trivial in Docker and Kubernetes. Panel users can't.

Safety: 3. Every file-based vector goes empty. It buys that by opening a different door, which I think deserves its own section.

7. A .env file with phpdotenv

Ease: developer. Needs a Composer workflow. Not shared-hosting friendly.

Safety: 3. Same file-read exposure as method 5, and it closes git because gitignoring .env is the entire point of the pattern. One real risk: .env isn't a .php file, so a server that doesn't know about it will happily serve it as text. There was a whole breach wave built on scraping exposed .env files. Keep it above the docroot or deny it explicitly.

8. An environment variable holding a file path

WORDPRESS_DB_PASSWORD_FILE=/run/secrets/db_password. The official WordPress Docker image honours the _FILE suffix on every WORDPRESS_* variable.

Ease: containers and cloud only.

Safety: 3.5. Strictly better than method 6. A phpinfo() page or an error reporter now leaks a path instead of a password. A file read still gets the value, because it's still a file.

The honest problem with environment variables

Bedrock and every Docker guide will tell you environment variables are the secure option. They close the file vectors, which is real. Then they hand you four new ways to leak the same string:

PHP has no clean switch to keep environment out of the superglobals, short of variables_order surgery. There's an open php-src issue asking for one.

There's also a quieter problem with where the value is written. PHP-FPM ships pool.d/*.conf as 0644 root:root, which I checked rather than assumed, so a password in an env[] directive is readable by every local user on the box. A systemd Environment= value is worse, because anyone who can run systemctl show sees it. Tighten those modes or use a LoadCredential-style file instead.

The trap that costs an afternoon: WP-CLI and system cron never see web-server environment variables. Set DB_PASSWORD via SetEnv or an FPM env[] entry and the site works fine while wp db check fails with an access denied error. The web server's environment isn't your shell's environment. You need the value exported in the shell too, or a systemd EnvironmentFile on the cron unit.

One more thing about the Docker path: the official PHP image ships clear_env=no, which means every FPM worker inherits the full container environment whether you listed it in env[] or not.

Methods 9-11: replacing the password with something else

9. systemd LoadCredential

systemd hands the service a credential as a file in $CREDENTIALS_DIRECTORY. It's on ramfs so it never swaps, mode 0400, owned by the service user, and the kernel re-checks permission on each access rather than letting it inherit down the process tree the way environment does. It can be sealed to the host TPM.

Ease: root, systemd 248 or newer.

Safety: 3.5. No environment leak surface at all, nothing in the webroot, nothing in git or backups. A file read as the PHP user still gets it, which is why this sits level with the TLS certificate below and not above it - both are a file the application can read.

The wrinkle, and I could not find a WordPress write-up of it: php-fpm's master process runs as root while the workers run as the pool user, so getting a per-pool credential into the worker is awkward. There's an open NixOS PR that exists because of it.

10. TLS client certificates

Create the MySQL user with REQUIRE X509 and a certificate becomes part of the credential. Two details matter and most write-ups skip both: REQUIRE X509 adds a certificate requirement on top of whatever password the account has, so the password only disappears if you give the account none. And bare X509 accepts any certificate your CA signed, which on a shared CA means any client at all - binding it to one client needs REQUIRE SUBJECT or ISSUER.

Ease: sysadmin plus developer, and it's the only method here that works against a remote database without a cloud provider.

Safety: 3.5. The plaintext password is gone, and the certificate can be revoked and rotated on its own. You can verify the server too, though you could always do that with password auth - it just tends to get turned on at the same time.

But core can't do it. wpdb never calls mysqli_ssl_set(), and there are no key or cert constants in core - Trac ticket 28625 has been asking since 2014. You need a db.php drop-in. And wp db in WP-CLI uses the constants directly without loading your drop-in, so CLI breaks.

The certificate and key are still files that the PHP user reads, so a file-read bug steals them exactly as easily as it stole a password.

11. A secrets manager with a db.php drop-in

wp-config.php holds an identifier, and a wpdb subclass fetches the real credentials at connect time and caches them.

Ease: sysadmin plus developer, and a real ops footprint. Craig Andrews wrote the canonical implementation for AWS Secrets Manager in 2019 and it's still the reference.

Safety: 4. Nothing usable at rest, every fetch audited, rotation automatic. With Vault's dynamic secrets you get a per-lease database user that gets revoked.

The secret-zero is now whatever authenticates you to the manager. Snicco is honest about this and so should everyone be. What you bought is an audit trail and rotation.

Two gotchas from Andrews' writeup: the fetch happens inside wp-config.php, before any WordPress API exists, so no wp_remote_get on that path. And WP-CLI needs a separate --require file, which re-fetches every time.

12. Socket auth: the one with no password at all

This is the method I care about most, and the one I see discussed least in WordPress circles.

MySQL's auth_socket plugin, or unix_socket on MariaDB, authenticates the operating-system user ID of whatever process connects over the Unix socket. The kernel reports it via SO_PEERCRED, which MySQL's manual notes is available "only on systems that support the SO_PEERCRED option, such as Linux". The database doesn't ask for a password because it already knows who you are, and the OS can't be talked out of it.

Which means DB_PASSWORD is an empty string. There is no password anywhere - hashed, encrypted or otherwise - for any of those eight vectors to capture. The authentication_string column in mysql.user is literally empty.

MariaDB's own documentation makes the pitch better than I can: "no password that can be accidentally exposed by user accident, poor security on backups, or poor security on passwords in configuration files."

Ease: needs root, and needs the database on the same machine. Impossible on shared hosting, pointless for a managed remote database. VPS and dedicated only.

Safety: 4.5. Every credential-theft vector comes up empty. It stops short of 5 because code running as the PHP user can still open the database through that socket. There's nothing to steal, but there's nothing stopping it either. That's the row where the matrix says no.

So socket auth makes a stolen wp-config.php worthless. It does nothing about an attacker already running your code, and it is worth being clear that those are different problems.

The walkthrough, tested

Tested on MariaDB 11.8.9 with WordPress 7.1.3, PHP 8.3 and php-fpm behind nginx. The gotchas below are the ones that only appear when you run it.

Create the OS user your PHP pool will run as, then the matching database user:

useradd -m -s /usr/sbin/nologin wp_site1

mysql -uroot -e "
CREATE DATABASE wptest;
CREATE USER 'wp_site1'@'localhost' IDENTIFIED VIA unix_socket;
GRANT ALL ON wptest.* TO 'wp_site1'@'localhost';
FLUSH PRIVILEGES;"

On MySQL rather than MariaDB the syntax is IDENTIFIED WITH auth_socket. Check your socket path, because it is not the same everywhere:

mysql -uroot -e "SHOW VARIABLES LIKE 'socket';"
# socket  /run/mysqld/mysqld.sock

Then the config. Two lines matter:

define( 'DB_NAME', 'wptest' );
define( 'DB_USER', 'wp_site1' );
define( 'DB_PASSWORD', '' );
define( 'DB_HOST', 'localhost:/run/mysqld/mysqld.sock' );

That DB_HOST form is the part people get wrong. wpdb splits on the colon, and a value starting with a slash after it is treated as a socket path rather than a port. Stanislav Khromov documented the quirk years ago.

Then the pool, which is the step the whole method rests on. One pool per site, each running as its own user:

[wp_site1]
user = wp_site1
group = wp_site1
listen = /run/php/php-fpm-wp_site1.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

Delete the default www.conf pool or it keeps serving as www-data underneath you. Check the workers actually dropped:

ps -eo user,comm,args | grep php-fpm
# root      php-fpm: master process
# wp_site1  php-fpm: pool wp_site1
# wp_site2  php-fpm: pool wp_site2

The master stays root. That is also the reason systemd credentials are awkward with FPM, visible right there in the process list.

Both of my test sites came up HTTP 200 through nginx with their own titles out of their own databases, with an empty password in each config.

The pair that matters is what happens across sites. I pointed site1's user at site2's database and got a refusal, and the same in reverse:

as wp_site1 -> DB user wp_site1: CONNECTED
as wp_site1 -> DB user wp_site2: DENIED
as wp_site2 -> DB user wp_site1: DENIED

The gotchas, all of which I hit.

WP-CLI has to run as that user. This works:

sudo -u wp_site1 wp db check --path=/var/www/wptest
# Success: Database checked.

And this does not:

wp db check --path=/var/www/wptest --allow-root
# mariadb-check: Got error: 1698: Access denied for user 'wp_site1'@'localhost'

Error 1698 is what socket auth refusing you looks like. If you have cron jobs calling WP-CLI, every one of them needs sudo -u, and --allow-root will never work again.

Root can't impersonate the user either. I expected root to get in and it doesn't - MySQL matches the uid strictly, so root connecting as wp_site1 gets the same 1698. Root can of course su to the user first, so this isn't a boundary against root. It does mean your own admin scripts need the same sudo -u treatment.

phpMyAdmin and Adminer stop working in any normal setup, because they run as their own user and have no way to present yours. You need a second, password-authenticated user for tooling, and now you have a password again - so give that one read-only grants and a host restriction.

Per-site uid hygiene is the whole game. One PHP user maps to one database user. If every site on the box runs as www-data, then every site can open every other site's database, and you have gained nothing over passwords. This only works with per-site FPM pools running under distinct users.

I'd also confirm what the attacker gets. I ran a script as the PHP user, with no credentials at all, and it read wp_users straight out of the database. That's the residual risk, demonstrated rather than assumed. Socket auth removes the credential while leaving the access.

Methods 13-16: cloud and managed hosting

13. Cloud IAM database authentication

RDS IAM, Cloud SQL IAM, Azure Entra. The "password" is a short-lived signed token, generated locally from the instance's own role rather than fetched from anywhere.

Ease: developer, cloud only. PHP has no first-party library the way Java does, so it's a db.php drop-in and the AWS SDK.

Safety: 4. No static credential exists. The secret-zero is the instance role, reachable from that instance's metadata service. The fifteen minutes is RDS specific; Cloud SQL and Entra hand out OAuth access tokens that live about an hour.

I'd be careful with the audit story you sometimes see attached to this. An RDS token is a SigV4 string signed locally, so minting one makes no API call and there is no cloud-side log of the connection. Connection auditing is a database feature, and it works the same under socket auth. What IAM really buys you is that the thing on disk expires.

It also doesn't stop code running on the box. An attacker there reads the role from the metadata service and mints fresh tokens for as long as the role lives.

14. A database proxy holding the credentials

Safety: 3. The pitch is that ProxySQL holds the real credentials so your app doesn't. The pitch doesn't work yet: the frontend and backend flags exist in the schema but aren't honoured, which is still open as issue 4111. The same credential authenticates the app to the proxy and the proxy to the database. What you can do is point WordPress at a localhost socket with a dummy or empty frontend password. Read that carefully before building it: an empty frontend password means any local process can open that listener and reach the database through the proxy. On a single-tenant box that may be fine. On a shared box it hands every neighbour every database the proxy knows about, which is why this is a 3.

15. Host-injected configuration

This varies more than anything else on the list, so it's worth knowing where your host sits.

Pantheon injects credentials through $_ENV and rotates them, and tells you never to paste them into wp-config.php because they'll change. Platform.sh does the same through a base64 JSON relationship variable. WordPress VIP predefines the constants and you can't set DB_NAME at all. Altis keeps secrets in Parameter Store backed by an HSM and pulls them into memory at container start, never onto disk. Call those 3.5 to 4.

Kinsta has you copy the credentials out of the dashboard into wp-config.php by hand. WP Engine ships a managed wp-config.php with the password in it and only lets you prepend to it. Those are a 1. Kinsta's docs at least put the database in the same pod, so the credential is worth less to an outsider than it looks - though that is their architecture helping, and it is their claim rather than my measurement.

16. php.ini and auto_prepend_file

Safety: 2.5. mysqli.default_pw is a real INI setting and ini_get() reads it back, so you can keep the password out of every PHP file. Then phpinfo() prints it in the configuration table, which makes it worse than environment variables on the debug axis and better on the file axis.

The auto_prepend_file variant is more interesting: define the constant in a file above the docroot that PHP loads before wp-config.php runs. Snicco suggests it as a way to hide the path of a secrets file. Exotic, but it works anywhere you control PHP's configuration.

Why the new Secrets API can't help

WordPress is getting a Secrets API, though not necessarily in the next release. Jake Spurlock committed it as changeset 64337 on 13 October 2026, described as "Security: Introduce the Secrets API." It gives plugins wp_set_secret() and wp_get_secret() so API keys stop living as plaintext options, and each secret keeps its previous version so credentials can rotate without breaking readers. It's good, and it's overdue.

It cannot hold your database password, and the commit message explains why without meaning to.

Default storage is "encrypted with libsodium and stored in the options table." The options table is in the database. The password is what opens the database. You can't read the secret until you've already used the thing the secret was supposed to protect.

Then the key: "The encryption key never goes in the database: it comes from a WP_SECRETS_KEY constant in wp-config.php, or is derived from the LOGGED_IN_KEY and LOGGED_IN_SALT constants when that is not defined." The key lives in the exact file you were trying to clean out.

And hosts can override storage with a wp-content/secrets.php drop-in, which "loads just before must-use plugins" - long after WordPress has connected to the database.

So all three layers land after the DB connection. This is a good answer for the fifty plugin API keys sitting in your options table in plaintext. It was never going to be an answer for DB_PASSWORD, and I'd rather say that now than watch people wait for 7.2 to fix something it structurally can't reach.

On status: it is committed to trunk. Whether it ships in 7.2 gets decided at Beta 1, not by a commit landing.

Dead ends

Encrypting wp-config.php. Grade 1, and the reasoning is a one-liner: whatever decrypts it has to be readable by the same process, so the key sits next to the lock. Otto42 again: "if an attacker is able to get your wp-config.php, he can get everything he needs to decrypt". ionCube can obfuscate the file, but a loader that can execute it can also get_defined_constants() afterwards.

The hash-derivation trick. Hash an environment variable with some fixed string and use that as the password. The secret is now the hash input instead of the password, which is the same exposure with extra steps, and you can't rotate the database password without changing the scheme.

~/.my.cnf with a [client] section. This one's dead for a specific reason worth knowing: mysqlnd doesn't read MySQL option files at all. The PHP manual marks MYSQLI_READ_DEFAULT_FILE as not supported by mysqlnd. Stock PHP builds can't use the classic trick.

Storing it in the database. Same chicken-and-egg as the Secrets API.

Quorum or two-person decryption. Works fine until the server reboots at 3am and the site stays down waiting for two humans.

What I'd actually do

On a VPS where I control the box and the database is local, socket auth. It's the only configuration on this list where no password exists to leak, which is the actual answer to the problem Tokumaru pointed at. It costs you per-site FPM users, sudo -u on every WP-CLI call, and a second user for phpMyAdmin.

On cloud infrastructure, IAM tokens. You trade the stored credential for an instance identity, and what sits on the disk expires on its own.

On shared hosting, you can't have either. Put the credentials in an include file outside the docroot - assuming your host gives you somewhere that isn't web-accessible, which not all of them do - deny the backup patterns at the web server, and pair it with a host-restricted database user.

That last part does more work than its reputation suggests. A password that only works from the machine that already got compromised is worth a lot less than one that works from anywhere.

Be careful how far you strip the grants, though. SELECT, INSERT, UPDATE and DELETE alone will get you through normal traffic and then fail the next time core updates or a plugin activates, because those need ALTER, CREATE, DROP and INDEX. The official hardening page says the same. Either keep the schema rights and restrict the host, or be ready to grant them temporarily for every update.

This is one of the places where WordPress's age shows, and I've written about that before. Whatever you pick, check what your backup plugin puts in the archive. A zip file sitting in the webroot with your config inside it needs no vulnerability at all.