Part · Chapter

Users, roles and security

Every connection passes three gates: can it reach the port, is it who it claims to be, and what is it allowed to do.

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.

Illustration · MySQL and PostgreSQL
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 levelExampleCovers
GlobalON *.*Every database; ALL here is effectively another root
DatabaseON shop.*Every table in shop, including ones created later
TableON shop.ordersOne table
ColumnSELECT (name, city) ON shop.customersNamed columns only
RoutineEXECUTE ON PROCEDURE shop.close_orderOne stored routine
ProxyPROXY ON 'other'@'host'One account acting as another, with external authentication
Dynamic (global only)BACKUP_ADMIN, CONNECTION_ADMIN, SYSTEM_VARIABLES_ADMIN, ROLE_ADMINSlices of the old catch-all SUPER
PrivilegeWhat it allowsWho usually gets it
SELECT, INSERT, UPDATE, DELETERead and change rowsApplication users, on their own database
CREATE, ALTER, DROP, INDEXChange the schemaDeploy user, on its database
CREATE VIEW, SHOW VIEW, TRIGGER, EVENT, CREATE ROUTINE, EXECUTEViews, triggers, events, stored routinesDeploy user; EXECUTE for apps that call procedures
PROCESSSee other sessions in SHOW PROCESSLISTMonitoring, DBAs
RELOADFLUSH statements, needed by some backup toolsBackup user
REPLICATION CLIENT, REPLICATION SLAVERead binary log position; pull binary logs as a replicaMonitoring; the replication account
LOCK TABLESExplicit table locksBackup user
BACKUP_ADMINLOCK INSTANCE FOR BACKUP, used by MySQL Shell dumpsBackup user
CREATE USER, GRANT OPTION, ROLE_ADMINManage accounts and roles, pass privileges onYou, on your named admin account
USAGEMay log in, nothing elseEvery account has this line
ALL PRIVILEGES ON *.*Everything, everywhereroot 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.

Illustration · MySQL and PostgreSQL
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.

AccountMySQL 8.4 / 9.7 / 26.xPostgreSQL 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 reportingSELECT 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.
Backupmysqldump: 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.
MonitoringPROCESS, REPLICATION CLIENT, SELECT ON performance_schema.* and sys.*, SHOW DATABASES.pg_monitor.
Your admin accountA 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 appNever. 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_address defaults to all of them; PostgreSQL's listen_addresses defaults to localhost. Set each to the smallest value that works.
  • TLS. Both clients fall back to plain text silently, so demand it: require_secure_transport=ON or REQUIRE SSL per account in MySQL, hostssl lines and sslmode=require in 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.

PowerShell
# 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_ATTEMPTS and ACCOUNT LOCK for leavers; PostgreSQL has VALID UNTIL, NOLOGIN and CONNECTION 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_connections and the free pgAudit extension.

Go deeper

Quick check

1. You created 'app'@'localhost' on MySQL 8.4 with the right grants, and the application on the web server (10.0.0.7) gets "Access denied for user 'app'@'10.0.0.7'". Why?

A MySQL account is 'user'@'host'. A firewall block would time out instead of returning an access-denied message. Reread "The account model: who is asking".

2. A ten-year-old PHP application fails to connect to a new MySQL 9.7 server with an error about mysql_native_password. What is the durable fix?

The re-enable switch exists only on 8.4, and even there it is deprecated; 9.x has no mysql_native_password at all. Reread "Authentication: proving it".

3. On MySQL you created role shop_read with SELECT on shop.*, granted it to 'reporting'@'10.0.0.%', and the user still gets "SELECT command denied". What is missing?

A granted role gives nothing until it is activated in the session or set as a default role; activate_all_roles_on_login is off by default. Reread "Privileges and roles: what you may do".

4. On PostgreSQL 18 you granted the reporting role SELECT ON ALL TABLES IN SCHEMA public. A week later the deploy role creates a new table and reporting cannot read it. What should you have added?

GRANT ... ON ALL TABLES covers only tables that exist at that moment; default privileges cover what a given role creates later. Reread "Privileges and roles: what you may do".

5. A developer says the connection is encrypted because the PostgreSQL connection string contains sslmode=prefer, and the MySQL server has TLS certificates in its data directory. Is the traffic guaranteed to be encrypted?

Availability of TLS is not the same as enforcement; both clients quietly fall back unless the server or the account demands encryption. Self-signed certificates do encrypt, they just do not prove identity. Reread the TLS point in "The network: where connections may come from".