MySQL Security Best Practices
A MySQL database containing customer data, financial records, or personal information is a high-value target. Securing MySQL is not optional — it is a fundamental requirement. This guide covers the essential steps to harden a MySQL installation from the default (insecure) state to production-ready.
Complete Security Hardening Checklist
Item | Default State | Recommended State | How to Fix |
|---|---|---|---|
Root password | Empty or weak on some distros | Strong password | Run mysql_secure_installation |
Anonymous users | May exist | Removed | DELETE FROM mysql.user WHERE user = '' |
Test database | Exists, world-accessible | Removed | DROP DATABASE test |
Remote root login | Allowed on some installs | Disabled | DELETE root accounts with host != 'localhost' |
SSL/TLS | Off or auto-generated certs | Enabled with real certs | require_secure_transport = ON |
Port 3306 exposure | Bound to 0.0.0.0 | Bind to private IP or 127.0.0.1 | bind-address in my.cnf |
Password policy | No minimum requirements | MEDIUM or STRONG | validate_password component |
App user privileges | Often over-privileged | Only DML on own schema | GRANT SELECT,INSERT,UPDATE,DELETE |
At-rest encryption | Off | Enabled for sensitive tables | ENCRYPTION = 'Y' |
Audit logging | Off | Log logins + sensitive queries | Percona or Enterprise Audit plugin |
skip-grant-tables | Off | NEVER enabled in production | Remove from my.cnf if present |
General query log | Off | Off (performance impact) | Enable only for short debugging sessions |
Run mysql_secure_installation
After a fresh MySQL installation, always run the built-in hardening script immediately. It steps through the most critical security settings:
mysql_secure_installation
Sets or validates the root password strength using the validate_password component.
Removes anonymous user accounts that allow anyone to log in without a password.
Disables remote root login (root should only connect via localhost Unix socket).
Removes the test database that is accessible to anonymous users by default.
Reloads privilege tables to ensure all changes take effect immediately.
Remove Anonymous Users and Test Database
-- Check for anonymous users SELECT user, host, authentication_string FROM mysql.user WHERE user = ''; -- Remove all anonymous users DELETE FROM mysql.user WHERE user = ''; -- Remove the test database (accessible to anonymous users by default) DROP DATABASE IF EXISTS test; -- Remove test DB permissions from the db table DELETE FROM mysql.db WHERE Db = 'test' OR Db = 'test\_%'; -- Reload privileges FLUSH PRIVILEGES; -- Verify no anonymous users remain SELECT user, host FROM mysql.user WHERE user = ''; -- Should return: Empty set
Principle of Least Privilege
-- Application user: only DML on its own database CREATE USER 'webapp'@'10.0.0.%' IDENTIFIED WITH caching_sha2_password BY 'V3ry$tr0ng!Pass'; GRANT SELECT, INSERT, UPDATE, DELETE ON myapp.* TO 'webapp'@'10.0.0.%'; -- Read-only replica user: SELECT only CREATE USER 'readonly'@'10.0.0.%' IDENTIFIED BY 'R3adOnly!Pass'; GRANT SELECT ON myapp.* TO 'readonly'@'10.0.0.%'; -- DBA user: full access but only from bastion host CREATE USER 'dba'@'10.0.0.10' IDENTIFIED BY 'DBA$ecure99!'; GRANT ALL PRIVILEGES ON *.* TO 'dba'@'10.0.0.10' WITH GRANT OPTION; FLUSH PRIVILEGES; -- Never grant these to application users: -- FILE — reads OS files via LOAD DATA INFILE / INTO OUTFILE -- SUPER — bypasses read_only, can kill any connection -- DROP/CREATE/ALTER — can destroy schema -- GRANT OPTION — can grant their privileges to others -- PROCESS — sees all running queries including other users
root user from application code. A compromised app with root credentials has unlimited database access — including the ability to read OS files, destroy all databases, and create new privileged users.Role-Based Access Control (MySQL 8.0)
MySQL 8.0 added proper roles, letting you define a set of privileges once and assign it to many users. This is far easier to manage than granting privileges individually.
-- Create roles CREATE ROLE 'app_readwrite', 'app_readonly', 'app_admin'; -- Grant privileges to roles GRANT SELECT, INSERT, UPDATE, DELETE ON myapp.* TO 'app_readwrite'; GRANT SELECT ON myapp.* TO 'app_readonly'; GRANT ALL PRIVILEGES ON myapp.* TO 'app_admin'; -- Assign roles to users GRANT 'app_readwrite' TO 'webapp'@'10.0.0.%'; GRANT 'app_readonly' TO 'reporting'@'10.0.0.%'; GRANT 'app_admin' TO 'dba'@'10.0.0.10'; -- Set default roles (auto-activated on login) SET DEFAULT ROLE 'app_readwrite' TO 'webapp'@'10.0.0.%'; -- View role grants SHOW GRANTS FOR 'webapp'@'10.0.0.%' USING 'app_readwrite';
Network Hardening
# my.cnf — bind to private network interface only (not 0.0.0.0) [mysqld] bind-address = 10.0.0.5 # Only listen on the private IP # Disable skip-grant-tables — NEVER leave this in production my.cnf # skip-grant-tables <-- REMOVE if present
# Allow MySQL only from app server IPs (Ubuntu UFW) ufw allow from 10.0.0.5 to any port 3306 ufw allow from 10.0.0.6 to any port 3306 ufw deny 3306 # Or with iptables iptables -A INPUT -p tcp --dport 3306 -s 10.0.0.0/24 -j ACCEPT iptables -A INPUT -p tcp --dport 3306 -j DROP
Block port 3306 from the public internet at the firewall/security group level.
Only allow connections from your application servers and bastion/jump hosts.
Use a VPN or SSH tunnel for DBA access rather than opening port 3306 to the internet.
Set bind-address to your private IP — not 0.0.0.0 — to prevent accidental exposure.
SSL/TLS Setup
# my.cnf — enable SSL with real certificates [mysqld] ssl_ca = /etc/mysql/certs/ca.pem ssl_cert = /etc/mysql/certs/server-cert.pem ssl_key = /etc/mysql/certs/server-key.pem require_secure_transport = ON # reject non-SSL connections entirely
-- Require SSL for a specific user ALTER USER 'webapp'@'%' REQUIRE SSL; -- Require a specific cipher for high-security environments ALTER USER 'webapp'@'%' REQUIRE CIPHER 'ECDHE-RSA-AES256-GCM-SHA384'; -- Verify SSL status of your current connection SHOW STATUS LIKE 'Ssl_cipher'; -- Or: s (client status command) -- Check SSL variables SHOW VARIABLES LIKE '%ssl%';
InnoDB Data-at-Rest Encryption
InnoDB tablespace encryption protects data pages on disk — useful against physical media theft or unauthorized filesystem access. It does not protect against authenticated MySQL users with SELECT privileges.
# my.cnf — enable keyring plugin (must load before InnoDB initializes) [mysqld] early-plugin-load = keyring_file.so keyring_file_data = /var/lib/mysql-keyring/keyring # MySQL 8.0.34+ uses component-based keyring # early-plugin-load = component_keyring_file
-- Verify keyring plugin is loaded SELECT PLUGIN_NAME, PLUGIN_STATUS FROM information_schema.PLUGINS WHERE PLUGIN_NAME LIKE '%keyring%'; -- Create a new encrypted table CREATE TABLE sensitive_data ( id INT NOT NULL AUTO_INCREMENT, ssn VARCHAR(20), PRIMARY KEY (id) ) ENCRYPTION = 'Y'; -- Encrypt an existing table (requires a full table rebuild) ALTER TABLE sensitive_data ENCRYPTION = 'Y'; -- Create an encrypted database (all new tables inherit encryption) CREATE DATABASE secure_db ENCRYPTION = 'Y'; -- Check which tables are encrypted SELECT TABLE_SCHEMA, TABLE_NAME, CREATE_OPTIONS FROM information_schema.TABLES WHERE CREATE_OPTIONS LIKE '%ENCRYPTION%';
Password Policy Plugin
-- Install the validate_password component (MySQL 8.0)
INSTALL COMPONENT 'file://component_validate_password';
-- Configure minimum requirements
SET GLOBAL validate_password.policy = MEDIUM; -- LOW, MEDIUM, or STRONG
SET GLOBAL validate_password.length = 12;
SET GLOBAL validate_password.mixed_case_count = 1;
SET GLOBAL validate_password.number_count = 1;
SET GLOBAL validate_password.special_char_count = 1;
-- Check current policy
SHOW VARIABLES LIKE 'validate_password%';
-- Test a password against the policy
SELECT VALIDATE_PASSWORD_STRENGTH('MyP@ssw0rd!5');
-- Returns 0-100: 0=failed, 25=LOW, 50=MEDIUM, 75=STRONG, 100=very strongLevel | Requirements |
|---|---|
LOW (0) | Minimum length only. |
MEDIUM (1) | Minimum length + mixed case + digits + special characters. |
STRONG (2) | MEDIUM requirements + password must not match a dictionary file. |
Audit Logging
MySQL Enterprise Edition includes MySQL Enterprise Audit. For Community Edition, the most common options are the Percona Audit Log Plugin or the McAfee MySQL Audit Plugin.
# Install Percona Audit Log Plugin # Add to my.cnf first, then restart: [mysqld] plugin-load-add = audit_log.so audit_log_policy = ALL # ALL, QUERIES, LOGINS, or NONE audit_log_format = JSON # JSON or OLD (legacy) audit_log_file = /var/log/mysql/audit.log audit_log_rotate_on_size = 1073741824 # Rotate at 1GB
-- After installing, verify audit is active
SHOW PLUGINS; -- should show audit_log as ACTIVE
-- Audit all current grants periodically
SELECT CONCAT('SHOW GRANTS FOR ', QUOTE(user), '@', QUOTE(host), ';') AS stmt
FROM mysql.user
WHERE user != ''
ORDER BY user, host;
-- Check a specific user
SHOW GRANTS FOR 'webapp'@'10.0.0.%';Monitoring for Intrusion
-- Watch for failed login attempts (performance_schema) SELECT event_name, count_star AS failed_logins, last_seen FROM performance_schema.events_statements_summary_by_digest WHERE digest_text LIKE '%Access denied%' ORDER BY count_star DESC LIMIT 10; -- Monitor currently running queries for suspicious patterns SELECT id, user, host, db, command, time AS seconds_running, info AS query_text FROM information_schema.PROCESSLIST WHERE time > 30 -- running more than 30 seconds AND command != 'Sleep' ORDER BY time DESC; -- Kill a suspicious long-running query KILL QUERY 1234; -- kill just the query (keep connection) KILL 1234; -- kill the connection entirely -- Check for unusual privilege grants made recently (via general log) SHOW VARIABLES LIKE 'general_log%'; -- Enable briefly: SET GLOBAL general_log = ON; -- Then review: SELECT * FROM mysql.general_log WHERE argument LIKE '%GRANT%'; -- Disable: SET GLOBAL general_log = OFF;
Disable skip-grant-tables
skip-grant-tables disables all MySQL authentication — any user can log in as root without a password. It is only for emergency password recovery and must never be left enabled in production.
# Check if skip-grant-tables is in your config files grep -r 'skip.grant' /etc/mysql/ # If found — REMOVE IT from my.cnf, then restart: sudo systemctl restart mysql # Verify it is not active SHOW VARIABLES LIKE 'skip_grant_tables'; -- Value should be OFF
skip-grant-tables is enabled in production, your MySQL server has zero authentication. Anyone who can connect to port 3306 has full root access to all databases.Regular Security Patches
Subscribe to the MySQL security mailing list or monitor the MySQL CVE list at cve.mitre.org.
Apply MySQL minor-version updates promptly — they often contain critical security fixes.
Test updates in staging before applying to production.
Keep the host OS patched — a root exploit on the OS bypasses all MySQL security.
Audit user accounts quarterly: remove accounts for former employees immediately.
Disable the General Query Log in Production
The general query log records every query — including plaintext passwords in authentication queries and sensitive data in SELECT results. It is a security risk and a severe performance overhead in production.
-- Check if general log is on SHOW VARIABLES LIKE 'general_log'; -- It must be OFF in production SET GLOBAL general_log = OFF; -- If you need it temporarily for debugging: SET GLOBAL general_log = ON; SET GLOBAL general_log_file = '/tmp/mysql_general.log'; -- ... debug your issue ... SET GLOBAL general_log = OFF; -- Prefer the slow query log instead — only logs slow queries SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1; -- log queries taking > 1 second
Verify Security Configuration
-- Quick security audit queries -- 1. Check for anonymous users SELECT user, host FROM mysql.user WHERE user = ''; -- 2. Check root remote access SELECT user, host FROM mysql.user WHERE user = 'root' AND host != 'localhost'; -- 3. List users with FILE privilege (can read OS files) SELECT user, host FROM mysql.user WHERE File_priv = 'Y'; -- 4. List users with SUPER privilege SELECT user, host FROM mysql.user WHERE Super_priv = 'Y'; -- 5. Check SSL requirement per user SELECT user, host, ssl_type FROM mysql.user WHERE ssl_type = ''; -- ssl_type empty = SSL not required for that user -- 6. Check if skip-grant-tables is active (should be OFF) SHOW VARIABLES LIKE 'skip_grant_tables'; -- 7. Check require_secure_transport SHOW VARIABLES LIKE 'require_secure_transport';
Summary: Security Hardening Checklist
Item | Recommended State | Command to Verify |
|---|---|---|
Anonymous users | None exist | SELECT user FROM mysql.user WHERE user = '' |
Test database | Removed | SHOW DATABASES |
Remote root login | Disabled | SELECT host FROM mysql.user WHERE user='root' AND host!='localhost' |
SSL/TLS | require_secure_transport = ON | SHOW VARIABLES LIKE 'require_secure_transport' |
Password policy | validate_password MEDIUM+ | SHOW VARIABLES LIKE 'validate_password%' |
Port 3306 firewall | Blocked from internet | External firewall rule check |
Audit logging | Enabled | SHOW PLUGINS (audit_log = ACTIVE) |
skip-grant-tables | OFF | SHOW VARIABLES LIKE 'skip_grant_tables' |
General query log | OFF | SHOW VARIABLES LIKE 'general_log' |
App user privileges | Only DML on own schema | SHOW GRANTS FOR 'webapp'@'%' |
Privilege Hierarchy Reference
Privilege | What it allows | Safe for app user? |
|---|---|---|
SELECT | Read rows from tables | Yes |
INSERT | Add rows to tables | Yes |
UPDATE | Modify existing rows | Yes |
DELETE | Remove rows from tables | Yes |
CREATE | Create tables, indexes, databases | No (for app; yes for migrations user) |
ALTER | Modify table structures | No |
DROP | Delete tables or databases | No |
TRUNCATE (part of DROP) | Empty a table instantly | No |
FILE | Read/write OS files via SQL | Never — very dangerous |
SUPER | Bypass read_only, kill any connection | No |
GRANT OPTION | Delegate own privileges to others | No |
PROCESS | See all running queries in SHOW PROCESSLIST | No (reveals other users data) |
REPLICATION SLAVE | Connect as a replication replica | Only for replication user |
ALL PRIVILEGES | Everything above | Never for application users |
Detecting Over-Privileged Users
-- Find users with FILE privilege (very dangerous — can read OS files)
SELECT user, host FROM mysql.user WHERE File_priv = 'Y' AND user != 'root';
-- Find users with SUPER privilege (can bypass read_only, kill all connections)
SELECT user, host FROM mysql.user WHERE Super_priv = 'Y' AND user != 'root';
-- Find users with DROP or CREATE privileges
SELECT user, host FROM mysql.user
WHERE Create_priv = 'Y' OR Drop_priv = 'Y' OR Alter_priv = 'Y'
ORDER BY user;
-- Find users with global (*.* level) privileges
SELECT user, host, Grant_priv, Super_priv, File_priv
FROM mysql.user
WHERE Select_priv = 'Y'
AND user NOT IN ('root', 'mysql.sys', 'mysql.infoschema', 'mysql.session')
ORDER BY user;
-- Revoke a specific dangerous privilege from a user
REVOKE FILE ON *.* FROM 'webapp'@'%';
REVOKE SUPER ON *.* FROM 'webapp'@'%';
FLUSH PRIVILEGES;