Guide
WordPress "Access denied for user" database errors — credential rotation, permission grants, and silent breakage
1. Problem
Your WordPress site is gone. Every URL returns the same flat page:
Error establishing a database connection
If you toggle define('WP_DEBUG', true) and reload, the real error spills out:
WordPress database error: [Access denied for user 'wp_user'@'10.0.4.21' (using password: YES)]
Or, in PHP's error log:
mysqli_real_connect(): (HY000/1045): Access denied for user 'wp_user'@'10.0.4.21' (using password: YES)
This is the textbook "wordpress error establishing database connection access denied" scenario. Host and port are correct — TCP works, MySQL is up — but MySQL is rejecting the credentials. This surfaces as a db_access_denied signal from the Logystera WP plugin within seconds.
This is not the same as "connection refused." If mysqld were down or the host were wrong, you'd see SQLSTATE [HY000] [2002] and the MySQL log would contain nothing. Here you see [1045], and MySQL's error log has a clean record of the rejected user. Two failure modes, one identical white page — fixes diverge after the first log line.
2. Impact
A WordPress install that can't authenticate to MySQL is fully offline. The admin, REST API, front-end, scheduled posts, WooCommerce checkout, and cron all fail at wp-load.php. There is no graceful degradation: $wpdb->db_connect() throws and bootstrap halts before the theme is resolved.
For a WooCommerce store, every minute of db_access_denied is failed woocommerce_checkout redirects and the angry email queue once the site recovers — "I was charged but the order doesn't exist". We've seen one customer rack up $11k in disputed Stripe charges during a 38-minute outage when an RDS rotation cron rewrote the master user but the deploy that updates wp-config.php was paused for a Friday freeze.
The quieter cost: WordPress's /wp-content/debug.log is opened after the connection attempt. When mysqli_real_connect() fails, that log file is never opened — your usual "tail debug.log" workflow returns nothing. wp_db_errors_total and wp_state_changes_total from the Logystera plugin keep climbing while the dashboards you'd normally check stay flat.
3. Why It’s Hard to Spot
WordPress papers over this failure mode aggressively. The user-facing output is a single static phrase ("Error establishing a database connection") that hasn't changed since WordPress 2.x — designed not to leak wp-config.php in a 500 response. There's nothing in the HTML to tell you whether the database is down, the host is wrong, or auth was rejected.
Three compounding reasons standard monitoring misses this:
- The web server is healthy. The TCP handshake to MySQL succeeded — only the auth payload was rejected. External health checks see
200from cached pages and500from dynamic ones, which most uptime monitors flag as "intermittent" rather than "down." - The CDN keeps serving the cached homepage at
200long after/wp-adminis broken. By the time/admincomplaints arrive, the cache is also lying about the home page. WP_DEBUG_LOGisn't written during a connection failure. You cantail -f /wp-content/debug.log, watch nothing happen, and conclude PHP isn't running.
Hosting dashboards make it worse. Kinsta, WP Engine, Pantheon, Cloudways all surface "MySQL service: running" as the green check. None check that this site's DB_USER + DB_PASSWORD actually authenticates. If a DBA rotated the password during a maintenance window and the deploy that updates wp-config.php is gated behind a freeze, the dashboard stays green while every WP install on the cluster is dead.
4. Cause
WordPress's wpdb::db_connect() runs early in wp-includes/load.php. It calls mysqli_real_connect() using the four constants from wp-config.php: DB_HOST, DB_NAME, DB_USER, DB_PASSWORD.
If TCP connects but the user/password pair fails MySQL's auth handshake, MySQL returns error 1045 with SQLSTATE 28000, and mysqli sets errno = 1045 with the message Access denied for user 'X'@'IP' (using password: YES). WordPress's wpdb swallows this into Error establishing a database connection, but the underlying mysqli error reaches the PHP error log.
The Logystera WP plugin's database error hook inspects the mysqli error code and message. When it matches 1045 or /access denied for user/i, it emits a db_access_denied signal with the masked user, the connecting IP from the error string, the database name, and a timestamp. The signal flushes to the gateway out-of-band — so it survives even when the DB is the thing that broke. This is distinct from db.connection_failed (host unreachable, daemon down — error 2002). Same blank page, different signal, different fix.
5. Solution
5.1 Diagnose (logs first)
Confirm 1045 on both sides (PHP and MySQL logs), identify which of the five credential-failure paths you're on, and time-correlate with the most recent change window.
1. PHP error log — confirms WordPress's mysqli failed and tells you the auth user and IP.
tail -n 500 /var/log/php-fpm/error.log \
| grep -iE "mysqli_real_connect|Access denied for user"
The line you want looks like this — the 1045 and the 'user'@'IP' are the diagnostic key:
PHP Warning: mysqli_real_connect(): (HY000/1045): Access denied for user 'wp_user'@'10.0.4.21'
(using password: YES) in /var/www/html/wp-includes/class-wpdb.php on line 1987
(using password: YES) matters. If it says NO, wp-config.php is sending an empty password — a deploy mangled the file or a templating engine (Ansible, Bitnami, Trellis) failed to substitute the secret. That stack frame in class-wpdb.php is the bootstrap mysqli call. This is what produces db_access_denied in the Logystera WP plugin.
2. MySQL error log — confirms MySQL saw the connection and rejected the credentials.
grep -iE "access denied" /var/log/mysql/error.log /var/log/mysqld.log 2>/dev/null | tail -n 20
What you see decides the fix:
Access denied for user 'wp_user'@'10.0.4.21' (using password: YES)with expected user, expected IP → password inwp-config.phpis wrong. Likely cause: rotation.Access denied for user 'wp_user'@'10.0.4.99'with an unfamiliar IP → host migrated, MySQL grant doesn't include the new IP.db_access_deniedcarries ahostfield that doesn't match any known app server.Access denied for user ''@'10.0.4.21'with an empty user →wp-config.phpwas rewritten andDB_USERis blank or commented out.Access denied for user 'wordpress'@'10.0.4.21'when you expectedwp_user→ WordPress's auto-installer ran a second time and resetDB_USERto default.db_access_deniedwith the default user, plus a freshwp_environment_changes_totalincrement.
3. Test from WordPress's perspective with WP-CLI.
This is the most useful single command for this guide:
# WP-CLI bypasses the web request and reads wp-config.php directly:
sudo -u www-data wp --path=/var/www/html db check 2>&1
sudo -u www-data wp --path=/var/www/html db query "SELECT 1;" 2>&1
If wp db query returns 1, the credentials work — the failure is environmental (an LB egressing through a different IP that doesn't have a grant). If it errors with ERROR 1045, your wp-config.php password is wrong for the user/host pair.
4. Confirm wp-config.php is what you think it is, and time-correlate.
# When was wp-config.php last touched, and is there a fresh sibling?
stat -c '%y %n' /var/www/html/wp-config.php
ls -la /var/www/html/wp-config*.php
# Was a deploy or secrets rotation logged?
journalctl --since "2 hours ago" | grep -iE "rotate|secret|wp-config|deploy"
If you find both wp-config.php and a fresh wp-config-sample.php with timestamps inside the failure window, WordPress's installer ran — path (d) from §5.2, with a data-corruption risk. If the first 1045 in PHP's error log lines up with a 02:00 cron that runs aws secretsmanager update-secret --rotate, rotation completed in MySQL but the deploy that updates wp-config.php didn't run. That correlation turns "the site is down" into "the site has been emitting db_access_denied since 02:14 UTC, immediately after the secrets-manager rotation cron at 02:13 UTC."
5.2 Root Causes
Each cause maps to a specific WordPress-side fix and a specific signal. Prioritized by frequency.
- DBA rotated the WP database password — the most common cause. A managed DB (RDS, Aurora, Cloud SQL) ran a scheduled rotation, or a DBA reset the password during a maintenance window, but the deploy that updates
wp-config.phpdidn't run or failed silently. Producesdb_access_deniedwith SQLSTATE[28000] [1045], the expected user, and a clearAccess deniedline in MySQL's error log. - Host migrated, no GRANT for the new app-server IP — the site moved to a new app server (autoscaling, blue/green cutover, container restart on a different subnet), but the MySQL user is granted as
'wp_user'@'10.0.4.21'(the old IP), not'wp_user'@'10.0.4.99'(the new one). Producesdb_access_deniedwhere the host in the error message is a fresh IP that doesn't appear inmysql.user. wp-config.phpwas edited and the password got broken — a developer hand-editedwp-config.phpand introduced a stray quote, a$that wasn't escaped, or a missing trailing single-quote. PHP parses, mysqli sends a corrupted password, MySQL rejects with1045. Producesdb_access_deniedand a fresh mtime onwp-config.php.- WordPress's auto-installer ran a second time and reset
DB_USER— a deploy that ships an empty document root (Docker image with nowp-config.phpbaked in, orrsync --deletewith bad source) caused/wp-admin/install.phpto render the installer. A health-check bot clicked through, and WordPress wrote a newwp-config.phpwith default values. Producesdb_access_deniedwithDB_USERset to the defaultwordpressrather than your real user, and a freshwp_environment_changes_totalevent. - RDS/Aurora endpoint changed and IAM auth wasn't re-granted — sites using RDS IAM auth with rotating tokens (or Aurora cluster failover where the writer endpoint flipped) re-resolve
DB_HOSTto a new instance. If that instance'smysql.userdoesn't have a grant for the WordPress IAM user, every connection fails. Producesdb_access_deniedimmediately after awp_environment_changes_totalevent.
5.3 Fix
Match the fix to what db_access_denied told you, not to a guess.
Cause A — Password rotated, wp-config.php stale: update both sides — the password in MySQL and the value in wp-config.php (or your secrets-injection mechanism: AWS Secrets Manager, Vault, Kubernetes Secret). Bounce PHP-FPM only after both sides match.
ALTER USER 'wp_user'@'%' IDENTIFIED BY 'new_password_from_secrets_manager';
FLUSH PRIVILEGES;
sudo -u www-data wp --path=/var/www/html config set DB_PASSWORD 'new_password_from_secrets_manager'
sudo -u www-data wp --path=/var/www/html db check
sudo systemctl reload php8.3-fpm
Cause B — Grant host wrong: the host in MySQL's Access denied log line is the smoking gun. Switch the grant to a subnet-scoped pattern that survives autoscaling.
SELECT user, host FROM mysql.user WHERE user = 'wp_user';
CREATE USER 'wp_user'@'10.0.4.%' IDENTIFIED BY '<password>';
GRANT ALL ON wp_database.* TO 'wp_user'@'10.0.4.%';
DROP USER 'wp_user'@'10.0.4.21'; -- the stale grant
FLUSH PRIVILEGES;
Cause C — wp-config.php corrupted: restore from git. Do not hand-edit during the outage — you'll introduce a second typo. Validate with php -l before swapping.
git -C /var/www/html show HEAD:wp-config.php > /tmp/wp-config.php.candidate
php -l /tmp/wp-config.php.candidate
Cause D — Auto-installer ran twice: stop traffic immediately. Restore wp-config.php from the deploy artifact and disable /wp-admin/install.php access at the web-server level. Treat as a data-integrity incident, not just an outage.
echo 'maintenance' > /var/www/html/.maintenance
sudo -u www-data wp --path=/var/www/html option get blogname
sudo -u www-data wp --path=/var/www/html option get siteurl
Cause E — RDS/Aurora endpoint or IAM grant: verify which instance you're connecting to (SELECT @@hostname;), confirm the IAM user has a grant on the new writer, and rotate the IAM auth token. If using RDS Proxy, the proxy itself needs the grant — not the underlying RDS instance.
5.4 Verify
You're looking for two things to hold simultaneously: db_access_denied events stop appearing, and wp_db_errors_total returns to baseline.
# Should be empty for at least 15 minutes under normal traffic:
grep -iE "1045|Access denied" /var/log/php-fpm/error.log | tail -n 5
grep -i "access denied" /var/log/mysql/error.log | tail -n 5
# Should return 1 instantly on three consecutive runs:
for i in 1 2 3; do
sudo -u www-data wp --path=/var/www/html db query "SELECT 1;"
sleep 5
done
Healthy state in Logystera's entity view: zero db_access_denied events for 30 minutes, wp_db_errors_total at or below the site's normal floor, wp_state_changes_total quiet, wp_environment_changes_total quiet.
The baseline matters. A healthy WordPress site emits roughly 0 db_access_denied per day — this signal has no expected steady-state, so any non-zero rate over 5 minutes is anomalous. If you fixed Cause A (password) but db_access_denied still fires 1–2/minute, you're on Cause B as well: a host connecting from an IP without a grant. Go back to MySQL's error log and read the '@'IP' on the freshest line.
If db_access_denied reappears within an hour, you addressed a symptom, not the cause. The most common false fix is rotating the password while a stale RDS Proxy still serves cached credentials — bounce the proxy or wait for its TTL.
6. How to Catch This Early
Fixing it is straightforward once you know the cause. The hard part is knowing it happened at all.
This issue surfaces as db_access_denied.
Everything you just did manually — grep php-fpm/error.log for 1045, separate "wrong password" from "wrong host grant," cross-check wp-config.php mtime against the rotation cron, confirm with wp db query — Logystera does automatically. The WP plugin's database hook emits db_access_denied to the gateway out-of-band the instant mysqli throws — independent of WordPress's debug.log, which never gets written because the request never reaches the logging code.
!Logystera dashboard — db_access_denied over time db_access_denied rate, last 24h — spike at 02:14 UTC, immediately after the nightly RDS password rotation window.
The alert uses the same threshold pattern as the db.connection_failed rule: severity critical, threshold 1 event in 60 seconds, no smoothing. Healthy baseline is zero, so a threshold of 1 has no false positives in practice. The payload includes the mysqli error code (1045), the masked user, the connecting IP, the affected entity, and a timestamp — enough to decide which of the five root causes in §5.2 you have, from the alert body alone.
!Logystera alert — WordPress database access denied Critical alert fires within 60s of the first db_access_denied event, with mysqli error code, masked user, and affected entity.
The fix is simple once you know the problem. The hard part is knowing it happened. Logystera turns this from a 02:30 customer-reported emergency into a 60-second notification with the user, IP, and SQLSTATE that prove it.
7. Related Silent Failures
db.connection_failed(2002) — paired signal for the host-unreachable side of the same blank page. Same symptom, different fix path: MySQL is down, port is wrong, or the firewall blocks.wp_environment_changes_total— fires whenDB_USER,DB_HOST,WP_HOME, orWP_SITEURLchanges between requests. A common precursor todb_access_deniedwhen a deploy rewriteswp-config.phpwith bad values.wp_state_changes_totalwith installer activity — fires when WordPress's auto-installer runswp_install(). Paired withdb_access_denied, treat it as a data-integrity incident — not just an outage.wp_db_errors_totalbackground floor — every WP site has a small steady-state rate of "MySQL has gone away" or transient deadlocks. Knowing your floor makes adb_access_deniedspike obvious.- Cron-driven secret rotation breaking deploys — gate rotation behind a successful
wp db checkhealth probe, not behind a calendar.
See what's actually happening in your WordPress system
Connect your site. Logystera starts monitoring within minutes.