MySQL User Management and Grants
Introduction¶
MySQL access control is 'user'@'host' plus privileges granted at global,
database, table, or column scope. Getting app accounts to least privilege — and
being able to audit what an account can actually do — is basic hygiene that's
often skipped.
user@host¶
An account is identified by both the username and the host pattern it connects from:
CREATE USER 'app'@'10.0.1.%' IDENTIFIED BY 'strong-secret'; -- from the app subnet
CREATE USER 'app'@'127.0.0.1' IDENTIFIED BY 'strong-secret'; -- local socket/loopback
CREATE USER 'backup'@'localhost' IDENTIFIED BY '...';
'app'@'%'— any host. Convenient, sloppy. Scope to the real source range.'app'@'localhost'matches the Unix socket, not127.0.0.1(which is a separate account). This trips people up constantly.- MySQL picks the most specific matching host row at login; a broad
'app'@'%'with fewer privileges can shadow a specific one — order matters.
Auth plugins¶
CREATE USER 'app'@'10.0.1.%' IDENTIFIED WITH caching_sha2_password BY 'secret';
-- MySQL 8 default. Some old clients/drivers only do mysql_native_password:
CREATE USER 'legacy'@'%' IDENTIFIED WITH mysql_native_password BY 'secret';
-- passwordless via socket/OS identity:
CREATE USER 'ops'@'localhost' IDENTIFIED WITH auth_socket;
caching_sha2_password needs TLS or the RSA key exchange for the first auth —
"Authentication plugin ... cannot be loaded" from an old client means switch that
account to mysql_native_password or update the driver.
Privilege scopes¶
-- global (*.*), e.g. admin/replication/backup roles
GRANT PROCESS, REPLICATION CLIENT, RELOAD, BACKUP_ADMIN ON *.* TO 'backup'@'localhost';
-- one database — the common app grant
GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO 'app'@'10.0.1.%';
-- one table
GRANT SELECT ON shop.audit_log TO 'reporting'@'10.0.2.%';
-- column-level (rare, for masking)
GRANT SELECT (id, name, created_at) ON shop.users TO 'reporting'@'10.0.2.%';
Least privilege for an app account: SELECT, INSERT, UPDATE, DELETE on its
schema. It usually does not need CREATE, DROP, ALTER, INDEX,
GRANT OPTION, FILE, SUPER, or *.* anything. Migrations run under a
separate, more-privileged account, in CI/deploy, not the app.
Dangerous privileges to keep off app accounts: FILE (read/write server
files), SUPER/SYSTEM_VARIABLES_ADMIN, GRANT OPTION, SHUTDOWN,
PROCESS (see other sessions' queries).
Roles (MySQL 8)¶
CREATE ROLE 'app_rw', 'app_ro';
GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO 'app_rw';
GRANT SELECT ON shop.* TO 'app_ro';
GRANT 'app_rw' TO 'app'@'10.0.1.%';
-- roles aren't active until set as default (or SET ROLE per session)
SET DEFAULT ROLE 'app_rw' TO 'app'@'10.0.1.%';
Roles let you manage privileges centrally and grant them to many users.
Changing and revoking¶
ALTER USER 'app'@'10.0.1.%' IDENTIFIED BY 'new-secret';
ALTER USER 'app'@'10.0.1.%' PASSWORD EXPIRE; -- force a change on next login
ALTER USER 'app'@'10.0.1.%' ACCOUNT LOCK; -- disable without dropping
REVOKE INSERT, UPDATE, DELETE ON shop.* FROM 'reporting'@'10.0.2.%';
DROP USER 'olddev'@'%';
Password rotation with dual accounts (app_a/app_b) enables zero-downtime
rotation — see Secrets Manager rotation.
Auditing¶
SHOW GRANTS FOR 'app'@'10.0.1.%';
SHOW GRANTS FOR 'app'@'10.0.1.%' USING 'app_rw'; -- include role privileges
SELECT * FROM information_schema.user_privileges WHERE grantee LIKE "'app'%";
SELECT * FROM mysql.user\G -- the raw account table (host, plugin, locked, expiry)
SELECT user, host FROM mysql.user;
For a full audit trail of what accounts actually did, use the audit log plugin (Enterprise, or Percona/MariaDB's) — the general query log is too heavy for production.
Verification and troubleshooting¶
SELECT CURRENT_USER(), USER(); -- CURRENT_USER = the account row matched; USER = what you typed
SHOW GRANTS; -- for the current session
Access denied for user 'app'@'10.0.1.5'— no matchinguser@hostrow (the app connects from an IP your host pattern doesn't cover), wrong password, or the account isACCOUNT LOCKed /PASSWORD EXPIREd.SELECT user, host FROM mysql.userand check the connecting IP.CURRENT_USER()is''@''or an unexpected account — a broad wildcard row matched instead of the specific one, or the anonymous account exists (drop''@'localhost'/''@'%'— they're a security hole).- App works from one host, not another — host pattern too narrow. Widen to
the real subnet, not
%. caching_sha2_passwordauth errors — old client;ALTER USER ... IDENTIFIED WITH mysql_native_passwordor update the driver / enable TLS.- Grant made but not effective — for roles, they must be activated
(
SET DEFAULT ROLEoractivate_all_roles_on_login=ON);SHOW GRANTS ... USING roleto see the effective set. localhostvs127.0.0.1confusion — a grant on'app'@'localhost'won't apply when the app connects via TCP to 127.0.0.1. Create both, or force the connection type.- Too many privileges discovered in an audit —
REVOKEthe extras; test the app still works (it should, if it only does CRUD).
Related tools and reading¶
- On-site: Password generator.
- Related posts: ProxySQL basics, Connection pooling explained.
Stuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.More in Databases & Caching
Redis Cluster vs Sentinel
Sentinel gives you automatic failover for one Redis dataset. Cluster shards data across nodes AND fails over. Here's how each works and which you actually need.
September 24, 2026Redis Streams as a Work Queue
Redis Streams give you a persistent, consumer-group log — enough for many work-queue needs without adding Kafka or RabbitMQ. Here's the commands and the reliability model.
September 15, 2026ProxySQL Basics
ProxySQL sits between apps and MySQL doing connection pooling, read/write splitting, and failover routing. Here's the config model and a working read/write split.
August 30, 2026