Database security checklist

6 Database Access Control Checks You Are Probably Missing (2026)

Most database accounts are created once during setup with the broadest permissions available and never reviewed again. This page documents all 6 checks in the CtrlOps Least-Privilege Permissions audit for MySQL and PostgreSQL: what each one reads, the exact value that passes, the values that warn or fail, and the command to fix it.

Hiren KalariyaLast reviewed: Aug 22, 202613 min read
6
checks in this audit
0
rated high severity
12s
automated run time
6
need root or sudo

Every pass, warning and failure threshold on this page is transcribed from the database-security-least-privilege audit script that CtrlOps runs, and a build check fails if the two ever disagree. See how the audit runs.

Least privilege means every database account holds exactly the permissions its job requires and nothing more. This page documents the 6 checks in the CtrlOps Least-Privilege Permissions audit for MySQL and PostgreSQL: what each one reads, the exact value that passes, the values that warn or fail, and the command to fix it.

It is the layer that decides how much damage a leaked credential does. Authentication controls whether an attacker gets in; least privilege controls what they find when they do. Every threshold below is transcribed from the audit script itself, not from general advice.

Key takeaways

Six checks, all MEDIUM, all on MySQL and PostgreSQL, all measuring the same thing from different angles: how much more access exists than anyone needs.

  • Every check is MEDIUM severity, and that is deliberate. None of these is an open door on its own. Each one is an amplifier: it decides whether a compromised application account means one database or the whole server.
  • All six need root, because each opens an administrative connection to read a privilege table or the live host rules file. Without sudo the audit reports SKIP and names the reason.
  • The whole audit takes about 12 seconds when CtrlOps runs it over your existing SSH connection, against roughly 15 to 20 minutes by hand.
  • MongoDB and Redis are out of scope here. The audit covers MySQL and PostgreSQL because those two have the granular privilege models these checks interrogate.
#CheckEngineSeverityWhat it catches
1Wildcard hostsMySQLMEDIUMAccounts reachable from any address
2FILE privilegeMySQLMEDIUMAccounts that can read server files through SQL
3File import/exportMySQLMEDIUMUnrestricted file operations and local_infile
4Admin accountsMySQLMEDIUMSUPER held beyond the system set
5SuperusersPostgreSQLMEDIUMRoles that bypass every access control
6Allowed hostsPostgreSQLMEDIUMHost rules open to every address
Which accounts count as system accounts. Checks 2 and 4 exclude a fixed list from their findings: root, mysql, mysql.sys, mysql.session, mysql.infoschema, mariadb.sys, debian-sys-maint and percona.telemetry. Those are packaged accounts that legitimately hold elevated privileges, and flagging them would mean every server fails forever. Anything outside that list holding FILE or SUPER is the finding, including an account you created and named something sensible.

Check 1: Can any MySQL account connect from any host?

Medium severityNeeds sudo

MySQL identifies an account by a user and host pair, so appuser@10.0.1.5 and appuser@% are different accounts with different reach. A host of % means the account accepts connections from any address that can reach the port. The password still applies, but you have removed a whole layer of defence: a leaked credential is now usable from anywhere rather than from one machine.

The audit lists every account whose host is exactly %, skipping the blank username that check 2 of the authentication audit already covers.

How to check manually

sudo mysql -N -e "SELECT CONCAT(user,'@',host) FROM mysql.user WHERE host='%' AND user<>''"
Result thresholds this check applies
ResultWhenWhat it means
PASSNo account uses the wildcard hostEvery account is pinned to a specific address or to localhost.
WARNOne or more accounts use itThe accounts are listed as user@host. Pin each to localhost or to the application server address.
SKIPMySQL is not installed, or is not accessibleThe two reasons are reported separately.

This warns rather than failing because a wildcard host is sometimes the only workable answer: an autoscaling application tier has no fixed address to pin to. In that situation, narrow it to a subnet rather than leaving it at %, and rely on the network isolation audit to make sure the port is not reachable from outside that subnet in the first place.

How to fix it

# Add the scoped account FIRST
sudo mysql -e "
CREATE USER 'appuser'@'10.0.1.5' IDENTIFIED BY 'the-existing-password';
GRANT SELECT, INSERT, UPDATE, DELETE ON appdb.* TO 'appuser'@'10.0.1.5';"

# Verify the application works, then remove the wildcard one
sudo mysql -e "DROP USER 'appuser'@'%'; FLUSH PRIVILEGES;"

For a dynamic application tier, scope to the private subnet instead: 'appuser'@'10.0.1.%'.


Check 2: Can a MySQL account read files off the server?

Medium severityNeeds sudo

The FILE privilege lets an account read any file the MySQL process can read, and write files anywhere it can write. That includes /etc/passwd, application source, and the .env file the credential storage audit is worried about.

The reason this matters more than it sounds: it converts a SQL injection from a database problem into a filesystem read. An attacker who can inject LOAD_FILE('/var/www/html/.env') does not need to escalate anything.

The audit lists non-system accounts where File_priv is Y.

How to check manually

sudo mysql -N -e "
SELECT CONCAT(user,'@',host) FROM mysql.user WHERE File_priv='Y'
  AND user NOT IN ('root','mysql','mysql.sys','mysql.session','mysql.infoschema',
                   'mariadb.sys','debian-sys-maint','percona.telemetry')"
Result thresholds this check applies
ResultWhenWhat it means
PASSNo application account holds FILEA SQL injection stays inside the database.
FAILOne or more non-system accounts hold itThe accounts are listed. Revoke FILE on each.
SKIPMySQL is not accessibleNot installed, server down, or root was unavailable.

This is the only check on the page that fails rather than warning, despite carrying the same MEDIUM severity as the rest. The difference is that unlike a wildcard host or an extra superuser, there is no legitimate configuration in which an application account needs FILE. If something is using it, that is worth finding out about rather than accepting.

How to fix it

sudo mysql -e "REVOKE FILE ON *.* FROM 'appuser'@'localhost'; FLUSH PRIVILEGES;"

If an application genuinely uses LOAD DATA INFILE, keep FILE revoked and restrict the operation instead, which is check 3.


Check 3: Is MySQL file import and export restricted?

Medium severityNeeds sudo

Two settings decide how much filesystem MySQL exposes through SQL:

  • secure_file_priv confines LOAD DATA INFILE and SELECT INTO OUTFILE to one directory. Empty means anywhere. NULL means the operations are disabled entirely.
  • local_infile lets a client push a local file to the server with LOAD DATA LOCAL INFILE, which bypasses secure_file_priv completely and can be abused by a malicious or compromised server to read files off the client.

The audit reads both and combines them. local_infile being on is a warning regardless of anything else.

How to check manually

sudo mysql -e "SHOW VARIABLES LIKE 'secure_file_priv'"
sudo mysql -N -e "SELECT @@local_infile"
Result thresholds this check applies
ResultWhenWhat it means
PASSsecure_file_priv is NULL or set to a directory, and local_infile is offThe actual value is reported. File operations are confined or disabled outright.
WARNlocal_infile is on, or secure_file_priv is emptyThe reason is named. An empty value means any directory; local_infile on means a SQL injection can read client-side files.
SKIPMySQL is not accessible, or secure_file_priv could not be read at allThe second case is deliberate: a failed query is not the same as an unrestricted setting, so it is not reported as one.

That last SKIP branch is worth understanding, because it is the difference between a useful check and a false alarm. An empty secure_file_priv value means unrestricted access. A query that returns no row at all means the audit could not read the setting. Those look identical if you only capture the value, so the script keeps the whole row and distinguishes them.

How to fix it

# /etc/mysql/mysql.conf.d/mysqld.cnf
[mysqld]
secure_file_priv = /var/lib/mysql-files/   # or NULL to disable entirely
local_infile = 0

sudo systemctl restart mysql
NULL is stronger than a directory. Setting secure_file_priv to a path keeps import and export working inside that path. Setting it to NULL disables them completely, which is the right answer on the large majority of servers, because almost nothing in normal application traffic uses LOAD DATA INFILE. Both pass this check; only one of them removes the capability.

Check 4: Do any application accounts hold SUPER?

Medium severityNeeds sudo

SUPER (and SYSTEM_USER on MySQL 8) allows operations that override normal restrictions: killing other sessions, changing global variables, bypassing read-only mode, skipping replication filters. It belongs to maintenance accounts, not to the account your web application connects with.

The audit lists accounts with Super_priv set to Y, excluding the same packaged system set as check 2.

How to check manually

sudo mysql -N -e "
SELECT CONCAT(user,'@',host) FROM mysql.user WHERE Super_priv='Y'
  AND user NOT IN ('root','mysql','mysql.sys','mysql.session','mysql.infoschema',
                   'mariadb.sys','debian-sys-maint','percona.telemetry')"
Result thresholds this check applies
ResultWhenWhat it means
PASSNo unexpected account holds SUPERAdministrative privilege is confined to the system accounts. Keep application accounts on table-level grants.
WARNOne or more accounts hold it beyond the system setThe accounts are listed. Application accounts need SELECT, INSERT, UPDATE and DELETE, not admin rights.
SKIPMySQL is not accessibleNot installed, server down, or root was unavailable.

How to fix it

sudo mysql -e "REVOKE SUPER ON *.* FROM 'appuser'@'localhost'; FLUSH PRIVILEGES;"

# What an application account should actually have
sudo mysql -e "
GRANT SELECT, INSERT, UPDATE, DELETE ON appdb.* TO 'appuser'@'localhost';"

# A migration account, separate from the runtime one
sudo mysql -e "
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, DROP, INDEX
  ON appdb.* TO 'migrate'@'localhost';"
Two accounts, not one. The common reason an application account ends up with SUPER is that it also runs migrations, and somebody granted broadly to make a deploy work at 2am. Splitting the runtime account from the migration account fixes the finding permanently: the long-lived credential in your application config gets table-level access only, and the DDL rights live on an account that is used for minutes at a time.

Check 5: How many PostgreSQL superusers are there?

Medium severityNeeds sudo

A PostgreSQL superuser bypasses everything: row-level security, role permissions, object ownership, all of it. The built-in postgres role is expected. Every additional one is another credential that, if leaked, is equivalent to root for the entire cluster.

The audit lists roles where rolsuper is true, excluding postgres.

How to check manually

sudo -u postgres psql -X -tAc "SELECT rolname FROM pg_roles WHERE rolsuper AND rolname <> 'postgres'"
Result thresholds this check applies
ResultWhenWhat it means
PASSpostgres is the only superuser roleSuperuser access is as small as it goes.
WARNAny other role has the superuser attributeThe role names are listed. Application roles should own their schema, not the cluster.
SKIPPostgreSQL is not installed, or is not accessibleThe two reasons are reported separately.

How to fix it

# Grant the specific capability the role actually needs
sudo -u postgres psql -c "ALTER ROLE dba_user CREATEDB CREATEROLE;"
sudo -u postgres psql -c "GRANT pg_read_all_data TO reporting_user;"

# Then remove superuser
sudo -u postgres psql -c "ALTER ROLE dba_user NOSUPERUSER;"
Most superusers only need one capability. Superuser gets handed out because it is the fastest way to unblock someone, but the underlying need is almost always narrower: creating databases, managing roles, or reading everything for reporting. PostgreSQL has CREATEDB, CREATEROLE and the predefined pg_read_all_data role for exactly those, and each leaves row-level security and object ownership intact.

Check 6: Does PostgreSQL accept connections from any address?

Medium severityNeeds sudo

The host-based authentication file is where PostgreSQL decides which networks may connect at all, before any password is checked. An entry with an address of 0.0.0.0/0, ::/0 or the literal all is the PostgreSQL equivalent of the MySQL % wildcard from check 1.

The audit resolves the live file with SHOW hba_file, strips comments, and counts lines whose type starts with host and whose address column is one of those three values.

How to check manually

HBA=$(sudo -u postgres psql -X -tAc 'SHOW hba_file')
sudo grep -vE '^[[:space:]]*(#|$)' "$HBA" \
  | awk '$1 ~ /^host/ && ($4=="0.0.0.0/0" || $4=="::/0" || $4=="all")'
Result thresholds this check applies
ResultWhenWhat it means
PASSEvery host entry is scoped to a specific address or networkNothing accepts a connection attempt from an arbitrary address.
WARNOne or more host entries use an any-address valueThe count is reported. Scope each to the application server subnet.
SKIPPostgreSQL is not accessibleNot installed, server down, or root was unavailable.

Note that this looks at the address column, while check 5 of the authentication audit looks at the method column of the same file. They are separate findings on the same lines: an entry can be perfectly scoped and still use trust, or use scram-sha-256 and still be open to the world. Read both.

How to fix it

# Before
host  all     all       0.0.0.0/0     scram-sha-256

# After
host  appdb   appuser   10.0.1.5/32   scram-sha-256
host  appdb   appuser   10.0.1.0/24   scram-sha-256

sudo systemctl reload postgresql

What this audit does not cover

Being explicit about scope is part of the point of publishing thresholds.

  • Whether accounts have passwords at all. That is the authentication audit.
  • Whether the port is reachable from outside. That is the network isolation audit. A wildcard host on a database bound to loopback is a much smaller problem than the same account on an exposed one.
  • MongoDB and Redis permissions. Both have role and ACL models, but this audit covers MySQL and PostgreSQL, whose privilege tables these checks query directly.
  • Table and column level grants. The audit reads global privilege flags such as File_priv and Super_priv. It does not enumerate what each account can do to each table, so an account with no SUPER and ALL PRIVILEGES on every database passes every check here.
  • Row-level security policies. Whether a policy exists and whether it is correct are application design questions.
  • Managed database services. RDS, Cloud SQL and Azure Database do not expose these tables to a host session, and their superuser model is different by design.

The manual audit problem at scale

Running these 6 checks by hand takes 15 to 20 minutes per server, and the queries are not the hard part. The hard part is that the answer is a list of account names, and account names mean nothing without context. Is reporting_user supposed to be a superuser? Was legacy_import created for a migration in 2023 that finished? The query is instant; the judgement is what takes the time.

That is also why this audit degrades faster than the others. Permissions are granted under time pressure, during an incident or a deploy, with the intention of tightening them later. The grant is permanent and the intention is not. Nothing on the server ever objects, because from the database's point of view an over-privileged account is a working account.

A single run tells you the current state. What you actually want to know is what changed since last month, which needs the previous result to compare against. That is the same server management drift problem that compounds across every audit category.


How CtrlOps runs all 6 checks in one click

Instead of running these queries on every server by hand, CtrlOps runs the whole Least-Privilege audit over the SSH connection you already have open. Nothing is installed on the server: no agent, no daemon, no package, no new credentials. The checks are read-only POSIX shell that executes, streams its results back, and leaves nothing behind.

Watch it run

The recording below walks through the SSH & Access audit rather than this one, but the flow is identical for every audit in the catalog: pick it, watch the checks stream in, read the score, send the failures to the AI Terminal. The written steps that follow cover the same ground if you would rather read than watch.

Step 1: Open the Audit tab

Connect to your server in CtrlOps and open the Audit tab in the left sidebar.

Step 2: Select Least-Privilege Permissions

Choose it from the Database category. All 6 checks are listed with their descriptions, severity levels and the estimated run time (about 12 seconds in total). Toggle off any check you want to skip.

Step 3: Run it

The audit runs over your existing SSH session. Results stream in as they complete, so you watch each check pass, warn or fail rather than waiting on a final report.

Step 4: Read the report

You get a summary containing:

  • A hardening score out of 100
  • A severity breakdown: how many HIGH, MEDIUM and LOW findings
  • Pass, warning, failed and skipped counts
  • A findings table you can sort and filter

Step 5: Fix what failed

Select the failed findings and send them to the AI Terminal. Each proposed fix command appears with an explanation of what it changes. Nothing runs until you review and approve it, which matters on this audit in particular: revoking a privilege an application is quietly using breaks it immediately.

Step 6: Re-run and compare

Run the same audit again after applying the fixes and watch the hardening score move. The stored history is what turns a list of account names into a signal, because it tells you which grants are new.

TaskBy hand, 8 serversCtrlOps, 8 servers
Run all 6 checksAbout 2 hoursAbout 100 seconds
Spot which grants are new since last monthOnly if you saved the outputAutomatic
Get a scored reportNot availableAutomatic
Export a PDF for a clientNot availableOne click
Fix what failedCopy-paste from your notesAI-generated commands behind an approval gate

Conclusion

Least privilege is the layer that decides the blast radius. Every other database audit reduces the chance of a compromise; this one reduces what a compromise is worth. An application account with SELECT, INSERT, UPDATE and DELETE on one schema is a bad afternoon. The same account with FILE and SUPER and a wildcard host is a rebuilt server.

All six checks are MEDIUM because none is an open door on its own, and all six are the kind of thing granted in a hurry and never reviewed. The CtrlOps Security Audit runs the set in about 12 seconds per server, agentlessly.

The neighbouring checklists cover the other database layers: authentication, credential storage, configuration and hardening, network isolation and transport encryption.


Frequently asked questions

It means every account holds exactly the permissions its function requires and nothing more. An application that reads and writes one schema should not have administrative rights, file access, or the ability to connect from any address. The point is blast radius: with least privilege a leaked credential gives an attacker the application's access, not the database server's.

Because none of them is an open door by itself. A wildcard host still requires a password. An extra superuser still requires a credential. What each one changes is how much a successful compromise is worth, which makes them amplifiers rather than entry points. The entry points live in the authentication and network isolation audits, and those carry the HIGH ratings.

Practically never for an application account. FILE lets the account read any file the MySQL process can read and write to any location it can write, which turns a SQL injection into a filesystem read of things like /etc/passwd or your .env. That is why it is the one check here that fails rather than warns: unlike a wildcard host, there is no normal configuration that needs it. If something uses LOAD DATA INFILE, restrict the operation with secure_file_priv instead of granting FILE.

One, the built-in postgres role. Every additional superuser bypasses row-level security, role permissions and object ownership, so it is a full-access credential. For the tasks that usually prompt the grant, use the narrower attributes instead: CREATEDB for creating databases, CREATEROLE for managing users, and the predefined pg_read_all_data role for reporting access.

Empty means unrestricted: LOAD DATA INFILE and SELECT INTO OUTFILE can touch any directory the MySQL process can reach, which is why the audit warns on it. NULL means the operations are disabled entirely, which passes. A directory path also passes and confines them to that path. On most servers NULL is the right answer, because almost no normal application traffic uses these statements.

Because an empty value and a failed query look identical if you only capture the value, and reporting a failed query as "unrestricted file access" would be a false finding on every server where the query did not run. The script keeps the whole result row and checks whether the variable name came back at all, so a read failure reports SKIP and an actual empty value reports WARN.

Because they are packaged system accounts that legitimately hold elevated privileges, and flagging them would mean every MySQL server on earth fails these checks permanently. The excluded set is root, mysql, mysql.sys, mysql.session, mysql.infoschema, mariadb.sys, debian-sys-maint and percona.telemetry. Anything outside that list holding FILE or SUPER is reported, including accounts you created yourself.

In MySQL, create the account against a specific address, 'appuser'@'10.0.1.5', rather than the % wildcard, and use a subnet such as 10.0.1.% where the application tier has no fixed address. In PostgreSQL, replace 0.0.0.0/0 in the host rules file with the actual subnet. Add the scoped account or rule before removing the broad one, so the application never loses its connection mid-change.

No, and this is the main limit worth knowing. These checks read global privilege flags: wildcard hosts, File_priv, Super_priv, superuser roles, host rules. They do not enumerate table and column grants, so an account with no SUPER that holds ALL PRIVILEGES ON *.* passes every check on this page. Review SHOW GRANTS per account alongside this audit.

It opens one administrative connection per engine over your existing SSH session, then queries mysql.user for wildcard hosts, File_priv and Super_priv, reads secure_file_priv and local_infile, queries pg_roles for superusers, and parses the live PostgreSQL host rules file for any-address entries. Every check is read-only and the whole audit takes about 12 seconds.

It reads privilege tables on MySQL and PostgreSQL instances you administer. It does not cover MongoDB or Redis permission models, managed services such as RDS or Cloud SQL where the privilege model is the provider's, Kubernetes RBAC, or application-level role mapping such as Django permissions. For managed databases, use the IAM and access control tooling in the provider console.

Audit your fleet

Run all these checks on every server, in one click

CtrlOps runs this audit over your existing SSH connection - no agents, no scripts to manage. $7/user/month after a 1 month free trial - no credit card required.

✓ Start instantly·✓ No credit card·✓ No sneaky autorenewals