MySQL · Security Hardening · Production Guide

MySQL security hardening:
the complete guide

MySQL is the #1 target for database attacks. Port 3306 is scanned millions of times per day worldwide — attackers find exposed instances in minutes and exploit weak credentials, anonymous users, and default configurations. This guide covers every hardening step for MySQL 8.0+ in production.

3306
Most scanned database port
Port 3306 receives more automated scan traffic than almost any other database port — millions of probes per day globally.
15 min
Average time to first attack
A newly exposed MySQL instance on a public IP typically receives its first brute force attempt within 15 minutes of going online.
72%
Of breaches involve database access
The majority of data breaches ultimately involve unauthorized database access — either directly or through compromised application credentials.

Step 1: Restrict the bind address

The single most impactful change you can make is ensuring MySQL only listens on localhost. By default on many Linux distributions, MySQL binds to 0.0.0.0 — meaning it accepts connections from any IP on the internet. This must be changed immediately on any production server.

Edit /etc/mysql/mysql.conf.d/mysqld.cnf (Ubuntu/Debian) or /etc/my.cnf (CentOS/RHEL):

[mysqld]

# IMPORTANT: restrict MySQL to localhost only

# Change from 0.0.0.0 (all interfaces) to 127.0.0.1 (loopback only)

bind-address = 127.0.0.1

# If using MySQL 8.0.13+ with X Protocol, also restrict mysqlx

mysqlx-bind-address = 127.0.0.1

# Optional: disable the X Protocol entirely if not using it

# mysqlx = 0

# Disable local file loading (prevents LOAD DATA INFILE attacks)

local_infile = 0

# Disable symbolic link following (prevents symlink attacks)

symbolic-links = 0

After editing, restart MySQL:

sudo systemctl restart mysql

Verify that MySQL is no longer listening on 0.0.0.0:

sudo ss -tlnp | grep 3306

# Expected: 127.0.0.1:3306 (NOT 0.0.0.0:3306)

If you need remote access to MySQL, use an SSH tunnel instead of opening port 3306. Connect via ssh -L 3307:127.0.0.1:3306 user@server and then connect your local client to 127.0.0.1:3307.

Step 2: Remove anonymous users and test databases

A fresh MySQL installation creates anonymous user accounts and a test database that anyone can access. These must be removed before the server goes into production. Run mysql_secure_installation to handle this interactively, or run the SQL commands manually for automation.

Option A — Interactive (recommended for first setup):

sudo mysql_secure_installation

# Answer prompts:

# - Set root password: YES

# - Remove anonymous users: YES

# - Disallow root login remotely: YES

# - Remove test database: YES

# - Reload privilege tables: YES

Option B — SQL commands (for scripted/automated hardening):

-- Connect as root

mysql -u root -p

-- Remove all anonymous accounts

DELETE FROM mysql.user WHERE User = '';

-- Remove test database

DROP DATABASE IF EXISTS test;

DELETE FROM mysql.db WHERE Db = 'test' OR Db = 'test\_%';

-- Disable remote root login (root should only connect from localhost)

DELETE FROM mysql.user WHERE User = 'root' AND Host NOT IN ('localhost', '127.0.0.1', '::1');

-- Apply changes immediately

FLUSH PRIVILEGES;

Verify no anonymous accounts remain:

SELECT user, host, authentication_string FROM mysql.user WHERE user = '';

# Expected: Empty set (0 rows)

Step 3: Enforce strong password policy

MySQL 8.0 ships with the validate_password component (previously a plugin in 5.7). Enable it and configure strict requirements so that weak passwords are rejected at creation time.

Install and configure the password validation component:

-- Install the validate_password component (MySQL 8.0+)

INSTALL COMPONENT 'file://component_validate_password';

-- Set policy to STRONG (requires: length, mixed case, digits, special chars)

SET GLOBAL validate_password.policy = STRONG;

-- Require at least 14 characters

SET GLOBAL validate_password.length = 14;

-- Require at least 2 uppercase, 2 lowercase, 2 digits, 2 special characters

SET GLOBAL validate_password.mixed_case_count = 2;

SET GLOBAL validate_password.number_count = 2;

SET GLOBAL validate_password.special_char_count = 2;

-- Block passwords that match the username

SET GLOBAL validate_password.check_user_name = ON;

Or persist settings in my.cnf under [mysqld]:

[mysqld]

# Load the validate_password component

early-plugin-load = validate_password.so

# Password policy: STRONG requires length + mixed case + digits + special chars

validate_password.policy = STRONG

validate_password.length = 14

validate_password.mixed_case_count = 2

validate_password.number_count = 2

validate_password.special_char_count = 2

validate_password.check_user_name = ON

# Use the modern caching_sha2_password authentication plugin

default_authentication_plugin = caching_sha2_password

Verify current password policy:

SHOW VARIABLES LIKE 'validate_password%';

Additionally, use caching_sha2_password (the MySQL 8.0 default) instead of the older mysql_native_password. Verify your root account uses it:

SELECT user, host, plugin FROM mysql.user WHERE user = 'root';

# Expected plugin: caching_sha2_password

Step 4: Apply least-privilege user accounts

Every application should connect with a dedicated MySQL user that has only the permissions it needs — nothing more. Never use the root account for application connections, and never grant GRANT ALL to application users.

Create a dedicated application user with minimal privileges:

-- Create a dedicated application database

CREATE DATABASE myapp_production;

-- Create user restricted to localhost with a strong password

CREATE USER 'myapp_user'@'localhost' IDENTIFIED BY 'Str0ng!P@ssw0rd#2026';

-- Grant ONLY what the application needs (not GRANT ALL)

GRANT SELECT, INSERT, UPDATE, DELETE ON myapp_production.* TO 'myapp_user'@'localhost';

-- For apps that manage schema (migrations), also allow DDL

GRANT CREATE, DROP, ALTER, INDEX ON myapp_production.* TO 'myapp_user'@'localhost';

-- Apply changes

FLUSH PRIVILEGES;

-- Never do this for application users:

-- GRANT ALL PRIVILEGES ON *.* TO 'myapp_user'@'localhost' WITH GRANT OPTION; -- DANGEROUS

Read-only reporting user

GRANT SELECT ON myapp.* TO

'reports'@'localhost'

IDENTIFIED BY '...';

Backup user

GRANT SELECT, LOCK TABLES,

SHOW VIEW, EVENT,

TRIGGER ON *.* TO

'backup'@'localhost';

Replication user

GRANT REPLICATION SLAVE,

REPLICATION CLIENT ON *.*

TO 'replica'@'10.0.0.%'

IDENTIFIED BY '...';

Deploy / migration user

GRANT SELECT, INSERT, UPDATE,

DELETE, CREATE, DROP,

ALTER, INDEX ON myapp.*

TO 'deploy'@'localhost';

Audit all users and their grants to spot over-privileged accounts:

SELECT user, host FROM mysql.user;

SHOW GRANTS FOR 'appuser'@'localhost';

Step 5: Enable audit logging

Logging database activity is essential for detecting unauthorized access and forensic investigation after a breach. MySQL Community Edition supports general_log for basic query logging. MySQL Enterprise Edition adds a full audit plugin. For most production setups, enabling the general log with rotation is a pragmatic baseline.

Enable query logging in my.cnf:

[mysqld]

# Enable general query log

general_log = ON

general_log_file = /var/log/mysql/general.log

# Enable slow query log (captures queries taking > 1 second)

slow_query_log = ON

slow_query_log_file = /var/log/mysql/slow.log

long_query_time = 1

log_queries_not_using_indexes = ON

# Log all connections and disconnections

log_connections = ON

log_disconnections = ON

Or enable/disable at runtime without restart:

SET GLOBAL general_log = 'ON';

SET GLOBAL general_log_file = '/var/log/mysql/general.log';

Set up log rotation with logrotate — create /etc/logrotate.d/mysql-general:

/var/log/mysql/general.log {

daily

rotate 30

compress

delaycompress

missingok

notifempty

sharedscripts

postrotate

# Tell MySQL to re-open the log file after rotation

mysql -u root -p$(cat /etc/mysql/root.password) \

-e 'FLUSH LOGS;' 2>/dev/null || true

endscript

}

Note that general_log logs every query and can be very verbose on busy servers. Consider enabling it only temporarily for audits, or use the slow query log (slow_query_log) as a lighter alternative that captures queries above a time threshold.

Step 6: Network security and SSL/TLS

Even with MySQL bound to localhost, you should enforce encrypted connections for any clients that connect over the network. MySQL 8.0 enables SSL/TLS by default, but you must verify it's working and optionally require it per user. For internet-facing MySQL (not recommended), firewall rules are mandatory.

Verify SSL is enabled in MySQL 8.0:

SHOW VARIABLES LIKE 'have_ssl';

# Expected: Value = YES

SHOW VARIABLES LIKE 'tls_version';

# Expected: TLSv1.2,TLSv1.3

Require secure transport globally in my.cnf:

[mysqld]

# Paths to SSL certificates (auto-generated by MySQL 8.0 if not specified)

ssl_ca = /var/lib/mysql/ca.pem

ssl_cert = /var/lib/mysql/server-cert.pem

ssl_key = /var/lib/mysql/server-key.pem

# Force all connections to use SSL/TLS (MySQL 8.0+)

require_secure_transport = ON

# Enforce minimum TLS version (disable old/vulnerable versions)

tls_version = TLSv1.2,TLSv1.3

Or require SSL per user account:

ALTER USER 'appuser'@'%' REQUIRE SSL;

FLUSH PRIVILEGES;

Firewall rules — block port 3306 from all external sources (iptables):

# Block all incoming traffic to port 3306 from external IPs

iptables -A INPUT -p tcp --dport 3306 -j DROP

# Allow MySQL only from localhost (loopback interface)

iptables -I INPUT -i lo -p tcp --dport 3306 -j ACCEPT

# If you have an application server on a private network (e.g., 10.0.0.0/8)

# allow it explicitly, and deny everything else

iptables -I INPUT -s 10.0.0.0/8 -p tcp --dport 3306 -j ACCEPT

# Save rules (Ubuntu/Debian)

sudo iptables-save > /etc/iptables/rules.v4

Or using nftables (modern Linux):

# /etc/nftables.conf — add inside the inet filter table

table inet filter {

chain input {

type filter hook input priority 0; policy drop;

# Allow loopback

iif lo accept

# Block external access to MySQL port 3306

tcp dport 3306 iif != lo drop

# Allow established connections

ct state established,related accept

}

}

Step 7: Automate MySQL security monitoring

Manual hardening is a one-time task. Ongoing security requires continuous monitoring: detecting brute force attempts in real time, alerting on exposed ports, and receiving advisories when MySQL CVEs affect your installed version. This is where Defensia complements your manual hardening steps.

1

Install the agent in 30 seconds

One curl command installs the Defensia agent as a systemd service. MySQL monitoring begins automatically — no configuration file to edit.

2

Port scan on startup

The agent checks if port 3306 is accessible from outside. If MySQL is bound to 0.0.0.0, you receive an advisory in your dashboard within seconds.

3

Real-time brute force detection

Defensia tails MySQL authentication logs and detects repeated "Access denied" failures. Attacking IPs are automatically banned via iptables after the threshold is crossed.

4

CVE advisories for your MySQL version

The agent reports your MySQL version via heartbeat. Defensia cross-references it against the NVD CVE database and surfaces critical advisories in your dashboard with CVSS scores.

5

Dashboard visibility

All MySQL security events appear in your Defensia dashboard — attack timelines, blocked IPs, port exposure advisories, and CVE matches for your installed version.

Install the Defensia agent

curl -fsSL https://defensia.cloud/install.sh | sudo bash -s -- --token YOUR_TOKEN

Works on Ubuntu, Debian, CentOS, Rocky, AlmaLinux, Amazon Linux. MySQL monitoring starts automatically after installation.

What Defensia does for MySQL

Defensia's agent watches your MySQL server continuously — detecting threats that slip past manual hardening and reacting faster than any human can.

Port scan detection

Detects if MySQL port 3306 is publicly accessible on startup and whenever the agent restarts. Creates a security advisory with remediation instructions if the port is exposed.

Brute force protection

Monitors MySQL error logs for authentication failures. After repeated failures from the same IP, the attacker is automatically banned via iptables/nftables — no manual intervention needed.

CVE advisories

Defensia tracks your installed MySQL version and alerts you when critical CVEs are published for it. You see the CVSS score, description, and fix version directly in your dashboard.

Hardening scan

On-demand hardening scan checks your MySQL configuration against security best practices — bind address, anonymous users, remote root login, and more. Results include prioritized fixes.

Zero-configuration setup

Defensia auto-detects MySQL log paths across distributions. No need to specify log file locations — the agent finds and monitors them automatically after install.

Automated IP banning

Bans integrate with iptables or nftables. Banned IPs are synced to the dashboard so you can review, whitelist, or extend bans. Ban duration is configurable per server.

MySQL authentication failures detected in real time

[DEFENSIA] MySQL auth failure: root@185.224.128.47 — attempt 1/5

[DEFENSIA] MySQL auth failure: root@185.224.128.47 — attempt 2/5

[DEFENSIA] MySQL auth failure: admin@185.224.128.47 — attempt 3/5

[DEFENSIA] MySQL auth failure: mysql@185.224.128.47 — attempt 4/5

[DEFENSIA] MySQL auth failure: root@185.224.128.47 — attempt 5/5

[DEFENSIA] BRUTE FORCE DETECTED — banning 185.224.128.47 via iptables

[DEFENSIA] IP 185.224.128.47 banned for 24h — event logged to dashboard

Frequently asked questions

What is the most important MySQL security setting?

Restricting the bind address to 127.0.0.1 is the single most impactful change. If MySQL is bound to 0.0.0.0, it is accessible to the entire internet and will be attacked within minutes. All other hardening steps are secondary to ensuring MySQL only listens on the loopback interface.

Is mysql_secure_installation enough to secure MySQL?

It is a good starting point. mysql_secure_installation removes anonymous users, the test database, and optionally sets a root password and disables remote root login. However, it does not configure password validation, SSL/TLS, audit logging, or firewall rules — all of which are covered in this guide.

Should I use mysql_native_password or caching_sha2_password?

Use caching_sha2_password, which is the default in MySQL 8.0. It provides stronger password hashing (SHA-256) and supports a caching mechanism for performance. Only use mysql_native_password if you have legacy clients that do not support the newer plugin — and plan to migrate those clients as soon as possible.

How do I check if my MySQL server is exposed to the internet?

Run `sudo ss -tlnp | grep 3306` on the server. If you see 0.0.0.0:3306, MySQL is listening on all interfaces and is publicly accessible. It should show 127.0.0.1:3306 instead. You can also use an external port scanner or install Defensia, which checks this automatically at startup and alerts you.

How does Defensia detect MySQL brute force attacks?

The Defensia agent tails MySQL error logs (typically /var/log/mysql/error.log or syslog) and looks for repeated "Access denied for user" messages from the same IP address. After a configurable threshold of failures (default: 5 within a short window), the attacking IP is banned via iptables/nftables and the event is reported to your dashboard.

Automate MySQL security monitoring

Harden once, monitor forever. Defensia watches your MySQL server 24/7 — detecting brute force, exposed ports, and CVE advisories automatically.

Get Started Free