
MySQL incidents are usually privileges, injection, or a bind address you left open.
FILE privilege, a user that can write to a web root, and a query built from a sort parameter are still the findings that become a dump. Version age matters. Grants matter more on a box that is already reachable.
The usual mistake is bumping 8.4 and never running SHOW GRANTS.
This page is five controls that close those holes, and the privilege chapter you should read on the version you actually run.
MySQL 8.4.0 shipped on 30 April 2024 as the LTS line. 8.4 privileges chapter on 22 August 2026. It still says be careful granting FILE.The control is an account that cannot read the host, a client that will not run a second statement, and TLS the app actually verifies.
This page is defense. It does not walk a payload. SQL that still concatenates user text is the injection guide. FILE that becomes a path on disk is the path traversal guide, rewritten as a prefix check, not a pivot cookbook. The process user and the host firewall sit on the Ubuntu hardening guide.
FILE turns a DML account into a host walk. Rip that privilege off the engine, leave stacked SQL off in mysql2, and verify TLS on the hop from webapp.
SecureCoding
The app user is not a DBA
Identifiers on this page stay webapp, appdb, and 10.0.2.% for the app subnet. Do not connect the site as root. Do not grant ALL PRIVILEGES. The 8.4 manual’s “Making MySQL Secure Against Attackers” chapter says never run the server as Unix root, and it says do not grant FILE to a non-admin account.
CREATE USER 'webapp'@'10.0.2.%'
IDENTIFIED WITH caching_sha2_password BY RANDOM PASSWORD;
GRANT SELECT, INSERT, UPDATE, DELETE
ON appdb.* TO 'webapp'@'10.0.2.%';
That is the whole grant. No FILE. No PROCESS. No SUPER. No GRANT OPTION. No CREATE, DROP, or ALTER on the live account. Migrations use a second user you do not put in the web pool. Backups use a third user with the minimum needed for the dump, not SUPER.
'webapp'@'%' is the leftover that accepts a connection from anywhere the network can reach port 3306. Pin the host to the app subnet or to a single IP. The database does not listen on 0.0.0.0 unless you have no private NIC. The Ubuntu page is the packet filter. This page is the account.
FILE is the leftover host walk
FILE lets the account use the server’s OS user to read or write files the process can see. The 8.4 privileges page says a FILE holder can read any world-readable file on the host, including files in the data directory, and can write with the rights of mysqld. That is how a database account becomes a filesystem account. The old name for that walk was a pivot. The defense is to never grant it, and to pin writes if an admin job truly needs them.
Revoke FILE even if you think nobody uses it. Shared hosts and copied grants keep it around. Run REVOKE FILE ON *.* against the same webapp account you created, then FLUSH PRIVILEGES.
secure_file_priv is the second belt for accounts that must move files. A directory value limits import and export to that path. NULL disables both. 8.4 FILE description for that pair. If you do not run a dump that needs OUTFILE, set NULL. The app that serves user uploads should not use FILE at all. It should use the path-traversal prefix check on a directory you own.
Refuse stacked statements
A stacked statement is a second SQL command in the same client call, after a semicolon. mysql2 documents multipleStatements as default false, and the type comment says enabling it exposes you to injection. mysql2 connection typings for that sentence. PHP has a separate mysqli_multi_query. PDO MySQL does not give you a friendly multi-query helper. JDBC names allowMultiQueries. Leave every one of those off.
const mysql = require("mysql2/promise");
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
multipleStatements: false,
ssl: {
rejectUnauthorized: true,
ca: process.env.DB_SSL_CA,
},
});
pool is the name the later TLS snippet reuses. execute sends a prepared statement. query with a concatenated string is the hatch. Even with stacked SQL off, a single statement can still be a template. The injection page is that hatch. This page is the client flag.
Grep for the switch that turns it back on:
rg -n "multipleStatements\\s*:\\s*true|mysqli_multi_query|allowMultiQueries" --glob '!node_modules'
A hit is a review. Migrations that want two statements should be two calls, or a tool that is not the web pool.
local_infile stays off
The 8.4 page “Security Considerations for LOAD DATA LOCAL” says local_infile is disabled by default, a change from older MySQL. The client library in official binaries is compiled with ENABLED_LOCAL_INFILE disabled. Both sides must be on for a LOCAL load to work. Keep both off for the web tier.
LOCAL is the shape where the server asks the client to send a file. In a web app the client is your Node or PHP process, so a statement that names a path can read any file that process can read. That is a file-read through SQL. You do not need FILE on the server account for LOCAL. You need the client willing to send the bytes. Turn the willingness off.
[mysqld]
local_infile=OFF
secure_file_priv=NULL
SHOW VARIABLES WHERE Variable_name IN (
'local_infile',
'secure_file_priv',
'require_secure_transport'
);
Expect local_infile OFF, file import disabled or pinned to a directory you can name, and the TLS flag ON after the next section. MySQL Shell dump utilities require LOCAL on the target for some loads. Turn it on for that job, then turn it off. Do not leave it on because a migration once needed it.
caching_sha2_password, not native
8.4’s default plugin is caching_sha2_password. mysql_native_password is deprecated as of 8.0.34, disabled by default in 8.4, and removed in 9.0.0. 4 native-plugin page for those three dates. 8.4 will still start it if you pass mysql_native_password=ON. Do not.
[mysqld]
mysql_native_password=OFF
authentication_policy=*,,
default_authentication_plugin was removed in 8.4.0. Use authentication_policy. Then look at the rows you already have:
SELECT user, host, plugin FROM mysql.user
WHERE user NOT IN ('mysql.sys', 'mysql.session', 'mysql.infoschema');
Every leftover mysql_native_password row is a ticket. Move it with ALTER USER... IDENTIFIED WITH caching_sha2_password and a new secret. Old PHP and some JDBC builds needed a flag to speak SHA-2. Those builds are the compatibility problem. The plugin on the server is not. Do not re-enable native auth to silence a 2018 client.
TLS to the database
HSTS on the browser origin does not encrypt the hop from app to MySQL. The 8.4 encrypted-connections chapter says set require_secure_transport=ON so the server rejects a plain TCP login. Unix socket on the same host is still allowed. A remote webapp login is not, unless it uses TLS.
[mysqld]
require_secure_transport=ON
ssl_ca=ca.pem
ssl_cert=server-cert.pem
ssl_key=server-key.pem
The client must verify. ssl: true with rejectUnauthorized: false is a warm handshake that still accepts any certificate. The pool on this page already sets rejectUnauthorized: true and a CA file. mysql CLI uses --ssl-mode=VERIFY_IDENTITY. A managed cloud MySQL that offers a CA bundle is the same rule: put the bundle in DB_SSL_CA. I have not confirmed every managed vendor’s TLS default on 22 August 2026. Read the vendor’s TLS page and then run the SHOW VARIABLES query above.
Do not put the database on a public address “because TLS.” TLS is the wire. The host allowlist is still webapp on 10.0.2.% and a firewall that only the app subnet can hit. The Ubuntu guide is that packet path.
The app still owns the bind
Least privilege does not rewrite a string. FILE off does not rewrite a string. jiggawatts’s point is the one that survives every GRANT pass: if the driver substitutes text into the statement, you do not have a parameter. mysql2 execute sends the SQL with ? and the values separately. query with a template literal is the hatch. ORMs have the same hatch under other names. Those names live on the injection page.
const ticketId = req.params.id;
const orgId = req.actor.orgId;
const [rows] = await pool.execute(
"SELECT id, memo, status FROM tickets WHERE id = ? AND org_id = ?",
[ticketId, orgId],
);
Identifiers stay pool, ticketId, and orgId. The tenant predicate is still required. A locked MySQL user that can SELECT every row in appdb will return Bob’s ticket if the WHERE only names id. That is IDOR, not a MySQL CVE. This page does not replace the object check.
Prove the locks
You are not running a payload. You are printing grants, variables, plugins, and the client flags in your repo.
- Connect as an admin and run the SHOW GRANTS and SHOW VARIABLES blocks above for
webapp. - Confirm
pluginiscaching_sha2_passwordfor that user. - From a laptop off the app subnet, a login as
webappshould fail on host match or on the firewall. - From the app host, a connection that omits SSL should fail if the TLS flag is ON and you are not on the Unix socket.
mysql -u webapp -e "SHOW GRANTS"
# Expect: DML only, appdb.* only
# Expect: no FILE, no SUPER, no GRANT OPTION
SELECT user, host, plugin FROM mysql.user WHERE user = 'webapp';
# Expect: caching_sha2_password
rg -n "multipleStatements\\s*:\\s*true|rejectUnauthorized\\s*:\\s*false|IDENTIFIED WITH mysql_native_password|GRANT ALL" --glob '!node_modules'
A hit on rejectUnauthorized: false is a TLS ticket. A hit on GRANT ALL is an account ticket. A hit on multipleStatements: true is a stacked-SQL ticket. After those are clean, open the injection page and grep the ORM hatches. The database locks do not close a template.
| Lock | 8.4 default to trust | What you still set |
|---|---|---|
| Account | no automatic webapp user | DML only on appdb.*, host pinned |
| FILE | not granted to a new user | REVOKE on copies, disable file import |
| Stacked SQL | mysql2 flag false | grep for true, no mysqli_multi_query |
| LOCAL INFILE | server and official client off | keep OFF on the web tier |
| Auth plugin | SHA-2 default, native disabled | migrate leftover native rows |
| TLS | server can offer it | transport required, verify CA |
Questions we keep getting
Does revoking FILE stop SQL injection?
No. FILE stops a successful statement from reading the host. The statement still runs if the app built it from a string. Parameterize. Then revoke FILE so a later miss cannot walk the disk. The injection guide is the first job.
Can I leave native auth on for one old report box?
Not on 8.4 if you can replace the client. Native is disabled on purpose and gone in 9.0. Give that box a SHA-2 account, or put it behind a tunnel that uses a modern connector. Do not set mysql_native_password=ON on the shared server.
Is require_secure_transport enough by itself?
It refuses a cleartext TCP login. The client can still skip identity checks. Set rejectUnauthorized: true and a CA. Pin webapp to the app subnet. TLS without a host grant is an encrypted door on the public internet.



