Skip to main content
RunBook Academy

← All break/fix scenarios in PostgreSQL

intermediatepg-tls~40 min

A packet capture found database credentials in cleartext on a cluster where TLS had been enabled and verified eight months earlier

Reported symptoms

  • A scheduled network capture during a compliance audit finds PostgreSQL protocol traffic in cleartext, including a SCRAM exchange and query text, on port 5432
  • The captured traffic is all to db-replica-02, never to the primary
  • TLS was enabled across the estate eight months earlier and signed off with evidence at the time
  • No application has reported an error, and no application configuration has changed
  • The connection strings in the application configuration do not mention sslmode at all
  • A test connection from the database host with psql shows ssl = t, which is what the original sign-off recorded
  • db-replica-02 was rebuilt from a base image in March after a hardware failure

Evidence

  • · SHOW ssl on the primary returns on; SHOW ssl on db-replica-02 returns off
  • · The March rebuild ran the standard base image build and then pg_basebackup, and postgresql.auto.conf on the replica does not contain an ssl entry
  • · The pg_hba.conf on both servers uses host rather than hostssl for the application rule, so nothing rejects a plaintext client
  • · Every application connection string omits sslmode, and the libpq default is prefer
  • · A client with sslmode=prefer against a server with ssl = off connects successfully, with pg_stat_ssl reporting ssl = f, and emits no error, no warning, and no server log entry
  • · The same client with sslmode=require against the same server fails with server does not support SSL, but SSL was required
  • · Joining pg_stat_activity to pg_stat_ssl on the replica shows every application session with ssl = f
  • · The original sign-off evidence is a psql run from the database host itself against 127.0.0.1
Diagnosis and resolutionclick to reveal

Root cause

`db-replica-02` was rebuilt in March. The rebuild produced a working replica and nobody noticed that `ssl` was left `off`, because nothing in the estate required it to be `on`. Every application connection string omits `sslmode`. The libpq default is `prefer`, which means: attempt a TLS handshake, and if the server says it does not support TLS, **continue in cleartext**. There is no error, no warning, no log entry, and no connection-level indication that the negotiation failed. The client asked politely and accepted no for an answer. This is exactly what `prefer` is specified to do. It is not a defect in libpq. It is a defect in relying on `prefer` for a security property: `prefer` is an optimisation, not a control. An attacker positioned on the network does not need to break TLS — they only need to answer "no" to the handshake, and every `prefer` client will hand over its SCRAM exchange and its query text. Nothing enforced TLS on the server side either. Both hosts use `host` rather than `hostssl` in `pg_hba.conf`, so the server accepts a plaintext connection as readily as an encrypted one. Two independent controls — client-side `sslmode` and server-side `hostssl` — were both set to their permissive value, so the March rebuild had nothing to trip over. The sign-off eight months earlier is the third defect. It was a `psql` run from the database host against `127.0.0.1`, on the primary. It tested one connection path on one server, and the estate has two servers and several application paths. It could not have detected this, and it was filed as though it could.

Remediation

Establish the real state of every server before changing anything: ```sql SHOW ssl; SELECT name, setting FROM pg_settings WHERE name IN ('ssl','ssl_cert_file','ssl_key_file','ssl_ca_file','ssl_min_protocol_version'); ``` Then find which live sessions are actually unencrypted. This is the query that measures the problem rather than the configuration: ```sql SELECT a.pid, a.usename, a.client_addr, a.application_name, s.ssl, coalesce(s.version, '(plaintext)') AS tls_version FROM pg_stat_activity a LEFT JOIN pg_stat_ssl s USING (pid) WHERE a.client_addr IS NOT NULL ORDER BY s.ssl NULLS FIRST, a.pid; ``` Enable TLS on the replica with the same certificate material the primary uses. `ssl` itself is reloadable; the certificate paths are read when the SSL context is built: ```sql ALTER SYSTEM SET ssl = on; ALTER SYSTEM SET ssl_cert_file = '/etc/postgresql/18/main/tls/server.crt'; ALTER SYSTEM SET ssl_key_file = '/etc/postgresql/18/main/tls/server.key'; SELECT pg_reload_conf(); ``` Confirm `SHOW ssl` is `on` and that a `prefer` client now negotiates TLSv1.3, before going further. Now close the hole properly, in this order — server enforcement last, so you do not lock out a client that is still misconfigured: 1. **Set `sslmode` explicitly in every client.** `verify-full` is the only value that authenticates the server as well as encrypting the channel; `require` encrypts but will accept any certificate, including an attacker's. 2. **Distribute the CA certificate** to each client and set `sslrootcert`. `verify-full` cannot work without it. 3. **Change `host` to `hostssl`** in `pg_hba.conf` for every application rule, and reload. From that point the server refuses plaintext at the door rather than hoping clients ask for encryption. Rotate the credentials that were exposed. They travelled in cleartext across a network for eight months, on a path an auditor was able to capture. Treat them as disclosed.

Verification

`SHOW ssl` returns `on` on every server in the estate, checked individually rather than assumed from one. The live-session query returns no row with `ssl = f`, from every application, after a full deployment cycle has restarted the connection pools. Existing connections keep whatever they negotiated, so this check is only meaningful once connections have turned over. A plaintext client is now **refused**, which is the check that proves enforcement rather than configuration: ```text psql: error: connection to server at "10.12.9.8", port 5432 failed: FATAL: no pg_hba.conf entry for host "10.12.9.8", user "app_ro", database "reporting", no encryption ``` Note the `no encryption` at the end of that message — PostgreSQL is telling you the client arrived without TLS and no rule matched it. A `verify-full` client connecting by the name in the certificate succeeds, and the same client connecting by a different name fails: ```text psql: error: connection to server at "db.wrong.name" (10.12.9.8), port 5432 failed: server certificate for "pg.lab.internal" (and 1 other name) does not match host name "db.wrong.name" ``` That failure is the proof `verify-full` is doing identity checking. `require` and `verify-ca` both connect happily in that case. A repeat packet capture on the same segment shows no cleartext protocol traffic.

Prevention

**Enforce on the server with `hostssl`, do not request on the client with `sslmode`.** A client-side setting is a preference that a rebuild, a library default, or a new service can quietly ignore. `hostssl` is the control, and it fails closed. **Never leave `sslmode` unset.** The default is `prefer`, and `prefer` accepts cleartext without saying so. Set `verify-full` explicitly, in every connection string, including the ones in cron jobs and migration tools. **Understand what each mode actually guarantees**, measured on PostgreSQL 18.6 against a server with `ssl = off`: | `sslmode` | Server has TLS off | What it guarantees when TLS is on | | --- | --- | --- | | `disable` | connects, cleartext | nothing | | `allow` | connects, cleartext | nothing | | `prefer` *(default)* | **connects, cleartext, silently** | nothing | | `require` | refused | encryption only — accepts any certificate | | `verify-ca` | refused | encryption + a certificate from your CA | | `verify-full` | refused | encryption + CA + the server is who it claims | Only `verify-full` resists an active attacker. `require` protects against passive capture and nothing else. **Alert on `ssl = off` and on `pg_stat_ssl.ssl = false`.** The first catches a server; the second catches a client that got through anyway. Both are cheap queries and either would have caught this in March. **Put TLS in the host build and in a post-build check.** The March rebuild produced a working replica, and "working" did not include this. A rebuild checklist that ends with `SHOW ssl` costs nothing. **Do not accept loopback evidence for a network control.** The original sign-off tested `127.0.0.1` on one host. Verification must traverse the same path the application traverses, from the same network position.

Reported symptoms

A scheduled network capture during a compliance audit finds PostgreSQL protocol traffic in cleartext on port 5432 — a SCRAM exchange and query text, readable.

All of it is to db-replica-02. None to the primary.

TLS was enabled across the estate eight months ago and signed off with evidence at the time. No application has reported an error. No application configuration has changed. The connection strings do not mention sslmode at all.

A test connection from the database host with psql shows ssl = t, which is what the original sign-off recorded.

db-replica-02 was rebuilt from a base image in March after a hardware failure.

Evidence provided

SHOW ssl on the primary returns on. On db-replica-02 it returns off. The March rebuild ran the base image build and then pg_basebackup; postgresql.auto.conf on the replica has no ssl entry.

Both servers use host, not hostssl, for the application rule.

Every connection string omits sslmode, so every client gets the libpq default, which is prefer. Here is what that actually does:

Read-only / Safeevery sslmode against one server with ssl = off
$ for M in disable allow prefer require verify-ca verify-full; do psql "host=... sslmode=$M" -c 'SELECT ssl FROM pg_stat_ssl WHERE pid=pg_backend_pid()'; done
  sslmode=disable:      ssl = f
sslmode=allow:        ssl = f
sslmode=prefer:       ssl = f
sslmode=require:
  psql: error: connection to server at "172.17.0.11", port 5432 failed: server does not support SSL, but SSL was required
sslmode=verify-ca:
  psql: error: connection to server at "172.17.0.11", port 5432 failed: server does not support SSL, but SSL was required
sslmode=verify-full:
  psql: error: connection to server at "172.17.0.11", port 5432 failed: server does not support SSL, but SSL was required

And on the replica, joining the two views shows it directly:

Read-only / Safewhich live sessions are actually encrypted
$ psql -c "SELECT a.pid, a.usename, a.client_addr, a.application_name, s.ssl, coalesce(s.version,'(plaintext)') AS tls_version FROM pg_stat_activity a LEFT JOIN pg_stat_ssl s USING (pid) WHERE a.client_addr IS NOT NULL ORDER BY s.ssl NULLS FIRST, a.pid;"
 pid | usename  | client_addr | application_name | ssl | tls_version 
-----+----------+-------------+------------------+-----+-------------
140 | postgres | 172.17.0.11 | psql             | f   | (plaintext)
138 | postgres | 172.17.0.11 | psql             | t   | TLSv1.3
147 | postgres | 172.17.0.11 | psql             | t   | TLSv1.3
(3 rows)

The original sign-off evidence is a psql run from the database host against 127.0.0.1, on the primary.

Work the evidence before reading on

  1. No application reported an error for eight months. Why not?
  2. sslmode is absent from every connection string. What value applies?
  3. Two independent controls could have stopped this. Name both, and say what each was set to.
  4. What did the original sign-off actually prove?

Root cause

prefer is an optimisation, not a control

Two permissive defaults met each other

Client side: sslmode unset, so prefer. Server side: host rather than hostssl, so plaintext is accepted at the door.

Either control alone would have caught the March rebuild. Both were at their permissive value, so the rebuild had nothing to trip over and produced a replica that worked perfectly and encrypted nothing.

PostgreSQL itself never downgrades

It is worth knowing where the silence does not come from, because it narrows the search:

Read-only / Safea corrupt certificate on reload: the old context is kept
$ psql -c 'SELECT pg_reload_conf();' && tail -2 postgresql.log
2026-08-28 07:20:23.305 UTC [1] LOG:  could not load server certificate file "/var/lib/postgresql/18/docker/tls/server.crt": no start line
2026-08-28 07:20:23.305 UTC [1] LOG:  SSL configuration was not reloaded

New connections after that reload still negotiated TLSv1.3. And across a restart with the same broken file:

Service impact possiblea corrupt certificate on restart: the server refuses to start
$ pg_ctl restart && tail -2 postgresql.log
2026-08-28 07:20:37.833 UTC [1] FATAL:  could not load server certificate file "/var/lib/postgresql/18/docker/tls/server.crt": no start line
2026-08-28 07:20:37.833 UTC [1] LOG:  database system is shut down

The sign-off could not have caught it

It was one psql run, from the database host, over 127.0.0.1, on the primary. The estate has two servers and several application paths. It tested none of them, and it was filed as though it had.

Resolution

Establish the real state of every server:

SHOW ssl;
SELECT name, setting FROM pg_settings
WHERE name IN ('ssl','ssl_cert_file','ssl_key_file','ssl_ca_file','ssl_min_protocol_version');

Then measure sessions, not configuration:

SELECT a.pid, a.usename, a.client_addr, a.application_name,
       s.ssl, coalesce(s.version, '(plaintext)') AS tls_version
FROM pg_stat_activity a
LEFT JOIN pg_stat_ssl s USING (pid)
WHERE a.client_addr IS NOT NULL
ORDER BY s.ssl NULLS FIRST, a.pid;

Enable TLS on the replica with the same certificate material as the primary. ssl is reloadable:

ALTER SYSTEM SET ssl = on;
ALTER SYSTEM SET ssl_cert_file = '/etc/postgresql/18/main/tls/server.crt';
ALTER SYSTEM SET ssl_key_file  = '/etc/postgresql/18/main/tls/server.key';
SELECT pg_reload_conf();

Confirm SHOW ssl is on and that a prefer client now negotiates TLSv1.3 — before going further.

Then close the hole, in this order, so you do not lock out a client that is still misconfigured:

  1. Set sslmode=verify-full explicitly in every client.
  2. Distribute the CA certificate and set sslrootcert. verify-full cannot work without it.
  3. Change host to hostssl for every application rule, and reload.

Verification

SHOW ssl returns on on every server, checked individually.

The live-session query returns no row with ssl = f, from any application, after a deployment cycle has recycled the pools. Existing connections keep whatever they negotiated, so this check means nothing until connections have turned over.

A plaintext client is now refused — this is the check that proves enforcement rather than configuration:

Read-only / Safehostssl rejects a cleartext client at the door
$ psql "host=... sslmode=disable" -c "select 1"
psql: error: connection to server at "172.17.0.11", port 5432 failed: FATAL:  no pg_hba.conf entry for host "172.17.0.11", user "postgres", database "postgres", no encryption

Note no encryption at the end: PostgreSQL is telling you the client arrived without TLS and no rule matched it.

A verify-full client succeeds by the certificate’s name and fails by any other name. That failure is the proof identity checking is on; require and verify-ca both connect happily in that case.

A repeat packet capture on the same segment shows no cleartext protocol traffic.

Prevention

Enforce on the server with hostssl; do not request on the client with sslmode. A client-side setting is a preference that a rebuild, a library default, or a new service can quietly ignore. hostssl fails closed.

Never leave sslmode unset. Set verify-full explicitly, everywhere — including cron jobs and migration tools.

Alert on ssl = off and on pg_stat_ssl.ssl = false. The first catches a server; the second catches a client that got through anyway. Either would have caught this in March.

Put TLS in the host build and in a post-build check. The March rebuild produced a replica that worked, and “worked” did not include this. A rebuild checklist ending in SHOW ssl costs nothing.

Do not accept loopback evidence for a network control. Verification must traverse the same path the application traverses, from the same network position.