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 Guide for Linux (2026)

Updated: June 1, 2026

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:

sudo install -o postgres -g postgres -m 0600 server.key /etc/postgresql/tls/server.key
sudo install -o postgres -g postgres -m 0644 server.crt /etc/postgresql/tls/server.crt
sudo systemctl restart postgresql@17-main

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:

  1. A single NOLOGIN group role per data domain (e.g., app_billing, app_inventory) that owns the schema.
  2. One or more LOGIN roles per service that inherit from the group with the minimum verbs needed.
  3. A separate read-only role for analytics, BI, and on-call queries.
  4. 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:

# /etc/vector/vector.yaml — ship to a Wazuh or Elastic ingest pipeline
sources:
  pg_audit:
    type: file
    include: ["/var/log/postgresql/postgresql-*.json"]
    read_from: end

transforms:
  parse:
    type: remap
    inputs: ["pg_audit"]
    source: |
      . = parse_json!(.message)
      .source_type = "postgres_pgaudit"

sinks:
  siem:
    type: elasticsearch
    inputs: ["parse"]
    endpoints: ["https://siem.internal:9200"]
    auth: { strategy: basic, user: "vector", password: "${SIEM_PW}" }
    tls: { ca_file: "/etc/ssl/internal-ca.crt", verify_certificate: true }

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:

[databases]
app_prod = host=10.10.5.12 port=5432 dbname=app_prod sslmode=verify-full

[pgbouncer]
listen_addr   = 10.20.0.5
listen_port   = 6432
pool_mode     = transaction
auth_type     = scram-sha-256
auth_file     = /etc/pgbouncer/userlist.txt
server_tls_sslmode  = verify-full
server_tls_ca_file  = /etc/pgbouncer/internal-ca.crt
client_tls_sslmode  = require
client_tls_cert_file = /etc/pgbouncer/pgbouncer.crt
client_tls_key_file  = /etc/pgbouncer/pgbouncer.key
ignore_startup_parameters = extra_float_digits
max_client_conn  = 2000
default_pool_size = 25

fail2ban for brute-force attempts

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:

ControlSettingVerification
3.1.4 No trust authgrep -E '^\s*(local|host).*trust' pg_hba.conf returns nothingManual review
3.1.7 SCRAM onlypassword_encryption = scram-sha-256SHOW password_encryption;
3.2.1 TLS enabledssl = on, TLS 1.3 floorSELECT ssl FROM pg_stat_ssl;
4.2 No superuser appsApplication roles lack SUPERUSERSELECT rolname FROM pg_authid WHERE rolsuper;
5.1 pgaudit loadedshared_preload_libraries includes pgauditSHOW shared_preload_libraries;
6.7 log_connectionslog_connections = onSHOW log_connections;
7.3 Encrypted backupspgBackRest or WAL-G with cipherBucket policy review
8.1 Patched version17.x current minorSELECT version();

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.

Article changelog (1)
  • — Expanded with TL;DR, table of contents, or additional sections
About the Author Editorial Team

Our team of expert writers and editors.