Grant least privilege in MySQL
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 brieflyCREATE 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;Why it matters
Section titled “Why it matters”The shape almost every MySQL setup starts with is:
GRANT ALL PRIVILEGES ON *.* TO 'app'@'%' WITH GRANT OPTION; -- don'tThree separate mistakes, each of which matters on its own:
*.*— every database, includingmysqlitself. The app can read the password hash table.'%'— from any host. A leaked credential works from anywhere on earth.ALL ... WITH GRANT OPTION— includingFILE,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.
The three columns to narrow
Section titled “The three columns to narrow”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.
Separate DDL from runtime
Section titled “Separate DDL from runtime”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.
Roles make this maintainable
Section titled “Roles make this maintainable”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.
Related
Section titled “Related”- Restrict file privileges — why FILE matters so much.
- Remove anonymous users — the accounts you shouldn’t have at all.