Bali, Indonesia
Insights

Binding MySQL to 127.0.0.1 Doesn't Protect It If phpMyAdmin Is Public

October 24, 2026
0:00
A lot of site owners set bind-address = 127.0.0.1 in the MySQL config, see that port 3306 is closed from the outside, and treat the database as safe from brute-force bots. The first half of that is true. MySQL really is no longer listening on a public interface. But if phpMyAdmin or Adminer is reachable on a public URL, anyone on the internet can type a username and password into a web form. The tool then opens the database connection from the server itself, so MySQL sees every attempt as coming from localhost. This post is written as a post-mortem of a pattern that shows up often on VPS and shared hosting. It is a composite, not a specific incident, so there are no timestamps or customer details. The phases still run in the order you would see them in a real investigation.
The first signs are usually vague. Pages get slow. PHP workers stay busy longer than normal. MySQL connection counts climb, and the application may occasionally throw Too many connections. CPU load rises with no matching rise in real visitors. The owner's first reaction is reasonable: the database can't be the target, because it only listens on 127.0.0.1 and the firewall blocks 3306 anyway. Support staff often hear the same thing. That belief sends the investigation toward the web application, the caching layer, or SSH, and away from the actual entry point.
The sensible first step is to confirm the network side, because assumptions about bind settings are wrong surprisingly often. Configs get overridden by a file in a conf.d directory, or a panel rewrites them. After that, look at who is hitting web paths that belong to database tools, and whether any such tools are sitting in web roots.
BASH
# 1. Confirm what MySQL/MariaDB actually listens on (read-only)
sudo ss -tlnp | grep -E ':3306|mysqld|mariadbd'

# 2. Count POST requests to common DB tool paths, grouped by client IP
#    Log path depends on web server and control panel
sudo grep -iE 'phpmyadmin|/pma|adminer' /var/log/nginx/access.log \
  | grep -F 'POST /' \
  | awk '{print $1}' | sort | uniq -c | sort -rn | head -20

# 3. Look for stray Adminer files in web roots (adjust the path)
sudo find /var/www -iname 'adminer*.php' 2>/dev/null
In this pattern, the first command shows MySQL bound to 127.0.0.1:3306 as intended, so the network check passes. SSH logs show the usual background noise of password guessing. That is worth handling on its own, as covered in Hardening Linux Web Servers Against SSH Brute Force and Zero-Day Exploits, but it doesn't explain the database load. The confusing clue comes from MySQL itself. If failed authentication logging is enabled (it depends on log_error_verbosity in MySQL or log_warnings in MariaDB), the error log fills with Access denied for user lines. Every one of them names localhost as the client host. It looks like something on the server is guessing passwords, which sends people hunting for local malware.
The access log query is where it clicks. Instead of a few POSTs from the admin's own IP, there is a steady stream of POST requests to paths like /phpmyadmin/index.php, /pma/, or /adminer.php from many different addresses. Most of them never load a page normally first. They go straight to the login form. The path from bot to database looks like this:
  1. A bot scans common URLs for database tools and finds a login page that answers.
  2. It submits username and password guesses to that page over normal HTTP or HTTPS, which the firewall allows because the web server has to be public.
  3. phpMyAdmin or Adminer, running as PHP on the same machine, takes those credentials and connects to MySQL through the local socket or 127.0.0.1.
  4. MySQL sees a login from localhost, checks the password, and rejects or accepts it.
bind-address only decides which network interfaces MySQL accepts TCP connections on. It has no say in which PHP script on the same box is allowed to connect. From MySQL's point of view nothing unusual happened. A local client asked to log in, which is exactly what the website does all day. This also explains the resource drain. Each guess costs a PHP worker, a MySQL authentication, and often a TLS handshake. It explains why per-IP rate limits barely help, too. Credential sweeps of this kind are commonly run from botnets made of other compromised Linux servers, so the guesses arrive spread across many addresses. The worst part is what accounts are exposed. Admins often create 'root'@'localhost' or other powerful users on the logic that localhost-only means safe. A public database tool turns that logic around. The localhost accounts are exactly the ones an outside visitor can now try.
The fastest safe move is to stop the public internet from reaching the tool at all. Renaming the URL to something obscure reduces scanner noise, but it isn't access control and shouldn't be the only change. An IP allowlist at the web server is better, because the request is refused before PHP or MySQL do any work. On Nginx, it typically looks like the block below. The comments carry the important part. Nginx picks regex locations such as \.php$ over plain prefix matches, so an allow/deny rule on a plain prefix block can be silently skipped for exactly the PHP requests you meant to block.
NGINX
# Restrict phpMyAdmin to a trusted address (203.0.113.10 is a documentation IP)
location ^~ /phpmyadmin/ {
    allow 203.0.113.10;
    deny all;

    # Keep PHP handling nested here. With ^~, Nginx skips regex
    # locations defined elsewhere, and the nested block inherits
    # the allow/deny rules above.
    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $request_filename;
        # Socket path depends on distro and PHP version
        fastcgi_pass unix:/run/php/php-fpm.sock;
    }
}
Run nginx -t before reloading. Then test from an address outside the allowlist and confirm you get a 403 on both the directory and a .php URL, not just one of them. On Apache 2.4, the equivalent is a Require ip rule in the Directory or Location section that serves phpMyAdmin. Where that section lives depends on whether phpMyAdmin came from a distro package, a panel, or a manual install. HTTP basic auth on top of the allowlist adds a second layer that bots don't get past by guessing MySQL passwords. If the investigation shows any POST from an unknown IP followed by normal browsing inside the tool, assume a login succeeded. Rotate the passwords of every database user, starting with the most privileged one, update the application configs that use them, and review the database for unexpected users or changes. Any Adminer file found by the find command that nobody actively uses should be deleted. These single-file tools are often uploaded for a quick fix and then forgotten in a web root, where they stay reachable for as long as the site exists.
The allowlist is a patch. The lasting fix is to make sure there is no route from a public web page to a database login at all. For most people who manage their own VPS, the cleanest option is an SSH tunnel. phpMyAdmin comes off the public vhost, and a desktop client or the command line reaches MySQL through SSH, which you are already hardening anyway:
BASH
# Forward local port 3307 to MySQL on the server's loopback interface
# -N: don't open a remote shell, just hold the tunnel
ssh -N -L 3307:127.0.0.1:3306 [email protected]

# In another terminal (or a GUI client), connect through the tunnel
mysql -h 127.0.0.1 -P 3307 -u dbuser -p
With this setup, MySQL stays bound to 127.0.0.1, and the only way to get a localhost connection is to already hold a valid SSH login. That is the security property people thought bind-address gave them in the first place. If a team really needs a web UI, put it on a vhost that listens only on localhost or a VPN interface, and reach it through the same tunnel or the VPN. On servers with a control panel, the bundled phpMyAdmin is often opened through the panel's own login. It is still worth checking for yourself whether it also answers on a direct public path, because that differs between panels and versions. The last layer is limiting what a successful guess can do. Give each site its own database user with privileges only on its own database. Avoid using root for day-to-day work, and in phpMyAdmin set cfg['Servers'][i]['AllowRoot'] = false; so root can't log in through the web UI even if the page is reachable. Use long, unique passwords for any account a web tool can reach. None of this replaces closing the path, but it turns a full compromise into a contained one. The practical takeaway: bind-address protects the MySQL port, not MySQL logins. Whenever a server has phpMyAdmin, Adminer, or any web-based database tool, ask one question: can a stranger on the internet load its login page? If the answer is yes, the database is effectively public, whatever the MySQL config says. Check the access logs, restrict or remove the tool, and move admin access behind SSH.