Skip to content

Grant least privilege in MySQL

Severity: mediumApplies to: MySQL 8.4 LTSApplies to: MariaDB 11.x / 12.x
The fix
CREATE USER 'app'@'10.0.1.%' IDENTIFIED WITH caching_sha2_password BY 'secret';
GRANT SELECT, INSERT, UPDATE, DELETE ON appdb.* TO 'app'@'10.0.1.%';
-- migrations run as a different account, used briefly
CREATE USER 'app_ddl'@'10.0.1.%' IDENTIFIED WITH caching_sha2_password BY 'other-secret';
GRANT ALL ON appdb.* TO 'app_ddl'@'10.0.1.%';
FLUSH PRIVILEGES;

The shape almost every MySQL setup starts with is:

GRANT ALL PRIVILEGES ON *.* TO 'app'@'%' WITH GRANT OPTION; -- don't

Three separate mistakes, each of which matters on its own:

  • *.* — every database, including mysql itself. The app can read the password hash table.
  • '%' — from any host. A leaked credential works from anywhere on earth.
  • ALL ... WITH GRANT OPTION — including FILE, SUPER, and the ability to hand those to others.

FILE is the one that turns a SQL injection into a host problem. It permits SELECT ... INTO OUTFILE and LOAD_FILE(), which read and write files as the mysql OS user — see restricting file privileges and the threat page. An application never needs it.

Each is a layer that survives a leaked password:

Narrow Effect
Database (appdb.* not *.*) The credential cannot touch mysql or other apps
Host (10.0.1.% not %) The credential is useless from outside your subnet
Privileges (SELECT, INSERT, … not ALL) No DROP, no FILE, no GRANT

Host scoping is the one people skip because '%' “just works”, and it is the one that most reduces the value of a stolen password.

MySQL has no owner concept like Postgres, so the separation is done with two accounts rather than ownership.

The runtime account gets SELECT, INSERT, UPDATE, DELETE — enough to run the application and nothing more. A separate account holds ALL ON appdb.* and is used only by migrations, briefly, with a credential the running service does not hold.

The payoff: a SQL injection against the runtime account cannot DROP TABLE users or ALTER anything. It’s the difference between a data breach and a destroyed database.

MySQL 8 and MariaDB 10.0.5+ both have roles, which is what makes least privilege survive contact with a growing team:

CREATE ROLE 'app_read', 'app_write';
GRANT SELECT ON appdb.* TO 'app_read';
GRANT INSERT, UPDATE, DELETE ON appdb.* TO 'app_write';
GRANT 'app_read', 'app_write' TO 'app'@'10.0.1.%';
SET DEFAULT ROLE ALL TO 'app'@'10.0.1.%';

SET DEFAULT ROLE is the step people miss. Without it a granted role is not active on connect, the application gets permission errors, and the usual response is to abandon roles and GRANT ALL — which is how you end up back at the top of this page.