Databases, users & privileges
Every application connecting as root is one SQL-injection bug away from dropping every
database on the server. MySQL's account model is slightly unusual — an account is a user plus a
host — and once you understand that, setting up safe, minimal access is straightforward.
1. Databases = schemas
CREATE DATABASE retail CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; SHOW DATABASES; USE retail; DROP DATABASE IF EXISTS scratch; -- deletes every table inside. No undo.
Unlike Postgres, MySQL has no separate schema level inside a database: retail.orders is
database retail, table orders, and you can join across databases on the same
server with that dotted name.
2. An account is user@host
CREATE USER 'app'@'10.0.1.%' IDENTIFIED BY 'a-long-random-secret'; CREATE USER 'analyst'@'%' IDENTIFIED BY '…' PASSWORD EXPIRE INTERVAL 90 DAY; CREATE USER 'backup'@'localhost' IDENTIFIED BY '…'; SELECT user, host, plugin, account_locked FROM mysql.user; ALTER USER 'analyst'@'%' ACCOUNT LOCK; DROP USER 'analyst'@'%';
MySQL 8 defaults to caching_sha2_password. Some old client libraries only speak the
legacy mysql_native_password, which is disabled by default in 8.4. The right fix is to
upgrade the client library, not to downgrade the server's authentication.
3. GRANT and REVOKE
GRANT SELECT, INSERT, UPDATE, DELETE ON retail.* TO 'app'@'10.0.1.%'; -- one database GRANT SELECT ON retail.orders TO 'analyst'@'%'; -- one table GRANT SELECT (customer_id, city) ON retail.customers TO 'analyst'@'%'; -- columns GRANT PROCESS, RELOAD, LOCK TABLES, REPLICATION CLIENT ON *.* TO 'backup'@'localhost'; SHOW GRANTS FOR 'app'@'10.0.1.%'; REVOKE DELETE ON retail.* FROM 'app'@'10.0.1.%';
| Account | Needs | Must NOT have |
|---|---|---|
| Web application | SELECT/INSERT/UPDATE/DELETE on its database | DROP, ALTER, GRANT, FILE, SUPER, anything on *.* |
| Migration runner | + CREATE, ALTER, INDEX, DROP on its database | Global privileges |
| Analyst / BI tool | SELECT (ideally on a read replica) | Any write privilege |
| Backup | SELECT, LOCK TABLES, RELOAD, PROCESS, REPLICATION CLIENT | Write privileges |
If the app's account can only modify rows in one database, an injection bug can at worst damage that
data — not drop tables, read mysql.user or write files to disk (FILE
privilege). Separating the migration account from the runtime account means the running app cannot
even ALTER its own tables.
4. Roles (MySQL 8)
CREATE ROLE 'retail_read', 'retail_write'; GRANT SELECT ON retail.* TO 'retail_read'; GRANT INSERT, UPDATE, DELETE ON retail.* TO 'retail_write'; GRANT 'retail_read', 'retail_write' TO 'app'@'10.0.1.%'; SET DEFAULT ROLE ALL TO 'app'@'10.0.1.%'; -- ⚠ roles are NOT active on login otherwise
Granting a role does not activate it. Without SET DEFAULT ROLE (or
activate_all_roles_on_login=ON), the user logs in with no effective privileges and gets
"access denied" despite SHOW GRANTS listing the role.
Recap
- Database = schema in MySQL; cross-database joins use
db.table. - An account is
'user'@'host'; the most specific host match wins. - Grant the minimum, at the narrowest scope — separate runtime, migration, analyst and backup accounts.
- Roles bundle privileges — remember
SET DEFAULT ROLE. - Keep
caching_sha2_password; upgrade old clients instead.
Checkpoint
'report'@'%', but report connecting from the server itself gets "access denied". Why?
SELECT CURRENT_USER() after connecting — it shows which account actually matched. (FLUSH PRIVILEGES is not needed after GRANT.)