The short version
- A MySQL account is
'user'@'host'. The host is part of the identity, so'app'@'localhost'is not'app'@'10.0.0.%'. - PostgreSQL has roles plus
pg_hba.conf. Every user is a role, and where it may connect from lives in that file. - Grant the narrowest scope that works. One account per job, DML only for the application, never root from the app.
- Roles are bundles of privileges. In MySQL a granted role does nothing until it is activated.
- New authentication defaults break old clients. Fix the driver, never the server.
- Close gate one too. Listen on the fewest interfaces, require TLS, and keep the port off the internet.
The account model: who is asking
MySQL An account is the pair 'user'@'host', and the host is part of the identity, so 'app'@'localhost' and 'app'@'%' are two accounts with their own passwords and grants. The host can be a name, an IP, a pattern such as '10.0.0.%', or '%' for anywhere, and the most specific match wins. An app moved to another machine then gets "Access denied for user 'app'@'10.0.0.7'": that account does not exist. PostgreSQL has one concept, the role; a role with LOGIN is what you call a user, a role without it is a group, and where a role may connect from lives in pg_hba.conf, one line per rule, first match wins.
CREATE USER 'app'@'10.0.0.%' IDENTIFIED BY 'a-long-random-secret'; -- MySQL
SELECT user, host, plugin FROM mysql.user WHERE user = 'app';
CREATE ROLE app LOGIN PASSWORD 'a-long-random-secret'; -- PostgreSQL
-- pg_hba.conf: host shop app 10.0.0.0/24 scram-sha-256
Remember
MySQL puts "from where" inside the account name. PostgreSQL keeps it in pg_hba.conf and makes every user a role. Both store only a password hash, so you can reset a password, never look one up.
Authentication: proving it
Each MySQL account has an authentication plugin. caching_sha2_password is the default in 8.4, 9.7 and 26.x; mysql_native_password is disabled by default in 8.4 and was removed in 9.0, so 9.x cannot use such accounts at all. In PostgreSQL the method comes from the matching pg_hba.conf line, and scram-sha-256 has been the default since 14. Old clients fail because their connector only speaks the old scheme: the error names caching_sha2_password, or an account restored from an old dump fails with ERROR 1524 Plugin 'mysql_native_password' is not loaded. The fix is a newer driver, never a weaker server.
Careful
Re-enabling mysql_native_password on 8.4 is a loan: the option does not exist on 9.x, so the upgrade to 9.7 breaks that app again. Track it as debt with a date.
Privileges and roles: what you may do
A privilege is always this action, on this scope, for this account. GRANT gives one, REVOKE takes it back, and SHOW GRANTS in MySQL or \du and \dp in psql show what someone has. A grant at a wide level covers every narrower object inside it.
| MySQL level | Example | Covers |
|---|---|---|
| Global | ON *.* | Every database; ALL here is effectively another root |
| Database | ON shop.* | Every table in shop, including ones created later |
| Table | ON shop.orders | One table |
| Column | SELECT (name, city) ON shop.customers | Named columns only |
| Routine | EXECUTE ON PROCEDURE shop.close_order | One stored routine |
| Proxy | PROXY ON 'other'@'host' | One account acting as another, with external authentication |
| Dynamic (global only) | BACKUP_ADMIN, CONNECTION_ADMIN, SYSTEM_VARIABLES_ADMIN, ROLE_ADMIN | Slices of the old catch-all SUPER |
| Privilege | What it allows | Who usually gets it |
|---|---|---|
| SELECT, INSERT, UPDATE, DELETE | Read and change rows | Application users, on their own database |
| CREATE, ALTER, DROP, INDEX | Change the schema | Deploy user, on its database |
| CREATE VIEW, SHOW VIEW, TRIGGER, EVENT, CREATE ROUTINE, EXECUTE | Views, triggers, events, stored routines | Deploy user; EXECUTE for apps that call procedures |
| PROCESS | See other sessions in SHOW PROCESSLIST | Monitoring, DBAs |
| RELOAD | FLUSH statements, needed by some backup tools | Backup user |
| REPLICATION CLIENT, REPLICATION SLAVE | Read binary log position; pull binary logs as a replica | Monitoring; the replication account |
| LOCK TABLES | Explicit table locks | Backup user |
| BACKUP_ADMIN | LOCK INSTANCE FOR BACKUP, used by MySQL Shell dumps | Backup user |
| CREATE USER, GRANT OPTION, ROLE_ADMIN | Manage accounts and roles, pass privileges on | You, on your named admin account |
| USAGE | May log in, nothing else | Every account has this line |
| ALL PRIVILEGES ON *.* | Everything, everywhere | root and nobody else |
A role is a named bundle: grant privileges to the role once, grant it to as many accounts as you like, and tightening the bundle fixes all of them. In MySQL 8 and later a granted role is inert until SET DEFAULT ROLE or activate_all_roles_on_login turns it on. In PostgreSQL roles are the same objects as users, membership is a plain GRANT, and built-in roles such as pg_read_all_data and pg_monitor replace long grant lists. GRANT ... ON ALL TABLES covers only today's tables, so ALTER DEFAULT PRIVILEGES covers tomorrow's.
CREATE ROLE shop_read; -- MySQL
GRANT SELECT ON shop.* TO shop_read;
GRANT shop_read TO 'reporting'@'10.0.0.%';
SET DEFAULT ROLE shop_read TO 'reporting'@'10.0.0.%'; -- without this: no privileges
GRANT pg_read_all_data TO reporting; -- PostgreSQL
ALTER DEFAULT PRIVILEGES FOR ROLE deploy IN SCHEMA public
GRANT SELECT ON TABLES TO reporting; -- tomorrow's tables
Ask the AI
Ask for the statements: "MySQL 8.4: create a read-only user 'reporting'@'10.0.0.%' for the shop database through a role called shop_read, require TLS, and show the SHOW GRANTS output to expect."
Least privilege: the recipes
Least privilege means each account can do its job and nothing more. It limits the damage from a leaked password, a buggy deploy and SQL injection at once.
| Account | MySQL 8.4 / 9.7 / 26.x | PostgreSQL 17 / 18 |
|---|---|---|
| Application (DML only, one database) | SELECT, INSERT, UPDATE, DELETE ON shop.*, plus EXECUTE if it calls procedures. Host limited to the app servers. | CONNECT, USAGE on the schema, SELECT/INSERT/UPDATE/DELETE on tables, USAGE on sequences, default privileges for future tables. No ownership. |
| Read-only reporting | SELECT ON shop.* through a role such as shop_read; column grants where columns are sensitive. | pg_read_all_data, or SELECT on the schema's tables plus default privileges. |
| Backup | mysqldump: SELECT, SHOW VIEW, TRIGGER, EVENT, PROCESS, LOCK TABLES unless --single-transaction, RELOAD when GTIDs are on. MySQL Shell util.dumpInstance(): the same plus BACKUP_ADMIN. | pg_dump: pg_read_all_data is simplest. pg_basebackup: the REPLICATION attribute and a replication line in pg_hba.conf. |
| Migration / deploy (DDL) | CREATE, ALTER, DROP, INDEX, CREATE VIEW, TRIGGER, CREATE ROUTINE, ALTER ROUTINE, REFERENCES ON shop.*. Used only by the pipeline. | Owns the schema objects, or CREATE on the schema; the default-privileges rule is written FOR ROLE deploy so the app keeps access. |
| Monitoring | PROCESS, REPLICATION CLIENT, SELECT ON performance_schema.* and sys.*, SHOW DATABASES. | pg_monitor. |
| Your admin account | A named account with ALL PRIVILEGES ON *.* WITH GRANT OPTION, so root's password stays in the safe and the logs show who did what. | A named role with SUPERUSER, or CREATEROLE plus CREATEDB; keep postgres for emergencies. |
| root / postgres from the app | Never. One injected query or leaked config file then owns every database and every account. | |
For you as a DBA
The tickets you will get: "create a user for the new service", "give the analyst read access", "the app cannot connect since we moved it". Answer each with a named account and the narrowest host and scope that works, and review every account's grants once a quarter.
The network: where connections may come from
- Listening interfaces. MySQL's
bind_addressdefaults to all of them; PostgreSQL'slisten_addressesdefaults tolocalhost. Set each to the smallest value that works. - TLS. Both clients fall back to plain text silently, so demand it:
require_secure_transport=ONorREQUIRE SSLper account in MySQL,hostssllines andsslmode=requirein PostgreSQL. - Windows Firewall. Restrict the inbound rule for port 3306 or 5432 to the application servers' subnet.
- Never the internet. A VPN, an SSH tunnel or a bastion host is the way in from outside.
Credentials and SQL injection
Scheduled jobs must log in without a human, so store the credentials outside the script: mysql_config_editor writes a login path to %APPDATA%\MySQL\.mylogin.cnf and every MySQL client then takes --login-path=backup instead of a password. PostgreSQL reads %APPDATA%\postgresql\pgpass.conf, one line per server in the form hostname:port:database:username:password.
# MySQL: store once (prompts for the password), then no password in any script
mysql_config_editor set --login-path=backup --host=localhost --user=backup --password
mysqldump --login-path=backup --single-transaction shop --result-file=C:\backups\shop.sql
# PostgreSQL: one line in %APPDATA%\postgresql\pgpass.conf
# localhost:5432:*:backup:the-password
SQL injection happens when an application glues user input into SQL text, so ' OR 1=1 -- becomes part of the statement. Parameterized queries stop it completely, and your least-privilege grants keep an injected query from dropping tables or reading other databases.
The rest, in one line each
- Password lifecycle. MySQL has
validate_password,PASSWORD EXPIRE INTERVAL,PASSWORD HISTORY,FAILED_LOGIN_ATTEMPTSandACCOUNT LOCKfor leavers; PostgreSQL hasVALID UNTIL,NOLOGINandCONNECTION LIMIT. - Auditing. MySQL Enterprise Audit is paid, so on Community you lean on the binary log, short bursts of the general log and audit triggers; PostgreSQL has
log_statement,log_connectionsand the free pgAudit extension.
Go deeper
- Privileges provided by MySQL
Every static and dynamic privilege.
- Using roles in MySQL
Default roles and activation.
- mysql_native_password: deprecated, disabled, removed
The timeline behind most "old app cannot connect" tickets.
- PostgreSQL database roles
Roles, attributes, membership, predefined roles.
- The pg_hba.conf file
Line format and methods.