What this check reads
The check queries the grant table directly with SELECT user,host FROM mysql.user WHERE host='%' AND user<>'', which lists every named account that may connect from any address. It matches the host column literally against the single character %, and the user<>'' clause drops rows with a blank user name so that anonymous accounts are counted once, by the Anonymous Users check. One row is the threshold: any non-empty result is a WARN naming each user@host pair, and only an empty result passes.
When it applies
Runs only when MySQL/MariaDB is detected (HAS_MY=1) and reachable as root over the socket (MYOK=1); otherwise SKIP. It audits the grant table, not the listener - a user@'%' account is reported even when bind-address=127.0.0.1, skip_networking is on, or a firewall blocks 3306, because the over-broad grant is the finding. Only the bare % host is matched: partial wildcards such as 10.0.% or %.internal are not reported, so PASS does not mean every host value is specific. Accounts with an empty user name are excluded here (they are the Anonymous Users check's job).
What each result means
| Result | When | What it means |
|---|---|---|
| SKIP | MySQL not installed / not accessible | Nothing was checked. No MySQL was found, or it could not be queried as root, so the grant table was never read. |
| WARN | One or more `user@'%'` accounts | At least one named account can authenticate from any address, and the message lists which ones. |
| PASS | None | No named account carries the bare % host, so every grant this check can see names a specific address. |
Why it matters
MySQL authorisation is on user@host. % makes the password the only control; combined with bind-address=0.0.0.0 and an open port it exposes the account to the internet. OWASP: restrict accounts to allowed hosts. CIS MySQL "Ensure no users have wildcard hostnames".
Why it fails, and when it is wrong
- Docker/Compose
MYSQL_USERcreatesuser@'%'by design; inside a private Compose network that is acceptable only if 3306 is not published. - Applications that connect over TCP to
127.0.0.1needuser@'127.0.0.1'oruser@'localhost'(the latter means Unix socket in MySQL). Many admins pick%to avoid learning this distinction. - Partial wildcards (
10.0.%,%.internal) are not reported; only the bare%. - If
skip_networkingis on orbind-address=127.0.0.1, a%account is unreachable remotely; the check still warns because it audits the grant, not the listener.
How to fix it
List the wildcard accounts
The WARN message already names every offender. If you are working on the server instead, the check's own query gives you the same list.
sudo mysql -N -e "SELECT CONCAT(user,'@',host) FROM mysql.user WHERE host='%' AND user<>''"Rename each account to a specific host
RENAME USER rewrites the host half of the pair in place. The password hash and every grant travel with it, so there is no window in which the account does not exist and nothing has to be re-granted afterwards.
RENAME USER 'app'@'%' TO 'app'@'10.0.0.5'; -- keeps password and grants
-- or for local TCP:
RENAME USER 'app'@'%' TO 'app'@'127.0.0.1';In MySQL localhost means the Unix socket, so an application that dials 127.0.0.1:3306 needs the 127.0.0.1 form rather than localhost.
Verify the application connects
Restart or reconnect the application and confirm it still authenticates from its new host before you move on. When it does not, the cause is almost always the socket-versus-TCP distinction above rather than a lost grant.
Pair the grant with a bound listener
Pair with bind-address and a firewall rule (see the Network Isolation checks). The grant decides who may authenticate; the listener and the firewall decide who can reach port 3306 at all, and this check deliberately audits only the first of the three.
Verify the fix
sudo mysql -N -e "SELECT CONCAT(user,'@',host) FROM mysql.user WHERE host='%' AND user<>''"
# expected: no rows
# See every host value, including the partial wildcards this check ignores:
sudo mysql -N -e "SELECT user, host FROM mysql.user ORDER BY user"
# Grants survive RENAME USER - confirm the account still works from its new host:
mysql -u app -h 10.0.0.5 -p -e 'SELECT 1'Debugging
HAS_MY=0 or MYOK=0. Test with sudo mysql -N -e 'SELECT 1'; see the Authentication checks' debugging notes for socket-path and sudo causes.
The MYSQL_USER entrypoint always creates user@'%'. Inside a private Compose network with 3306 unpublished this is low risk, but it is still a real wildcard grant. Confirm the port is not published (docker ps --format '{{.Ports}}') and record it, or pin the account to the app container's subnet.
Deliberate. The check audits the grant; exposure is covered by MySQL Network Binding and Exposed Database Ports. Fixing the grant is still the right move - defence in depth.
In MySQL localhost means the Unix socket, not TCP to 127.0.0.1. An app connecting to 127.0.0.1:3306 needs app@'127.0.0.1'. This trips people up constantly and is the usual reason % was chosen in the first place.
Partial wildcards are invisible to this check. Review the full user, host listing above.
Sources
- MySQL 8.4: Specifying Account Names
- MySQL 8.4: RENAME USER
- MySQL 8.4: Access Control, Stage 1: Connection Verification
- MariaDB KB: Configuring MariaDB for Remote Client Access
How the script reads this
Next
Re-run the Least-Privilege Permissions audit after applying the fix and confirm this check moves to PASS. CtrlOps runs all 6 checks over your existing SSH connection and scores the result, so the change is visible without reading another config file.
All 6 Least-Privilege Permissions fixes