MENU
Users, Privileges, Roles, and SQL Injection
Every statement runs as an account and is checked against its privileges. This section builds that access control, then turns to SQL injection, the attack that rewrites your queries, and to the two defenses: server-side prepared statements and a least-privilege account (the PHP side is Section 4.16). The listings change the whole server, so they run in order as root on a mysql:9.7 container of your own (Section 3.1.6) holding shop.3.16.1 Accounts, Hosts, and Authentication Plugins
An account is a user name plus a host: 'shop_app'@'localhost' and 'shop_app'@'%' are two accounts with separate passwords and privileges. The host is localhost (the Unix socket), an address, a pattern such as '10.0.0.%' or '10.0.0.0/24', or '%' for anywhere, the default. When several accounts match, the most specific host wins. Each account also names the authentication plugin that checks its credentials:SELECT user, host, plugin, LEFT(authentication_string, 7) AS hash
FROM mysql.user WHERE user NOT LIKE 'mysql.%';
CREATE USER 'legacy'@'%' IDENTIFIED WITH mysql_native_password BY 'Legacy#Pass-2026';
+------+-----------+-----------------------+---------+
| user | host | plugin | hash |
+------+-----------+-----------------------+---------+
| root | % | caching_sha2_password | $A$00A$ |
| root | localhost | caching_sha2_password | $A$00A$ |
+------+-----------+-----------------------+---------+
ERROR 1524 (HY000) at line 3: Plugin 'mysql_native_password' is not loaded
3.16.2 CREATE USER, ALTER USER, and Password Policy
CREATE USER makes an account that can log in and do nothing else; ALTER USER takes the same clauses later. The validate_password component (Section 3.1.7) rejects weak passwords server-wide, and per-account clauses set expiry, reuse history, and a block after failed logins:INSTALL COMPONENT 'file://component_validate_password';
CREATE USER 'shop_app'@'localhost' IDENTIFIED BY 'shopapp2026';
CREATE USER 'shop_app'@'localhost' IDENTIFIED BY 'App#Shop-2026'
PASSWORD EXPIRE NEVER FAILED_LOGIN_ATTEMPTS 5 PASSWORD_LOCK_TIME 1;
CREATE USER 'ana'@'localhost' IDENTIFIED BY 'Ana#Pass-2026'
PASSWORD EXPIRE INTERVAL 90 DAY PASSWORD HISTORY 5 PASSWORD REQUIRE CURRENT
FAILED_LOGIN_ATTEMPTS 3 PASSWORD_LOCK_TIME 1;
try() { mysql -ushop_app -p"$1" -Nse "SELECT CURRENT_USER()" 2>&1 | grep -v Warning; }
sudo mysql -e "ALTER USER 'shop_app'@'localhost' IDENTIFIED BY 'App#Shop-2027'
RETAIN CURRENT PASSWORD"
try 'App#Shop-2026'; try 'App#Shop-2027'
sudo mysql -e "ALTER USER 'shop_app'@'localhost' DISCARD OLD PASSWORD"
try 'App#Shop-2026'
shop_app@localhost
shop_app@localhost
ERROR 1045 (28000): Access denied for user 'shop_app'@'localhost' (using password: YES)
3.16.3 Account Locking, Renaming, and Dropping
After ana's third consecutive wrong password the server blocks her for a day, and even the right one fails. ACCOUNT UNLOCK lifts the block; ACCOUNT LOCK shuts an account on purpose:try() { mysql -uana -p"$1" -Nse "SELECT CURRENT_USER()" 2>&1 | grep -v Warning; }
try wrong1; try wrong2; try wrong3; try 'Ana#Pass-2026'
sudo mysql -e "ALTER USER 'ana'@'localhost' ACCOUNT UNLOCK"; try 'Ana#Pass-2026'
sudo mysql -e "ALTER USER 'ana'@'localhost' ACCOUNT LOCK"; try 'Ana#Pass-2026'
ERROR 1045 (28000): Access denied for user 'ana'@'localhost' (using password: YES)
ERROR 1045 (28000): Access denied for user 'ana'@'localhost' (using password: YES)
ERROR 3955 (HY000): Access denied for user 'ana'@'localhost'. Account is blocked for 1 day(s)
(1 day(s) remaining) due to 3 consecutive failed logins.
ERROR 3955 (HY000): Access denied for user 'ana'@'localhost'. Account is blocked for 1 day(s)
(1 day(s) remaining) due to 3 consecutive failed logins.
ana@localhost
ERROR 3118 (HY000): Access denied for user 'ana'@'localhost'. Account is locked.
ALTER USER 'ana'@'localhost' ACCOUNT UNLOCK;
GRANT SELECT ON shop.customers TO 'ana'@'localhost';
CREATE DEFINER = 'ana'@'localhost' VIEW customer_names AS SELECT id, name FROM customers;
RENAME USER 'ana'@'localhost' TO 'ana.souza'@'localhost';
SELECT * FROM customer_names LIMIT 1;
DROP VIEW customer_names;
DROP USER 'ana.souza'@'localhost';
ERROR 1449 (HY000) at line 5: The user specified as a definer ('ana'@'localhost') does not
exist
3.16.4 GRANT, REVOKE, and Privilege Levels
GRANT ... ON level TO account adds rights and REVOKE ... FROM removes them at five levels, each in its own grant table: global *.* (mysql.user), database shop.* (mysql.db), table (tables_priv), column (columns_priv) and routine (procs_priv). Both take effect at once, without FLUSH PRIVILEGES. GRANT no longer creates a missing account, so a typo fails, and with partial_revokes on, a global grant can exclude one schema:CREATE USER 'support'@'localhost' IDENTIFIED BY 'Help#Desk-2026';
GRANT SELECT (id, name, country) ON shop.customers TO 'support'@'localhost';
GRANT SELECT ON shop.orders TO 'suport'@'localhost';
SET PERSIST partial_revokes = ON;
CREATE USER 'auditor'@'localhost' IDENTIFIED BY 'Audit#Read-2026';
GRANT SELECT ON *.* TO 'auditor'@'localhost';
REVOKE SELECT ON mysql.* FROM 'auditor'@'localhost';
SHOW GRANTS FOR 'auditor'@'localhost';
ERROR 1410 (42000) at line 3: You are not allowed to create a user with GRANT
+-------------------------------------------------------+
| Grants for auditor@localhost |
+-------------------------------------------------------+
| GRANT SELECT ON *.* TO `auditor`@`localhost` |
| REVOKE SELECT ON `mysql`.* FROM `auditor`@`localhost` |
+-------------------------------------------------------+
3.16.5 Static and Dynamic Privileges
Static privileges are built into the server and stored as Y/N columns of the grant tables: SELECT, INSERT, UPDATE, DELETE, the DDL rights, and global ones such as FILE (server files), PROCESS (everyone's SQL) and CREATE USER. Dynamic privileges are registered by the server and its components, are global only, and are rows in mysql.global_grants. They split the deprecated SUPER into narrow rights: CONNECTION_ADMIN to kill others' sessions, SYSTEM_VARIABLES_ADMIN for SET GLOBAL, SYSTEM_USER to shield an account from ordinary admins.GRANT PROCESS, CONNECTION_ADMIN ON *.* TO 'auditor'@'localhost';
GRANT CONNECTION_ADMIN ON shop.* TO 'auditor'@'localhost';
SELECT user, priv FROM mysql.global_grants WHERE user = 'auditor';
REVOKE PROCESS, CONNECTION_ADMIN ON *.* FROM 'auditor'@'localhost';
ERROR 3619 (HY000) at line 2: Illegal privilege level specified for CONNECTION_ADMIN
+---------+------------------+
| user | priv |
+---------+------------------+
| auditor | CONNECTION_ADMIN |
+---------+------------------+
3.16.6 Roles, Default Roles, and SET ROLE
A role is a locked, named account that holds privileges. Grant privileges to the role and the role to accounts, and one change reaches every holder:CREATE ROLE 'shop_read', 'shop_write';
GRANT SELECT ON shop.* TO 'shop_read';
GRANT INSERT, UPDATE, DELETE ON shop.* TO 'shop_write';
CREATE USER 'report'@'localhost' IDENTIFIED BY 'Report#Only-2026';
GRANT 'shop_read' TO 'report'@'localhost';
q() { mysql -ureport -p'Report#Only-2026' -Nse "$1" 2>&1 | grep -v Warning; }
q "SELECT CURRENT_ROLE(); SELECT COUNT(*) FROM shop.orders"
q "SET ROLE 'shop_read'; SELECT CURRENT_ROLE(); SELECT COUNT(*) FROM shop.orders"
sudo mysql -e "SET DEFAULT ROLE 'shop_read' TO 'report'@'localhost'"
q "SELECT COUNT(*) FROM shop.orders; UPDATE shop.products SET price = 0"
NONE
ERROR 1142 (42000) at line 1: SELECT command denied to user 'report'@'localhost' for table
'orders'
`shop_read`@`%`
9
9
ERROR 1142 (42000) at line 1: UPDATE command denied to user 'report'@'localhost' for table
'products'
3.16.7 How SQL Injection Works
Injection happens when code pastes input into SQL text: the server cannot tell your characters from the visitor's, so input that closes a string literal adds SQL of its own. Trust no field, cookie or header.
Figure 3.13: One template, parsed with honest and hostile inputThis lookup concatenates its argument into SQL and, like too many
applications, runs as root:lookup() { sudo mysql -t shop -e "SELECT id, name FROM customers WHERE email = '$1'"; }
lookup "x' OR '1'='1" | tail -n 2
lookup "x' UNION SELECT user, plugin FROM mysql.user -- " | head -n 6
| 8 | Hana Sato |
+----+---------------+
+------------------+-----------------------+
| id | name |
+------------------+-----------------------+
| root | caching_sha2_password |
| shop_read | caching_sha2_password |
| shop_write | caching_sha2_password |
3.16.8 Server-Side Prepared Statements
PREPARE name FROM 'sql' parses a statement once, with ? where values go; EXECUTE name USING @var supplies values that are never parsed; DEALLOCATE PREPARE frees it. The listing replays the tautology through concatenated dynamic SQL, then through a placeholder:SET @email = "x' OR '1'='1";
SET @sql = CONCAT("SELECT COUNT(*) AS matched FROM customers WHERE email = '", @email, "'");
PREPARE unsafe FROM @sql;
EXECUTE unsafe;
PREPARE lookup FROM 'SELECT COUNT(*) AS matched FROM customers WHERE email = ?';
EXECUTE lookup USING @email;
PREPARE bad FROM 'SELECT COUNT(*) FROM ?';
DEALLOCATE PREPARE lookup;
+---------+
| matched |
+---------+
| 8 |
+---------+
+---------+
| matched |
+---------+
| 0 |
+---------+
ERROR 1064 (42000) at line 7: You have an error in your SQL syntax; check the manual that
corresponds to your MySQL server version for the right syntax to use near '?' at line 1
3.16.9 A Least-Privilege Account for a Web Application
Prepared statements close the hole; privileges limit the damage of one you missed. The web account gets rows in one database and nothing else, from localhost only (an app on another host gets 'shop_app'@'10.0.0.%' with REQUIRE SSL, never '%'):GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO 'shop_app'@'localhost';
ALTER USER 'shop_app'@'localhost' WITH MAX_USER_CONNECTIONS 50;
SHOW GRANTS FOR 'shop_app'@'localhost';
+----------------------------------------------------------------------------+
| Grants for shop_app@localhost |
+----------------------------------------------------------------------------+
| GRANT USAGE ON *.* TO `shop_app`@`localhost` |
| GRANT SELECT, INSERT, UPDATE, DELETE ON `shop`.* TO `shop_app`@`localhost` |
+----------------------------------------------------------------------------+
app() { mysql -ushop_app -p'App#Shop-2027' shop -e "$1" 2>&1 | grep -v Warning; }
app "SELECT id, name FROM customers WHERE email = 'x'
UNION SELECT user, plugin FROM mysql.user -- '"
app "DROP TABLE reviews"
app "SELECT * FROM customers INTO OUTFILE '/var/lib/mysql-files/c.csv'"
app "CREATE USER 'evil'@'%' IDENTIFIED BY 'Evil#Pass-2026'"
ERROR 1142 (42000) at line 1: SELECT command denied to user 'shop_app'@'localhost' for table
'user'
ERROR 1142 (42000) at line 1: DROP command denied to user 'shop_app'@'localhost' for table
'reviews'
ERROR 1227 (42000) at line 1: Access denied; you need (at least one of) the FILE privilege(s)
for this operation
ERROR 1227 (42000) at line 1: Access denied; you need (at least one of) the CREATE USER
privilege(s) for this operation