Database SRE

Database Security Best Practices: Encryption, Access Control, and Auditing

Secure your MySQL and PostgreSQL databases — TLS, at-rest encryption, role-based access, and audit logging

JusDB Team
October 19, 2022
12 min read
158 views

A publicly exposed database with default credentials, no encryption in transit, and unrestricted superuser access is not a misconfiguration edge case — it is a pattern that appears repeatedly in breach post-mortems across healthcare, finance, and SaaS. Attackers do not need sophisticated exploits when the front door is unlocked. Securing a MySQL or PostgreSQL deployment requires layering controls across the network, the wire, the disk, the application, and the audit trail. This guide walks through each layer with concrete configuration steps, gotchas that trip up experienced DBAs, and the minimal SQL needed to harden a real production system.

TL;DR
  • Enforce TLS for every client connection — require_secure_transport=ON in MySQL, hostssl rules in PostgreSQL's pg_hba.conf.
  • Encrypt data at rest with TDE (MySQL Enterprise), pgcrypto, LUKS, or your cloud provider's KMS.
  • Apply the principle of least privilege: create dedicated users with the minimum GRANT set they actually need.
  • Enable row-level security in PostgreSQL and column-level grants to limit lateral data exposure inside the database.
  • Ship audit logs off-box with pg_audit or Percona's MySQL Audit Plugin — logs that live only on the database host can be wiped in a breach.
  • Never bind a database port to 0.0.0.0/0; keep it inside a VPC with tight security-group rules.
  • Rotate credentials through HashiCorp Vault or a cloud secrets manager — hardcoded passwords in application config files are a gift to attackers.

Understanding the Database Security Layers

Database security is not a single setting — it is a stack of overlapping controls, each one designed to limit the blast radius when the layer above it fails. Think of it as concentric rings:

  1. Network perimeter — who can reach the port at all.
  2. Transport security — are bytes encrypted between the client and server.
  3. Authentication — who is allowed to log in.
  4. Authorization — what an authenticated identity is permitted to do.
  5. Encryption at rest — what an attacker with disk access can read.
  6. Audit logging — what visibility you have after something goes wrong.

Skipping any ring means an attacker who defeats the ring above it immediately reaches your data. A VPC without TLS, for example, is broken the moment one internal service is compromised. The sections below address each ring in the order you should implement them.

Enforcing TLS in Transit

Unencrypted database traffic traverses the network in plaintext. On a cloud VPC this feels theoretical until you remember that cloud providers can have noisy-neighbor issues, that internal traffic is often routed across physical switches you do not own, and that a compromised application server sitting on the same subnet can run tcpdump. TLS is non-negotiable.

MySQL

MySQL has supported TLS since version 5.7, but it is not enforced by default. Add the following to my.cnf and restart:

ini
[mysqld]
require_secure_transport = ON
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 rejects any client connection that does not present a TLS handshake. Verify that existing accounts also enforce TLS at the account level:

sql
ALTER USER 'app_user'@'%' REQUIRE SSL;
-- Or, for certificate-pinned connections:
ALTER USER 'app_user'@'%' REQUIRE X509;
Warning

Setting require_secure_transport=ON will break any application or monitoring agent connecting without TLS. Audit all connection strings before flipping this switch in production — check your ORM configuration, your backup agent, and any read-replica replication channels.

PostgreSQL

In PostgreSQL, TLS is configured at two levels: the server (postgresql.conf) and the connection-acceptance rules (pg_hba.conf).

ini
# postgresql.conf
ssl = on
ssl_ca_file   = 'root.crt'
ssl_cert_file = 'server.crt'
ssl_key_file  = 'server.key'
ssl_min_protocol_version = 'TLSv1.2'
text
# pg_hba.conf — replace host lines with hostssl
# TYPE  DATABASE  USER    ADDRESS         METHOD
hostssl all       all     10.0.0.0/8      scram-sha-256
hostssl all       all     172.16.0.0/12   scram-sha-256

The hostssl record type means PostgreSQL will only accept connections over an encrypted channel from those CIDR ranges. Any connection attempt without TLS is rejected at the protocol level. Remove all bare host lines for production databases.

Tip

Use ssl_min_protocol_version = 'TLSv1.2' (PostgreSQL 12+) to disable TLS 1.0 and 1.1. On older versions, set ssl_ciphers explicitly to exclude weak cipher suites.

Encryption at Rest

TLS protects data moving across the wire. Encryption at rest protects data sitting on disk — from a stolen drive, a misconfigured backup bucket, or a cloud snapshot that ends up in the wrong hands.

Transparent Data Encryption (MySQL Enterprise)

MySQL Enterprise Edition provides InnoDB tablespace-level TDE. Keys are managed through a keyring plugin that can delegate to a KMS:

sql
-- Enable keyring and verify
SHOW VARIABLES LIKE 'keyring_file_data';

-- Create an encrypted tablespace
CREATE TABLESPACE `encrypted_ts`
  ADD DATAFILE 'encrypted_ts.ibd'
  ENCRYPTION = 'Y';

-- Move a table into the encrypted tablespace
ALTER TABLE orders TABLESPACE encrypted_ts;

pgcrypto in PostgreSQL

Community PostgreSQL does not ship with TDE, but pgcrypto allows column-level encryption inside the database:

sql
CREATE EXTENSION pgcrypto;

-- Store PII encrypted with AES
INSERT INTO customers (email, ssn_encrypted)
VALUES (
  'alice@example.com',
  pgp_sym_encrypt('123-45-6789', current_setting('app.encryption_key'))
);

-- Decrypt at query time (key supplied by application)
SELECT pgp_sym_decrypt(ssn_encrypted, current_setting('app.encryption_key'))
FROM customers
WHERE email = 'alice@example.com';
Warning

pgcrypto column-level encryption is not a substitute for full-disk encryption — indexes, WAL, and autovacuum temp files can still leak plaintext. Use pgcrypto for high-sensitivity columns alongside LUKS disk encryption or your cloud provider's encrypted EBS/persistent-disk volumes.

Disk-Level Encryption with LUKS and Cloud KMS

The most operationally simple at-rest solution is to encrypt the underlying block device. On Linux, LUKS handles this transparently to the database. On AWS, enabling encrypted EBS volumes and RDS encryption at cluster creation time achieves the same result with no application changes. On GCP, Cloud KMS customer-managed encryption keys (CMEK) can be attached to Cloud SQL instances.

Regardless of the mechanism, the critical requirement is key management: encryption is only as strong as the secrecy of the key. Integrate with a dedicated KMS rather than storing keys on the same host as the data.

Access Control and the Principle of Least Privilege

The most common access-control mistake is using the same database user for everything — migrations, application queries, analytics, and administrative tasks. When that credential is compromised, the attacker has full access. Create separate accounts with the minimum permissions each role actually requires.

MySQL: Minimal Grants

sql
-- Read-only reporting user
CREATE USER 'reporting'@'10.0.0.%' IDENTIFIED BY 'strong_random_password';
GRANT SELECT ON analytics.* TO 'reporting'@'10.0.0.%';

-- Application user — no DDL, no DROP, no FILE
CREATE USER 'app'@'10.0.0.%' IDENTIFIED BY 'strong_random_password';
GRANT SELECT, INSERT, UPDATE, DELETE ON myapp.* TO 'app'@'10.0.0.%';

-- Verify
SHOW GRANTS FOR 'app'@'10.0.0.%';

PostgreSQL: Roles, Row-Level Security, and Column Grants

PostgreSQL's role system is more expressive than MySQL's. Use role inheritance to manage privilege sets, and activate row-level security (RLS) to enforce data partitioning inside a single table:

sql
-- Create a base role with minimal access
CREATE ROLE app_readonly;
GRANT CONNECT ON DATABASE myapp TO app_readonly;
GRANT USAGE ON SCHEMA public TO app_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_readonly;

-- Create a user that inherits the role
CREATE USER reporting_svc WITH PASSWORD 'strong_random_password';
GRANT app_readonly TO reporting_svc;

-- Row-level security: tenants see only their own rows
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.current_tenant')::uuid);

-- Column-level grant: reporting can read everything except PII columns
REVOKE SELECT ON customers FROM app_readonly;
GRANT SELECT (id, created_at, plan, region) ON customers TO app_readonly;
Tip

Use ALTER DEFAULT PRIVILEGES in PostgreSQL so that new tables created by a migration user automatically grant the correct permissions to application roles — avoids the common "new table, broken app" incident.

Password Policies

Weak passwords undermine every other access control. MySQL ships with the validate_password component:

sql
-- MySQL: install and configure validate_password
INSTALL COMPONENT 'file://component_validate_password';

SET GLOBAL validate_password.length = 16;
SET GLOBAL validate_password.mixed_case_count = 1;
SET GLOBAL validate_password.number_count = 1;
SET GLOBAL validate_password.special_char_count = 1;
SET GLOBAL validate_password.policy = 'STRONG';

For PostgreSQL, the pg_password_check extension (community) or passwordcheck module enforces complexity rules at the protocol level before a password is accepted.

Audit Logging

Access controls tell you what should happen. Audit logs tell you what actually happened. A forensically complete audit trail requires capturing who connected, what they queried, what rows changed, and when — and shipping those logs somewhere the database host cannot reach.

PostgreSQL: pg_audit

ini
# postgresql.conf
shared_preload_libraries = 'pgaudit'
pgaudit.log = 'read, write, ddl, role'
pgaudit.log_catalog = off       # reduces noise from catalog queries
pgaudit.log_relation = on       # log the specific relation accessed
pgaudit.log_statement_once = on

After reloading PostgreSQL, pg_audit emits structured log lines tagged with AUDIT: for every qualifying statement. Ship these to a SIEM (Splunk, Elastic, Datadog Logs) using a log forwarder like Fluentd or Vector.

MySQL: Percona Audit Plugin

MySQL Community Edition lacks a built-in audit plugin. The Percona Audit Log Plugin is a drop-in addition:

sql
-- After installing the plugin library:
INSTALL PLUGIN audit_log SONAME 'audit_log.so';

SET GLOBAL audit_log_format = 'JSON';
SET GLOBAL audit_log_policy = 'ALL';
SET GLOBAL audit_log_rotate_on_size = 1073741824; -- rotate at 1 GB
Warning

Audit logs stored only on the database host are a liability — an attacker with root access can truncate or delete them before you investigate. Configure log forwarding to an append-only destination (S3 with Object Lock, a dedicated syslog server, or a SIEM) as part of your audit setup, not as an afterthought.

Network Security

Database ports should never be reachable from the public internet. This sounds obvious, yet Shodan consistently indexes hundreds of thousands of openly accessible MySQL and PostgreSQL instances. The correct posture:

  • VPC isolation: place the database in a private subnet with no internet gateway route.
  • Security groups / firewall rules: allow inbound on port 3306 (MySQL) or 5432 (PostgreSQL) only from the specific CIDRs of your application servers — never from 0.0.0.0/0 or ::/0.
  • Bastion / jump host for administrative access: DBAs connect to a hardened bastion via SSH, then from the bastion to the database. The bastion is the only host with a public IP.
  • Bind address: set bind-address = 127.0.0.1 (MySQL) or listen_addresses = 'localhost,10.0.1.5' (PostgreSQL) so the database does not even accept TCP on unintended interfaces.
ini
# PostgreSQL: bind only to localhost and private interface
listen_addresses = 'localhost,10.0.1.5'
port = 5432
ini
# MySQL: bind to private interface only
[mysqld]
bind-address = 10.0.1.5

Secrets Management with HashiCorp Vault

Hardcoded database credentials in .env files, Kubernetes secrets stored as base64, and passwords committed to git repositories are among the most common causes of credential exposure. The correct solution is a dedicated secrets manager:

bash
# Vault: enable the database secrets engine and configure a MySQL role
vault secrets enable database

vault write database/config/myapp-mysql \
  plugin_name=mysql-database-plugin \
  connection_url="{{username}}:{{password}}@tcp(10.0.1.5:3306)/" \
  allowed_roles="app-role" \
  username="vault_admin" \
  password="vault_admin_password"

vault write database/roles/app-role \
  db_name=myapp-mysql \
  creation_statements="CREATE USER '{{name}}'@'%' IDENTIFIED BY '{{password}}'; \
    GRANT SELECT, INSERT, UPDATE, DELETE ON myapp.* TO '{{name}}'@'%';" \
  default_ttl="1h" \
  max_ttl="24h"

With this configuration, Vault issues short-lived, rotated credentials to your application at runtime. No long-lived password exists in any config file. If a credential is leaked, it expires within hours automatically.

Tip

Common vulnerabilities to keep on your radar: SQL injection through improperly parameterized application queries (use prepared statements, always), credential stuffing against database ports exposed to the internet (enforce network isolation), and privilege escalation through overly broad GRANT OPTION assignments (audit information_schema.USER_PRIVILEGES regularly).

Key Takeaways

Key Takeaways
  • Layer your defenses — network isolation, TLS, authentication, authorization, at-rest encryption, and audit logging are each necessary; none is sufficient alone.
  • Enforce TLS at the server level (require_secure_transport=ON in MySQL; hostssl in pg_hba.conf) so that a misconfigured client cannot silently fall back to plaintext.
  • Encrypt data at rest using TDE, LUKS, or cloud KMS-backed encrypted volumes, and manage keys separately from the data they protect.
  • Create least-privilege database users for each application role; use PostgreSQL row-level security and column grants to enforce data partitioning at the database layer, not just the application layer.
  • Enable pg_audit or the Percona Audit Plugin and forward logs off-box to an append-only destination immediately.
  • Keep database ports inside a VPC; never expose 0.0.0.0/0. Restrict bind-address / listen_addresses to the minimum required interfaces.
  • Replace hardcoded credentials with short-lived, automatically rotated secrets from HashiCorp Vault or a cloud-native secrets manager.
  • Audit your privilege grants regularly — run SHOW GRANTS (MySQL) or query information_schema.role_table_grants (PostgreSQL) as part of your quarterly security review.

Secure Your Database Infrastructure with JusDB

Implementing these controls from scratch across multiple database engines, environments, and teams is a significant operational burden. JusDB provides managed MySQL and PostgreSQL instances with TLS enforced by default, encrypted storage, VPC-native networking, automated credential rotation, and audit log forwarding built into the platform — so your team can focus on application development rather than database hardening checklists.

Explore the JusDB managed database hosting options or contact the team to discuss your security requirements and compliance needs.

Share this article