Postgres on the public internet — six checks before you sleep

Six self-diagnose commands and the fixes the Postgres docs forget to put on the same page. SSL, pg_hba trust, MD5 hashes, rogue superusers, idle-in-transaction storms, and the missing audit trail.

Published 2026-05-2612 min readPostgresSecurity 101Tested on PG 16
On this page
  1. Why this post exists
  2. Is TLS actually on?
  3. Is pg_hba.conf wide open?
  4. Are passwords still MD5-hashed?
  5. Is there an audit trail?
  6. Who else is a superuser?
  7. Set up the read-only metrics user — properly

Why this post exists

Our monitor-agent watches the things you can observe from outside a Postgres cluster: connection count, lag, deadlocks, cache hit ratio, XID wraparound risk. There's an honest second list of mistakes the agent cannot see — they live in postgresql.conf, pg_hba.conf, and the role catalog. This post is that second list, with copy-pasteable commands.

Every command below was run on a fresh `postgres:16` Docker container on 2026-05-26 and the exact output is in the verification matrix of [the spec](https://github.com/elk-utilities/homelab-apps/blob/main/.ai/specs/security-101-best-practices-blog.md). Version caveats are flagged inline.

Is TLS actually on?

Plain-text Postgres on a network shared with anything other than you is a credential-leak waiting for someone to run tcpdump. The default in many self-installs (Docker images, homebrew, apt without a custom config) is OFF.

Check

Check: cluster-wide SSL state

`SHOW ssl` returns `on` or `off`. If `off`, no client can negotiate TLS — including the replication stream.

SQL✓ Postgres 16
SHOW ssl;

What to look for: If you see `off`, TLS is disabled cluster-wide. Every connection — application, replication, exporter — travels plaintext.

Check

Check: what does the runtime really think?

`SHOW` reflects the current effective value, but it doesn't tell you whether a recent `ALTER SYSTEM` will survive a restart. `pg_settings` does, including the `source` column — `command line` and `override` beat `auto.conf` so a docker-CMD `-c ssl=off` will silently override anything you set via SQL.

SQL✓ Postgres 16 (column shape unchanged since 9.5)
SELECT name, setting, source, pending_restart
FROM pg_settings
WHERE name IN ('ssl','log_connections','log_disconnections','password_encryption','idle_in_transaction_session_timeout');

What to look for: `source = 'command line'` means a `docker run -c ssl=off` or systemd unit override is winning over `ALTER SYSTEM`. Remove the override at the orchestration layer and `ALTER SYSTEM` regains authority.

Fix

Fix: generate certificates and turn SSL on

For an internal cluster, a private CA or a self-signed cert is fine; for anything customer-reachable, use Let's Encrypt. The placement and modes below match what `pg_ctl -D` expects.

bash✓ Postgres 16. Works the same way on 14, 15. On 12 and below `ssl_cert_file` defaulted to a different path — check `SHOW ssl_cert_file` after reload.

Verify it worked: psql -U postgres -c "SHOW ssl;" # should now print: on

Note: Reload (`pg_reload_conf()`) is enough for the server to start ACCEPTING TLS connections; you do NOT need a restart unless you change `ssl_passphrase_command`. Existing plaintext connections stay alive until they close — set a deploy window if you also flip `hostssl`-only in pg_hba.

Is pg_hba.conf wide open?

`trust` in pg_hba.conf means "no password required, accept any login". Docker images ship with `host all all all trust` so you can `docker exec psql` without ceremony — that exact line in production is a free shell to anyone with a route to port 5432.

Check

Check: any trust rules?

The `pg_hba_file_rules` view (PG 10+) parses pg_hba.conf the same way the server does, so you don't have to grep YAML-with-Lisp-comments by hand.

SQL✓ Postgres 16. `pg_hba_file_rules` requires PG 10+; on 9.x and earlier, `cat $PGDATA/pg_hba.conf` is the alternative.
SELECT type, database, user_name, address, auth_method
FROM pg_hba_file_rules
WHERE auth_method = 'trust';

What to look for: Any row at all on a network-reachable host is a finding. The default Docker `postgres` image returns seven rows out of the box.

Fix

Fix: switch trust to scram-sha-256

Edit `pg_hba.conf` to require password auth on every non-local rule. The example below preserves local-socket trust (often required for `postgres` user maintenance) but forces SCRAM on TCP.

bash✓ Postgres 16. `hostssl` enforced TLS is a 9.0+ feature.

Verify it worked: psql -U postgres -c "SELECT count(*) FROM pg_hba_file_rules WHERE auth_method = 'trust' AND type LIKE 'host%';" # should print: 0

Note: If clients have stored MD5-hashed passwords from a PG ≤9.6 era, they need to be re-set under `password_encryption = scram-sha-256` before the new rule will accept them — see next section.

Are passwords still MD5-hashed?

MD5 has been a known-bad password hash for a decade and Postgres 14 made SCRAM the default. Clusters upgraded from 9.6 / 10 still carry MD5-hashed roles forward unless somebody actively re-set every password.

Check

Check: hash type per login role

SQL✓ Postgres 16. `pg_authid` is superuser-only; if `permission denied` use `pg_shadow` (same shape, requires `pg_read_all_settings` or superuser).

What to look for: Any `md5` row in the result is a role that needs its password re-set. `other` rows are usually roles with `rolpassword = NULL` — fine if they auth via cert or peer, suspicious otherwise.

Fix

Fix: force SCRAM and rotate weak passwords

Setting `password_encryption` first is essential — `ALTER USER` re-hashes using whatever the current setting is, so without this line you re-hash MD5 to MD5.

SQL✓ Postgres 16. `password_encryption='scram-sha-256'` requires PG 10+; 14+ uses it by default.

Verify it worked: SELECT rolname FROM pg_authid WHERE rolpassword LIKE 'md5%'; -- should return 0 rows

Note: Clients on libpq ≥10 negotiate SCRAM automatically. JDBC ≥42.2.0, pgx ≥3.0, and asyncpg ≥0.13 all support SCRAM. Embedded / legacy clients (libpq from PG 9.6 and older) cannot connect to a SCRAM role at all — you'll see `FATAL: password authentication failed`. Either upgrade the client or, transitionally, leave the specific legacy role on MD5 and audit it separately.

Is there an audit trail?

If a credential gets leaked, the recovery question is "was it used, when, from where?". With `log_connections = off`, the answer is always "we have no idea".

Check

Check: connection logging state

SQL✓ Postgres 16 (settings exist since 8.1).
SHOW log_connections;
SHOW log_disconnections;

What to look for: If either returns `off`, you have no audit trail for that lifecycle event. Both should be `on`.

Fix

Fix: enable both + add a useful log line prefix

The default `log_line_prefix` omits the client address, which makes the audit trail useless. The five-field prefix below is what most production Postgres deployments converge on.

SQL✓ Postgres 16. `log_line_prefix = '%r'` (remote host:port) has existed since 9.0.
ALTER SYSTEM SET log_connections = 'on';
ALTER SYSTEM SET log_disconnections = 'on';
ALTER SYSTEM SET log_line_prefix = '%m [%p] %r %u@%d ';
SELECT pg_reload_conf();

Verify it worked: -- After a reload, watch the log file (or kubectl logs the pod) and -- open a new psql session. You should see: -- 2026-05-26 13:42:11.111 UTC [123] 10.0.0.5(54321) app_user@app LOG: connection authorized: user=app_user database=app SELECT name, setting, source FROM pg_settings WHERE name LIKE 'log_%connections';

Note: On managed Postgres (RDS, Cloud SQL, Crunchy Bridge) the GUC is sometimes locked. Use the provider's parameter group instead — same setting names. If `SHOW` still returns `off` after reload, run the verify query: `source = 'command line'` means the orchestrator is overriding you.

Who else is a superuser?

Every application role with `rolsuper` can bypass row-level security, write to system catalogs, and `COPY … FROM PROGRAM` to execute shell commands. There should be exactly one superuser (`postgres` or your platform-installed equivalent).

Check

Check: enumerate superusers

SQL✓ Postgres 16 (column shape unchanged since 8.1).
SELECT rolname FROM pg_roles WHERE rolsuper;

What to look for: Anything other than `postgres` (and possibly your cloud provider's break-glass role like `rdsadmin`) is a finding.

Fix

Fix: revoke superuser, grant the minimum that role needs

Most "why does the app need superuser?" stories trace back to one specific command (often `CREATE EXTENSION` or `COPY … FROM PROGRAM`). Identify the actual need and grant that, not the universe.

SQL✓ Postgres 16. `pg_monitor` predefined role is 10+; `pg_extension_install_create_role` is 16+.

Verify it worked: SELECT rolname FROM pg_roles WHERE rolsuper AND rolname <> 'postgres'; -- should be empty

Set up the read-only metrics user — properly

If you're running a Prometheus exporter (ours or a self-hosted one), the exporter does not need superuser, does not need read access to row data, and definitely should not connect as `postgres`. Give it a role that has exactly `pg_monitor` and nothing else.

Fix

Fix: minimum-privilege exporter role

This is the same SQL our [Add Exporter scoped-user guide](/exporters/postgres/scoped-user) ships — repeated here so you can copy from the article and stay on the page.

SQL✓ Postgres 16. `pg_monitor` role is 10+; on 9.x and earlier, grant per-view explicitly.
CREATE USER prometheus_exporter WITH PASSWORD 'a-strong-rotated-password';
GRANT pg_monitor TO prometheus_exporter;

Verify it worked: -- Login as the new user and confirm it can read stats but cannot mutate. -- (Expect ✓ on the SELECT and ✗ on the INSERT.) SET ROLE prometheus_exporter; SELECT count(*) FROM pg_stat_activity; -- expect: a number INSERT INTO pg_catalog.pg_class VALUES (0); -- expect: ERROR: permission denied RESET ROLE;