PostgreSQL Hardening on Linux: pg_hba, TLS, RLS, and pgaudit in 2026
Production-grade PostgreSQL 17 hardening on Linux: SCRAM-SHA-256 pg_hba, TLS 1.3 with client certs, least-privilege roles, RLS, and pgaudit shipped to a SIEM.
PostgreSQL hardening on Linux means closing five default-but-unsafe behaviors: trust authentication in pg_hba.conf, plaintext TCP connections, the all-powerful postgres superuser on application paths, missing row-level security on multi-tenant tables, and silent DDL/DML that never reaches a SIEM. This guide walks through a production-grade configuration for PostgreSQL 17 on Ubuntu 24.04 and RHEL 9 (SCRAM-SHA-256, TLS 1.3 with certificate-based clients, least-privilege roles, RLS, and pgaudit shipped to syslog) with copy-pasteable configs you can apply tonight.
Replace every trust and md5 line in pg_hba.conf with scram-sha-256, and set password_encryption = scram-sha-256 before re-creating roles.
Enable TLS 1.3 with ssl = on, restrict ssl_min_protocol_version = 'TLSv1.3', and require client certificates for replication and superuser logins via clientcert=verify-full.
Never let applications connect as postgres. Create per-service NOLOGIN role groups, grant least privilege through GRANT/REVOKE, and rotate passwords from a secret manager.
Turn on Row-Level Security (ALTER TABLE … ENABLE ROW LEVEL SECURITY) for any multi-tenant table and write CREATE POLICY rules against current_setting('app.tenant_id').
Install pgaudit, set pgaudit.log = 'ddl, role, write', forward to journald/rsyslog, and ingest into Wazuh or Elastic for tamper-evident audit trails.
Firewall port 5432 with nftables, expose only over a WireGuard or Tailscale overlay, and put PgBouncer in front to terminate connections before they hit the server.
PostgreSQL threat model on Linux
Before touching a single config line, it's worth being explicit about what we're defending against. In my experience running Postgres clusters behind regulated workloads (PCI, plus a stretch of HIPAA work), the realistic attack surface breaks into four buckets:
Network exposure. A Postgres port reachable from the public internet is brute-forced within hours. The 2024 Shadowserver scans counted more than 800,000 exposed 5432 listeners worldwide, and most still accepted md5.
Weak authentication. Default installs of older PostgreSQL packages still ship with peer for local connections and md5 for host connections. md5 is not a hash you want on a credential database in 2026.
Over-privileged application roles. Most ORM bootstrap scripts ask for a superuser. Once an SQL-injection or token theft lands, the attacker has COPY … FROM PROGRAM (a documented RCE primitive) straight into the OS.
Silent data exfiltration. Without pgaudit, a SELECT * against a sensitive table is indistinguishable from background traffic. Compliance frameworks (PCI DSS 4.0, HIPAA, SOC 2 CC7.2) require object-level audit logs.
The rest of this guide closes those four buckets in the order they tend to fail. If you run several Linux services with overlapping audit needs, pair this with our Linux auditd deep dive so OS-level events and database events land in the same SIEM index.
Hardening pg_hba.conf with SCRAM-SHA-256
The host-based authentication file is the gatekeeper for every connection. PostgreSQL 17 still parses lines top-to-bottom and stops at the first match, which means a forgotten trust line can override everything below it. The hardened baseline I deploy lives at /etc/postgresql/17/main/pg_hba.conf on Debian/Ubuntu and /var/lib/pgsql/17/data/pg_hba.conf on RHEL:
# TYPE DATABASE USER ADDRESS METHOD OPTIONS
# 1. Local Unix socket: only the OS user "postgres" maps to the DB superuser.
local all postgres peer map=admin
# 2. Application sockets, service accounts only, never the superuser.
local app_prod app_prod scram-sha-256
# 3. Replication: cert auth from known replicas only.
hostssl replication replicator 10.20.0.0/24 cert clientcert=verify-full
# 4. Internal application range, TLS-required, SCRAM passwords.
hostssl app_prod app_prod 10.10.0.0/16 scram-sha-256
# 5. Break-glass DBA path, TLS + client cert + IP allowlist.
hostssl all dba_break_glass 10.0.99.0/28 cert clientcert=verify-full
# 6. Everything else: deny.
host all all 0.0.0.0/0 reject
host all all ::/0 reject
Three rules I never violate. The file must end with explicit reject entries (PostgreSQL’s implicit default is reject, but explicit lines log the attempt). Every TLS connection uses hostssl rather than host so plaintext is impossible. And the postgres superuser is reachable only over the Unix socket via peer with a pg_ident.conf map. Before reloading, force SCRAM cluster-wide in postgresql.conf:
password_encryption = scram-sha-256
ssl = on
listen_addresses = 'localhost,10.10.5.12' # never 0.0.0.0 in production
Then re-hash every existing password. Old md5 hashes do not magically upgrade:
# As the postgres OS user
psql -c "SELECT rolname FROM pg_authid WHERE rolpassword LIKE 'md5%';"
# For each role returned, force a new password (use a secret manager, not your shell history):
psql -c "\password app_prod"
sudo systemctl reload postgresql@17-main
Enabling TLS 1.3 and certificate-based authentication
Plaintext PostgreSQL traffic leaks credentials, query text, and result sets to anyone on the wire, including the local SDN if you run on a shared VPC. TLS is not optional in 2026. PostgreSQL 16 introduced first-class TLS 1.3 support and PostgreSQL 17 lets you pin it as the floor. The minimum production set looks like:
ssl = on
ssl_cert_file = '/etc/postgresql/tls/server.crt'
ssl_key_file = '/etc/postgresql/tls/server.key'
ssl_ca_file = '/etc/postgresql/tls/internal-ca.crt'
ssl_min_protocol_version = 'TLSv1.3'
ssl_ciphers = 'TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256'
ssl_prefer_server_ciphers = on
ssl_passphrase_command_supports_reload = on
The server key must be readable only by the postgres user:
For high-trust connections (replication, break-glass DBA access, cross-region admin), combine SCRAM with X.509 client certificates by setting clientcert=verify-full on the relevant pg_hba.conf line. The client’s common name must equal the PostgreSQL role name, which kills credential reuse if a cert ever leaks.
Issue short-lived certs (24 to 72 hours) from an internal CA. cert-manager, step-ca, and HashiCorp Vault’s PKI engine all work; rotation should be automated. Pair this with the post-quantum readiness recommendations in our Linux TLS hardening guide, because PostgreSQL 17 supports the OpenSSL 3.2 hybrid groups when libpq is built against it.
Verify from a client without touching the server:
psql "host=db01.internal port=5432 dbname=app_prod user=app_prod \
sslmode=verify-full sslrootcert=/etc/ssl/internal-ca.crt"
# In session:
SHOW ssl; -- 'on'
SELECT ssl, version, cipher
FROM pg_stat_ssl
JOIN pg_stat_activity USING (pid)
WHERE pid = pg_backend_pid();
If cipher is anything other than a TLS 1.3 suite, recheck ssl_min_protocol_version and the client’s libpq version. I hit this exact bug last quarter on a stack where libpq was still 14.x; the server was happy, the handshake just kept downgrading.
Designing least-privilege roles and the “no postgres in prod” rule
The PostgreSQL postgres role is the operating-system equivalent of running kubectl --as system:admin for every web request. Yet most application stacks default to using it. Honestly, this is the single change that gives you the most upside per hour invested. Here’s the role model I deploy:
A single NOLOGIN group role per data domain (e.g., app_billing, app_inventory) that owns the schema.
One or more LOGIN roles per service that inherit from the group with the minimum verbs needed.
A separate read-only role for analytics, BI, and on-call queries.
Break-glass DBA access via a personal cert, never a shared credential.
-- One-time setup as superuser
CREATE ROLE app_billing NOLOGIN;
CREATE ROLE app_billing_rw LOGIN PASSWORD :'rw_pw' INHERIT IN ROLE app_billing;
CREATE ROLE app_billing_ro LOGIN PASSWORD :'ro_pw' INHERIT IN ROLE app_billing;
-- Schema ownership stays with the group role
ALTER SCHEMA billing OWNER TO app_billing;
REVOKE ALL ON SCHEMA billing FROM PUBLIC;
GRANT USAGE ON SCHEMA billing TO app_billing_rw, app_billing_ro;
GRANT SELECT, INSERT, UPDATE, DELETE
ON ALL TABLES IN SCHEMA billing TO app_billing_rw;
GRANT SELECT
ON ALL TABLES IN SCHEMA billing TO app_billing_ro;
-- Lock down future objects too
ALTER DEFAULT PRIVILEGES IN SCHEMA billing
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_billing_rw;
ALTER DEFAULT PRIVILEGES IN SCHEMA billing
GRANT SELECT ON TABLES TO app_billing_ro;
-- Explicitly forbid the dangerous bits
REVOKE EXECUTE ON FUNCTION pg_read_server_files(text) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION pg_ls_dir(text) FROM PUBLIC;
ALTER ROLE app_billing_rw SET statement_timeout = '30s';
ALTER ROLE app_billing_rw SET idle_in_transaction_session_timeout = '60s';
The two timeouts at the bottom are routinely forgotten, and they save you in production. A hung transaction holding row locks while an attacker enumerates rows is exactly the kind of failure that turns a small breach into a long one. Rotate the passwords from a vault, since the patterns in our Linux secrets management guide map cleanly to PostgreSQL roles.
Row-Level Security for multi-tenant tables
Row-Level Security (RLS) is the only correct way to enforce tenant isolation inside PostgreSQL. Application-side WHERE tenant_id = ? filters get forgotten in raw SQL, COPY commands, and ad-hoc reporting jobs. RLS makes the database itself reject cross-tenant access. The pattern, using a session GUC the application sets after authentication:
-- Make the table tenant-aware
ALTER TABLE billing.invoices
ADD COLUMN IF NOT EXISTS tenant_id uuid NOT NULL;
CREATE INDEX IF NOT EXISTS invoices_tenant_idx ON billing.invoices(tenant_id);
-- Turn on RLS and force it for table owners too
ALTER TABLE billing.invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE billing.invoices FORCE ROW LEVEL SECURITY;
-- The policy: only see rows for the tenant in the session
CREATE POLICY tenant_isolation ON billing.invoices
USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);
-- Bypass role for back-office reports (rarely used, always audited)
CREATE ROLE reporting BYPASSRLS NOLOGIN;
GRANT reporting TO bi_analyst;
The application then issues SET LOCAL app.tenant_id = 'a1b2…' as the first statement of every transaction. The FORCE keyword closes the most common foot-gun: without it, the role that owns the table is exempt, and ORMs that connect as the owner silently bypass the policy. (I shipped that exact bug once. It took an audit to notice.)
pgaudit and forwarding logs to a SIEM
Native PostgreSQL logging (log_statement = 'all') is too noisy for security review and too coarse for compliance. It prints the parameterized SQL but not the rows touched, and it cannot be scoped per object class. The pgaudit extension solves both. On Ubuntu 24.04:
sudo apt install postgresql-17-pgaudit
Add it to postgresql.conf and tune what you log. The defaults are too quiet for production:
shared_preload_libraries = 'pgaudit'
pgaudit.log = 'ddl, role, write'
pgaudit.log_catalog = off
pgaudit.log_parameter = on
pgaudit.log_relation = on
pgaudit.log_statement = on
# Structured output that SIEMs can parse
log_destination = 'jsonlog'
logging_collector = on
log_directory = '/var/log/postgresql'
log_filename = 'postgresql-%Y-%m-%d.json'
log_line_prefix = '%m [%p] %q%u@%d/%a from %h '
log_connections = on
log_disconnections = on
log_hostname = off -- DNS lookups are slow and forge-able
log_min_duration_statement = 1000 -- catch slow queries that may be enumeration
Restart the cluster (this is one of the changes that requires more than a reload). Forward the JSON log to your SIEM using either Vector, Fluent Bit, or rsyslog:
For Wazuh specifically, drop a decoder into /var/ossec/etc/decoders/local_pgaudit.xml and a rule set under /var/ossec/etc/rules/local_pgaudit_rules.xml that pages on OBJECT events against pg_authid, pg_shadow, or any table tagged pii. The reference architecture in the PostgreSQL logging documentation covers the rest of the connection-side knobs.
Network isolation, PgBouncer, and fail2ban for Postgres
Even a hardened Postgres should not be directly reachable from application networks. Two changes pay back immediately.
nftables rules for port 5432
# /etc/nftables.d/postgres.nft
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
# Loopback and established
iif lo accept
ct state established,related accept
# SSH (consider an overlay-only listener instead)
tcp dport 22 accept
# PostgreSQL: only from the app subnet and the WireGuard CIDR
ip saddr { 10.10.0.0/16, 10.99.0.0/24 } tcp dport 5432 accept
ip6 saddr fd42::/64 tcp dport 5432 accept
# Log + drop everything else
log prefix "nft-drop: " limit rate 5/minute
counter drop
}
}
If you haven’t built a layered firewall yet, our nftables hardening guide covers ruleset organisation and automated threat response that fits cleanly in front of Postgres.
PgBouncer in front, with TLS upstream
PgBouncer terminates client connections, holds them in a pool, and lets you patch or restart Postgres without dropping users. It also gives you a second authentication choke point. The key bits of /etc/pgbouncer/pgbouncer.ini:
A custom fail2ban jail watches the Postgres JSON log for FATAL: password authentication failed lines and bans the source IP after three failures in five minutes. Combined with the deny-by-default nftables policy, this catches the slow trickle of attempts that beat rate-limiting.
Encrypted backups and WAL archiving
An attacker who cannot read your live database will reach for backups next. The same hardening applies:
Use pgBackRest or WAL-G with --repo1-cipher-type=aes-256-cbc (pgBackRest) or age/gpg encryption (WAL-G) so the bucket cannot read your data.
Store backup credentials in systemd-creds or Vault, never in /etc/cron.d.
Run a weekly integrity check (pgbackrest verify), because a backup you never restore is not a backup.
Replicate the encrypted bucket to a separate cloud account or region. The bucket the prod app can write to should not also hold the only recovery copy.
If you are migrating to immutable, signature-verified backups, the patterns in our Linux supply chain security guide for cosign/Sigstore work for backup artifacts too.
CIS PostgreSQL benchmark checklist
The CIS PostgreSQL Benchmark v1.1 (2025) maps neatly onto everything above. Use this as your pre-flight check:
Automate the checks with OpenSCAP’s PostgreSQL profile or pgaudit_analyze. The official pg_hba.conf reference is the source of truth when a benchmark control disagrees with reality.
Frequently Asked Questions
How do I secure PostgreSQL on Linux quickly?
The fastest meaningful pass: set password_encryption = scram-sha-256, replace every trust and md5 line in pg_hba.conf with hostssl ... scram-sha-256, set listen_addresses to specific interfaces, turn on ssl with a valid certificate, and firewall port 5432 to your app subnet only. Those five changes close roughly 80% of the real-world attack surface and take well under an hour.
Should I disable trust authentication in PostgreSQL?
Yes, always, everywhere, including development. trust means “accept the username the client claims, no verification.” A single forgotten trust line above your hardened rules grants unauthenticated superuser access. Use peer for local OS-mapped logins and scram-sha-256 for everything else.
What is the best authentication method for PostgreSQL?
For application traffic, scram-sha-256 over TLS 1.3 is the right default, because it resists offline brute force and credential replay. For privileged paths (replication, break-glass DBA, cross-region admin), pair it with X.509 client certificates and clientcert=verify-full. Reserve peer for OS-mapped local connections.
How do I enable SSL/TLS in PostgreSQL?
Set ssl = on, point ssl_cert_file and ssl_key_file at certs owned by the postgres user with mode 0600, set ssl_min_protocol_version = 'TLSv1.3', restart the cluster, and switch pg_hba.conf lines from host to hostssl. Verify by querying pg_stat_ssl for an active session.
What is pgaudit and how do I use it?
pgaudit is an extension that produces structured, object-level audit logs (schema changes, role grants, writes, and reads against tagged objects) rather than PostgreSQL’s raw log_statement firehose. Install it, add pgaudit to shared_preload_libraries, set pgaudit.log = 'ddl, role, write' as a sensible baseline, and forward the JSON logs to your SIEM (Wazuh, Elastic, or Splunk).
How do I set up row-level security in PostgreSQL for multi-tenancy?
Add a tenant_id column to the table, run ALTER TABLE ... ENABLE ROW LEVEL SECURITY and FORCE ROW LEVEL SECURITY, then CREATE POLICY rules comparing tenant_id to a session GUC like current_setting('app.tenant_id'). The application issues SET LOCAL app.tenant_id = ... at the start of every transaction. Always index the policy column.
How Linux kernel live patching works in 2026 with kpatch, Ubuntu Livepatch, and TuxCare KernelCare. Install commands, comparison table, and audit tips for zero-downtime CVE fixes.
Wolfi vs Distroless vs Alpine base images for 2026 Linux workloads, with CVE benchmarks, apko and cosign pipeline snippets, and real migration examples for Node.js and Python services.
Deploy Keylime continuous remote attestation on Linux with TPM 2.0: registrar, verifier, Rust agent, IMA runtime policies, and revocation webhooks in 2026.