Database Security¶
A database is only as secure as its weakest access path. Misconfigurations, default credentials, unencrypted connections, and injectable queries have been behind the majority of data breaches for decades. This guide covers the full security surface of production database systems - authentication, encryption, injection prevention, auditing, and privilege hardening - across both MySQL and PostgreSQL.
Authentication Methods¶
Authentication is the first gate. Every connection to your database must prove identity before it can do anything. Different methods vary in strength, complexity, and operational overhead.
Password-Based Authentication¶
The most common method. Both MySQL and PostgreSQL support multiple password hashing plugins:
| Plugin/Method | Database | Notes |
|---|---|---|
mysql_native_password |
MySQL | SHA-1 based, legacy default pre-8.0 |
caching_sha2_password |
MySQL | SHA-256 based, default in MySQL 8.0+ |
scram-sha-256 |
PostgreSQL | Salted challenge-response, default in PG 14+ |
md5 |
PostgreSQL | Legacy, still common but weaker |
MySQL password plugin configuration:
-- Create a user with a specific auth plugin (MySQL 8.0+)
CREATE USER 'app_user'@'10.0.0.%'
IDENTIFIED WITH caching_sha2_password BY 'strong_random_passphrase';
-- Check which plugin a user is using
SELECT user, host, plugin FROM mysql.user WHERE user = 'app_user';
-- Change an existing user's plugin
ALTER USER 'legacy_user'@'%'
IDENTIFIED WITH caching_sha2_password BY 'new_passphrase';
PostgreSQL password method configuration in pg_hba.conf:
# TYPE DATABASE USER ADDRESS METHOD
host all app_user 10.0.0.0/24 scram-sha-256
host all all 127.0.0.1/32 scram-sha-256
Set the default in postgresql.conf:
Avoid md5 and mysql_native_password in new deployments
Both use weak hashing algorithms. mysql_native_password stores SHA-1 hashes that are vulnerable to rainbow table attacks. PostgreSQL's md5 method is similarly outdated. Use caching_sha2_password for MySQL and scram-sha-256 for PostgreSQL.
Certificate-Based Authentication (x509)¶
Instead of passwords, clients present a TLS certificate signed by a trusted Certificate Authority. The database verifies the certificate chain.
MySQL x509 authentication:
-- Require a valid client certificate
CREATE USER 'secure_user'@'%'
IDENTIFIED WITH caching_sha2_password BY 'passphrase'
REQUIRE X509;
-- Require a specific certificate subject
CREATE USER 'strict_user'@'%'
REQUIRE SUBJECT '/CN=app-server/O=MyCompany';
PostgreSQL certificate authentication in pg_hba.conf:
# Client must present a valid certificate signed by the server's root CA
hostssl all cert_user 10.0.0.0/24 cert clientcert=verify-full
LDAP Integration¶
LDAP authentication delegates credential verification to a directory service like Active Directory or OpenLDAP. Users authenticate with their corporate credentials.
MySQL LDAP plugin:
-- Install the LDAP plugin (MySQL Enterprise)
INSTALL PLUGIN authentication_ldap_simple
SONAME 'authentication_ldap_simple.so';
-- Create a user that authenticates against LDAP
CREATE USER 'ldap_user'@'%'
IDENTIFIED WITH authentication_ldap_simple;
PostgreSQL LDAP in pg_hba.conf:
host all all 10.0.0.0/24 ldap
ldapserver=ldap.company.com
ldapbasedn="ou=People,dc=company,dc=com"
ldapsearchattribute=uid
Kerberos/GSSAPI and PAM¶
For enterprise environments with existing Kerberos infrastructure:
- Kerberos/GSSAPI: Both MySQL (with the
authentication_kerberosplugin) and PostgreSQL (via thegssmethod inpg_hba.conf) support Kerberos tickets for single sign-on - PAM (Pluggable Authentication Modules): Delegates authentication to the operating system's PAM stack, which can chain multiple authentication backends - LDAP, Kerberos, local files, MFA tokens
PostgreSQL PAM configuration:
The corresponding PAM service file (/etc/pam.d/postgresql) defines the actual authentication chain.
TLS/SSL Configuration¶
Authentication proves identity, but without encryption the credentials (and all subsequent data) travel in plaintext. TLS (Transport Layer Security, the successor to SSL) encrypts the connection between client and server.
Generating Certificates¶
For production, use certificates from your organization's internal CA or a public CA. For testing, self-signed certificates work:
# Generate a CA key and certificate
openssl genrsa 4096 > ca-key.pem
openssl req -new -x509 -nodes -days 3650 \
-key ca-key.pem -out ca-cert.pem \
-subj "/CN=MySQL-CA/O=MyCompany"
# Generate the server key and certificate signing request
openssl genrsa 4096 > server-key.pem
openssl req -new -key server-key.pem -out server-req.pem \
-subj "/CN=db-server.company.com"
# Sign the server certificate with the CA
openssl x509 -req -in server-req.pem -days 3650 \
-CA ca-cert.pem -CAkey ca-key.pem -CAcreateserial \
-out server-cert.pem
# Generate client key and certificate (for x509 auth)
openssl genrsa 4096 > client-key.pem
openssl req -new -key client-key.pem -out client-req.pem \
-subj "/CN=app-server/O=MyCompany"
openssl x509 -req -in client-req.pem -days 3650 \
-CA ca-cert.pem -CAkey ca-key.pem -CAcreateserial \
-out client-cert.pem
MySQL TLS Configuration¶
In my.cnf (or my.ini on Windows):
[mysqld]
# Require all connections to use TLS
require_secure_transport = ON
# Certificate paths
ssl-ca = /etc/mysql/ssl/ca-cert.pem
ssl-cert = /etc/mysql/ssl/server-cert.pem
ssl-key = /etc/mysql/ssl/server-key.pem
# Minimum TLS version
tls_version = TLSv1.2,TLSv1.3
Verify the server's TLS status:
SHOW VARIABLES LIKE '%ssl%';
-- ssl_ca, ssl_cert, ssl_key should show your paths
SHOW STATUS LIKE 'Ssl_cipher';
-- Should show a cipher name like TLS_AES_256_GCM_SHA384
PostgreSQL TLS Configuration¶
In postgresql.conf:
ssl = on
ssl_cert_file = '/etc/postgresql/ssl/server-cert.pem'
ssl_key_file = '/etc/postgresql/ssl/server-key.pem'
ssl_ca_file = '/etc/postgresql/ssl/ca-cert.pem'
ssl_min_protocol_version = 'TLSv1.2'
In pg_hba.conf, use hostssl instead of host to require TLS:
# Reject non-TLS connections from the network
hostssl all all 10.0.0.0/24 scram-sha-256
hostnossl all all 10.0.0.0/24 reject
Verifying Encrypted Connections¶
MySQL client verification:
-- Check your current connection
SHOW STATUS LIKE 'Ssl_cipher';
-- Non-empty = encrypted
SELECT * FROM performance_schema.session_status
WHERE variable_name = 'Ssl_version';
PostgreSQL client verification:
-- In psql
\conninfo
-- Should show "SSL connection (protocol: TLSv1.3, cipher: ...)"
SELECT ssl, version, cipher FROM pg_stat_ssl
WHERE pid = pg_backend_pid();
Encryption at Rest¶
TLS protects data in transit. Encryption at rest protects data stored on disk - against stolen drives, unauthorized filesystem access, or compromised backups.
Transparent Data Encryption (TDE)¶
TDE encrypts data files at the storage engine level. The database handles encryption and decryption transparently - applications need no changes.
MySQL Enterprise TDE (InnoDB tablespace encryption):
-- Install the keyring plugin (required for TDE)
-- In my.cnf:
-- early-plugin-load = keyring_file.so
-- keyring_file_data = /var/lib/mysql-keyring/keyring
-- Encrypt a tablespace
ALTER TABLE customers ENCRYPTION = 'Y';
-- Encrypt the system tablespace
ALTER TABLESPACE mysql.innodb_system ENCRYPTION = 'Y';
-- Verify encryption status
SELECT name, encryption
FROM information_schema.innodb_tablespaces
WHERE encryption = 'Y';
Key management is the hard part
TDE is only as secure as the encryption keys. Storing the keyring file on the same disk as the database defeats the purpose - a stolen disk includes both the encrypted data and the key. Use a dedicated Key Management Service (KMS) like HashiCorp Vault, AWS KMS, or Azure Key Vault in production.
Column-Level Encryption with pgcrypto¶
PostgreSQL does not have built-in TDE in the community edition, but the pgcrypto extension provides column-level encryption:
-- Enable pgcrypto
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Encrypt sensitive columns using symmetric encryption (AES-256)
INSERT INTO customers (name, ssn_encrypted)
VALUES (
'Jane Smith',
pgp_sym_encrypt('123-45-6789', 'encryption-key-from-app')
);
-- Decrypt when reading
SELECT name, pgp_sym_decrypt(ssn_encrypted, 'encryption-key-from-app') AS ssn
FROM customers
WHERE id = 42;
The encryption key should come from the application or a KMS - never hardcode it in SQL or store it in the database.
Filesystem-Level Encryption¶
An alternative to database-level encryption is encrypting the entire filesystem or block device:
- LUKS/dm-crypt (Linux): Encrypts block devices. The database writes normally; the kernel encrypts and decrypts transparently
- BitLocker (Windows): Full-disk encryption for Windows Server
- AWS EBS Encryption / Azure Disk Encryption: Cloud provider managed encryption for virtual disks
# Example: LUKS encryption for a database volume
sudo cryptsetup luksFormat /dev/sdb1
sudo cryptsetup luksOpen /dev/sdb1 db_encrypted
sudo mkfs.ext4 /dev/mapper/db_encrypted
sudo mount /dev/mapper/db_encrypted /var/lib/mysql
Filesystem encryption protects against physical theft but not against a compromised OS or database process - if the system is running, the filesystem is mounted and decrypted.
SQL Injection Prevention¶
SQL injection remains the most exploited database vulnerability year after year. It occurs when user input is concatenated directly into SQL strings, allowing an attacker to modify the query's logic.
The Problem: String Concatenation¶
# VULNERABLE - never do this
username = request.form['username']
query = "SELECT * FROM users WHERE username = '" + username + "'"
cursor.execute(query)
# If username = "admin' OR '1'='1" the query becomes:
# SELECT * FROM users WHERE username = 'admin' OR '1'='1'
# This returns ALL users
The Solution: Parameterized Queries¶
Parameterized queries (also called prepared statements) separate the SQL structure from the data. The database engine treats parameters as literal values, never as SQL syntax.
Stored Procedures as a Defense Layer¶
Stored procedures provide an additional layer of protection by encapsulating SQL logic on the server:
-- MySQL stored procedure
DELIMITER //
CREATE PROCEDURE get_user_by_email(IN p_email VARCHAR(255))
BEGIN
SELECT id, username, email, created_at
FROM users
WHERE email = p_email;
END //
DELIMITER ;
-- Call from application
CALL get_user_by_email('user@example.com');
The application only calls the procedure with parameters - it never constructs raw SQL. The procedure controls exactly which columns are returned and which tables are accessed.
Input Validation¶
Parameterized queries are the primary defense. Input validation is a secondary layer:
- Allowlist validation: If a field should be an integer, cast it to an integer before use
- Length limits: Reject inputs exceeding expected lengths
- Character restrictions: Email fields should match email patterns
- Reject known attack patterns: While not sufficient alone, blocking inputs containing
'; --orUNION SELECTadds depth
ORM Safety¶
Object-Relational Mappers (ORMs) like SQLAlchemy, Django ORM, Sequelize, and Hibernate use parameterized queries internally. They are generally safe, but watch for escape hatches:
# Django ORM - safe by default
User.objects.filter(email=user_email)
# Django raw query - safe if parameterized
User.objects.raw("SELECT * FROM users WHERE email = %s", [user_email])
# Django raw query - UNSAFE if concatenated
User.objects.raw("SELECT * FROM users WHERE email = '" + user_email + "'")
ORMs don't make you immune
Every ORM provides a way to execute raw SQL. When you use it, you are responsible for parameterization. Code review should flag any raw SQL that concatenates user input.
Audit Logging¶
You cannot protect what you cannot observe. Audit logging records who did what, when, and from where - essential for compliance (PCI DSS, HIPAA, SOX), incident investigation, and detecting unauthorized access.
MySQL Enterprise Audit¶
The MySQL Enterprise Audit plugin provides comprehensive logging:
-- Install the audit plugin
INSTALL PLUGIN audit_log SONAME 'audit_log.so';
-- Configure in my.cnf
-- [mysqld]
-- audit-log-format = JSON
-- audit-log-policy = ALL
-- audit-log-file = /var/log/mysql/audit.log
For the community edition, the general query log provides basic auditing:
Performance impact of general_log
The general query log records every single query and causes significant I/O overhead. Use it for short-term debugging or auditing, not as a permanent audit solution on high-traffic systems. The Enterprise Audit plugin has filtering capabilities that reduce overhead.
PostgreSQL pgAudit¶
pgAudit is the standard auditing extension for PostgreSQL:
-- Enable in postgresql.conf:
-- shared_preload_libraries = 'pgaudit'
-- pgaudit.log = 'ddl, role, write'
-- Per-database override
ALTER DATABASE production SET pgaudit.log = 'all';
-- Per-role override (audit everything the admin role does)
ALTER ROLE dba SET pgaudit.log = 'all';
pgAudit log classes:
| Class | What it logs |
|---|---|
read |
SELECT, COPY FROM |
write |
INSERT, UPDATE, DELETE, TRUNCATE |
function |
Function calls |
role |
GRANT, REVOKE, CREATE/ALTER/DROP ROLE |
ddl |
CREATE, ALTER, DROP (tables, indexes, etc.) |
misc |
DISCARD, FETCH, CHECKPOINT |
all |
Everything above |
What to Log¶
At minimum, audit these events:
- Authentication events: Successful and failed login attempts (track brute-force patterns)
- DDL changes: Schema modifications (CREATE, ALTER, DROP) - who changed the table structure?
- DML on sensitive tables: INSERT, UPDATE, DELETE on tables containing PII, financial data, or credentials
- Privilege changes: GRANT, REVOKE, CREATE ROLE, ALTER USER
- Administrative actions: Configuration changes, backup operations, replication setup
Log Management¶
Raw audit logs are useless if nobody reviews them:
- Centralize: Ship logs to a SIEM (Splunk, Elasticsearch, Datadog) for search and alerting
- Protect: Audit logs should be write-only for the database process, stored on separate storage, and backed up independently
- Retain: Define retention policies based on compliance requirements (PCI DSS requires 1 year, HIPAA requires 6 years)
- Alert: Set up automated alerts for suspicious patterns - failed login spikes, after-hours DDL, mass data exports
Privilege Hardening¶
Even authenticated users should only have access to what they need. Privilege hardening applies the principle of least privilege: every account gets the minimum permissions required for its function.
Separate Accounts by Function¶
Never use a single account for everything. Create purpose-specific accounts:
| Account | Purpose | Typical Privileges |
|---|---|---|
app_read |
Application read queries | SELECT on specific tables |
app_write |
Application write operations | SELECT, INSERT, UPDATE, DELETE on specific tables |
admin |
DBA administration | Full privileges (used interactively, never by applications) |
backup_user |
Backup operations | SELECT, LOCK TABLES, SHOW VIEW, RELOAD, REPLICATION CLIENT |
monitor_user |
Monitoring/metrics | SELECT on performance_schema, information_schema |
migration_user |
Schema migrations | CREATE, ALTER, DROP, INDEX on application database |
MySQL Privilege Hardening¶
-- Create a read-only application account
CREATE USER 'app_read'@'10.0.0.%'
IDENTIFIED WITH caching_sha2_password BY 'read_passphrase';
GRANT SELECT ON myapp.* TO 'app_read'@'10.0.0.%';
-- Create a write account with limited DML
CREATE USER 'app_write'@'10.0.0.%'
IDENTIFIED WITH caching_sha2_password BY 'write_passphrase';
GRANT SELECT, INSERT, UPDATE, DELETE ON myapp.* TO 'app_write'@'10.0.0.%';
-- Monitoring account
CREATE USER 'monitor'@'10.0.0.%'
IDENTIFIED WITH caching_sha2_password BY 'monitor_passphrase';
GRANT SELECT ON performance_schema.* TO 'monitor'@'10.0.0.%';
GRANT PROCESS ON *.* TO 'monitor'@'10.0.0.%';
-- Review existing privileges - look for overly broad grants
SELECT grantee, privilege_type, table_schema
FROM information_schema.schema_privileges
ORDER BY grantee;
PostgreSQL Privilege Hardening¶
-- Revoke default public schema access (critical in PostgreSQL)
REVOKE ALL ON SCHEMA public FROM PUBLIC;
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM PUBLIC;
-- Grant schema usage explicitly
GRANT USAGE ON SCHEMA public TO app_read;
GRANT USAGE ON SCHEMA public TO app_write;
-- Read-only role
CREATE ROLE app_read LOGIN PASSWORD 'read_passphrase';
GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_read;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT ON TABLES TO app_read;
-- Write role
CREATE ROLE app_write LOGIN PASSWORD 'write_passphrase';
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_write;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO app_write;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_write;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE ON SEQUENCES TO app_write;
ALTER DEFAULT PRIVILEGES matters
GRANT SELECT ON ALL TABLES only applies to tables that exist right now. When new tables are created later, the role will not have access. ALTER DEFAULT PRIVILEGES sets the permissions that future tables inherit automatically.
Avoiding GRANT ALL¶
GRANT ALL PRIVILEGES gives everything - SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, INDEX, and more. For application accounts, this is almost always too broad:
-- WRONG - application doesn't need DROP, ALTER, CREATE
GRANT ALL PRIVILEGES ON myapp.* TO 'app_user'@'%';
-- RIGHT - only what the application actually uses
GRANT SELECT, INSERT, UPDATE, DELETE ON myapp.* TO 'app_user'@'%';
If an application account is compromised, GRANT ALL means the attacker can drop tables, alter schema, and read data from every table. Scoped grants limit the blast radius.
Network-Level Restrictions¶
Database ports should never be exposed to the public internet:
# MySQL - bind only to internal interface (my.cnf)
# bind-address = 10.0.0.5
# PostgreSQL - listen only on internal interface (postgresql.conf)
# listen_addresses = '10.0.0.5'
# Firewall rules (iptables example)
iptables -A INPUT -p tcp --dport 3306 -s 10.0.0.0/24 -j ACCEPT
iptables -A INPUT -p tcp --dport 3306 -j DROP
iptables -A INPUT -p tcp --dport 5432 -s 10.0.0.0/24 -j ACCEPT
iptables -A INPUT -p tcp --dport 5432 -j DROP
Combine network restrictions with MySQL's host-based user system ('user'@'10.0.0.%') and PostgreSQL's pg_hba.conf address matching for defense in depth.
OWASP Database Security¶
The OWASP (Open Worldwide Application Security Project) identifies recurring patterns in database breaches. These are the risks that consistently appear in real-world incidents:
Top Database Security Risks¶
1. SQL Injection - The most exploited vulnerability. Parameterized queries eliminate it entirely, yet it persists because legacy code, raw query escape hatches, and dynamic SQL construction remain common.
2. Excessive Privileges - Application accounts with DBA-level access. When the application is compromised, the attacker inherits every privilege the account has. The principle of least privilege directly counters this.
3. Unpatched Databases - Known vulnerabilities with published CVEs remain exploitable until patched. Establish a patching cadence: critical CVEs within 72 hours, regular patches monthly.
4. Default Credentials - MySQL historically created accounts with no password. PostgreSQL's trust authentication in pg_hba.conf allows passwordless connections. Every default credential must be changed or removed before production deployment.
5. Exposed Backups - Database dumps stored in world-readable locations, uploaded to unsecured S3 buckets, or left in web-accessible directories. Encrypt backups and restrict access.
6. Unnecessary Features Enabled - File loading (LOAD DATA LOCAL INFILE in MySQL), external program execution (COPY ... PROGRAM in PostgreSQL), and unused stored procedures increase the attack surface. Disable what you do not use.
7. Insecure Transport - Unencrypted connections between application and database. TLS is non-negotiable in production.
Security Checklist¶
| Check | MySQL | PostgreSQL |
|---|---|---|
| Strong auth plugin | caching_sha2_password |
scram-sha-256 |
| TLS enforced | require_secure_transport = ON |
hostssl + hostnossl reject |
| No default/empty passwords | Check mysql.user |
Check pg_hba.conf for trust |
| Least privilege accounts | Review SHOW GRANTS |
Review \du and \dp |
| Audit logging | Enterprise Audit or general_log | pgAudit extension |
| Network restricted | bind-address + firewall |
listen_addresses + firewall |
| Backups encrypted | Encrypt dump files | Encrypt dump files |
| Patches current | SELECT VERSION() |
SELECT version() |
| File loading disabled | local_infile = OFF |
Restrict COPY ... PROGRAM |
Exercises¶
Further Reading¶
- MySQL Security Guide - official MySQL security documentation covering authentication, encryption, and access control
- PostgreSQL Client Authentication (pg_hba.conf) - comprehensive reference for PostgreSQL authentication configuration
- OWASP SQL Injection Prevention Cheat Sheet - parameterized query patterns for every major language
- pgAudit Documentation - PostgreSQL audit logging extension setup and configuration
- CIS Benchmarks for MySQL and PostgreSQL - industry-standard hardening checklists from the Center for Internet Security
Previous: Backup & Recovery Strategies | Next: Scaling & Architecture Patterns | Back to Index