Configuring TLS for Replication Connections

Encrypting a logical replication connection means choosing an sslmode strong enough that the decoded row-level change stream — every INSERT, UPDATE, and…

Encrypting a logical replication connection means choosing an sslmode strong enough that the decoded row-level change stream — every INSERT, UPDATE, and DELETE on your published tables — cannot be read or tampered with in transit, and this is one of the security boundaries and permissions that a consumer either enforces end-to-end or silently forfeits. Stop at sslmode=require and the traffic is encrypted but a forged server passes authentication; only verify-full closes the man-in-the-middle path that would otherwise expose the entire change feed.

This page covers the transport-crypto layer specifically: the sslmode ladder up to verify-full and what each rung defends against, the server ssl configuration and CA/certificate setup, client-certificate authentication for the replication role, and certificate rotation without dropping the stream. The pg_hba.conf rule that routes a hostssl replication connection is a companion topic covered in setting up pg_hba.conf for replication users; here the focus is the TLS handshake itself. All behavior is validated against PostgreSQL 14 through 17.

What each sslmode value protects a replication connection against A six-row matrix of libpq sslmode values against three columns: encrypts traffic, validates the server certificate against a CA, and verifies the server hostname. disable has none of the three. allow and prefer encrypt only opportunistically and validate nothing. require encrypts traffic but validates neither the certificate nor the hostname, so it stops passive eavesdropping but not an active man-in-the-middle. verify-ca encrypts and validates the certificate against the CA but does not check the hostname. verify-full has all three protections and is the only mode that defeats an active man-in-the-middle, so it is the correct choice for a replication consumer. sslmode ladder: what each rung defends the change stream against sslmode Encryptstraffic Validates certagainst CA Verifieshostname disable allow ~ prefer (default) ~ require verify-ca verify-full require stops passive eavesdropping but a forged server passes it; only verify-full defeats an active man-in-the-middle. Replication consumers should always use verify-full with a pinned CA.
The sslmode value is a security level, not a toggle: require merely encrypts, while verify-full is the only rung that authenticates the server and blocks an active interception of the change stream.

sslmode Protection Semantics

sslmode is a client-side setting that decides how far libpq will go to protect and authenticate the connection, and for a replication consumer the gap between “encrypted” and “authenticated” is the whole attack surface. The table fixes what each value guarantees and the threat it leaves open, targeting PostgreSQL 14–17.

sslmode Encryption Server authentication Threat left open
disable None None Everything — change stream in cleartext
allow Only if the server demands it None Eavesdropping; encryption is not guaranteed
prefer (libpq default) Opportunistic None Eavesdropping — silently falls back to plaintext
require Always None Active man-in-the-middle; any server cert is accepted
verify-ca Always Certificate chains to a trusted CA (sslrootcert) Hostname spoofing — a valid cert for any host passes
verify-full Always Cert chains to the CA and matches the server hostname None of the above — the correct replication default

The operationally important line is require versus verify-full. require gives a false sense of safety: the connection is encrypted, but because no certificate is validated, an attacker who can redirect the connection presents their own certificate and terminates the TLS session themselves, reading and modifying every decoded change before forwarding it. verify-ca closes the forged-certificate hole but still accepts a valid certificate issued for a different host, so a compromised but legitimately-signed server elsewhere in your estate can impersonate the publisher. Only verify-full, with sslrootcert pointing at your CA, authenticates that you are talking to the intended primary. Treat any replication DSN below verify-full as a finding. These modes pair with the role and network controls in the parent security boundaries and permissions guide.

Diagnostic Patterns

Prove that live replication connections are actually encrypted — a hostssl rule guarantees the server requires TLS, but you still want to confirm the negotiated protocol and cipher. pg_stat_ssl joined to pg_stat_replication shows exactly what each streaming consumer negotiated:

sql
-- Encryption state of every connected replication consumer.
SELECT r.application_name, r.client_addr, r.state,
       s.ssl, s.version AS tls_version, s.cipher,
       s.client_dn                              -- non-null only with client-cert auth
FROM pg_stat_replication r
JOIN pg_stat_ssl s ON s.pid = r.pid
ORDER BY r.application_name;

Any row with ssl = false is a plaintext replication stream and an immediate finding; a tls_version below TLSv1.2 means an outdated client negotiated a weak protocol and ssl_min_protocol_version should be raised. Confirm the server is presenting the certificate you expect and that it has not silently expired — an expired server certificate rejects every new verify-* connection while leaving established ones alive, so the breakage looks intermittent:

bash
# Inspect the server cert the publisher actually presents, including expiry.
openssl s_client -connect publisher.internal:5432 -starttls postgres </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates

Check the server-side TLS configuration is loaded and enforces a modern floor:

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

A require/verify-* client against a server showing ssl = off fails the handshake entirely; that mismatch is the most common reason a consumer that authenticated yesterday cannot connect after a config change.

Safe Deployment Sequence

Enabling TLS on the server is a reload for most settings but the initial ssl = on may require a restart depending on platform, so stage it as a controlled change with a tested revert.

  1. Install the server key and certificate with strict permissions — PostgreSQL refuses to start if the private key is group- or world-readable:

    bash
    install -o postgres -g postgres -m 0600 server.key /etc/postgresql/ssl/server.key
    install -o postgres -g postgres -m 0644 server.crt /etc/postgresql/ssl/server.crt
    install -o postgres -g postgres -m 0644 ca.crt     /etc/postgresql/ssl/ca.crt
    
  2. Enable TLS and pin the protocol floor in the server configuration:

    sql
    ALTER SYSTEM SET ssl = on;
    ALTER SYSTEM SET ssl_cert_file = '/etc/postgresql/ssl/server.crt';
    ALTER SYSTEM SET ssl_key_file  = '/etc/postgresql/ssl/server.key';
    ALTER SYSTEM SET ssl_ca_file   = '/etc/postgresql/ssl/ca.crt';   -- enables client-cert verification
    ALTER SYSTEM SET ssl_min_protocol_version = 'TLSv1.2';
    SELECT pg_reload_conf();                                          -- restart only if ssl was off at boot
    
  3. Require client certificates for the replication role by adding clientcert=verify-full to the hostssl line, so a stolen password alone cannot open the stream — the rule syntax is detailed in setting up pg_hba.conf for replication users:

    code
    # TYPE     DATABASE      USER          ADDRESS        METHOD        OPTIONS
    hostssl    replication   repl_etl_svc  10.0.5.0/24    scram-sha-256 clientcert=verify-full
    
  4. Prove verify-full end-to-end from a consumer host before cutting over, using the client key and the pinned CA:

    bash
    psql "host=publisher.internal dbname=replication user=repl_etl_svc \
      sslmode=verify-full sslrootcert=ca.crt sslcert=client.crt sslkey=client.key" \
      -c "IDENTIFY_SYSTEM"
    
  5. Revert. Because ssl and the file paths are reload-scoped after the first enable, rollback is ALTER SYSTEM RESET ssl; plus pg_reload_conf() — but never revert while consumers are pinned to verify-full, or every stream fails the handshake at once. Lower client sslmode first, then disable server TLS.

Pipeline Integration

A Python consumer must pin verify-full with an explicit sslrootcert in its DSN and treat a certificate failure as fatal, non-retryable — silently falling back to plaintext is exactly the failure TLS is meant to prevent. Build the connection string so encryption is not negotiable:

python
# Replication consumer that refuses anything weaker than verify-full.
import psycopg2
from psycopg2.extras import LogicalReplicationConnection

DSN = (
    "host=publisher.internal dbname=replication user=repl_etl_svc "
    "sslmode=verify-full "                 # authenticate the server, not just encrypt
    "sslrootcert=/etc/cdc/ca.crt "         # pin the CA — the anti-MITM control
    "sslcert=/etc/cdc/client.crt "         # client-cert auth for the replication role
    "sslkey=/etc/cdc/client.key"
)

def open_stream():
    try:
        return psycopg2.connect(DSN, connection_factory=LogicalReplicationConnection)
    except psycopg2.OperationalError as exc:
        msg = str(exc)
        if "certificate verify failed" in msg or "SSL" in msg:
            # A cert/hostname failure is a security event, not a transient blip. Do NOT retry.
            raise TlsVerificationError(msg) from exc
        raise                                # network/transient: caller applies backoff

Two rules keep TLS from becoming the thing that breaks the pipeline. First, rotate certificates with overlap, never with a hard swap: issue the new server and client certificates from the same CA before the old ones expire, distribute the new client key alongside the old, and reload the server (pg_reload_conf() rereads ssl_cert_file without dropping live connections) so there is always a valid pair on both ends during the window. Second, monitor expiry as a metric, exporting days-to-expiry for the server and client certificates so a rotation is scheduled, not triggered by an outage — an expired certificate rejects new verify-full connections while existing streams keep running, producing exactly the intermittent failure that is hardest to diagnose. Feed those gauges into the same collector used for slot health so certificate and stream alerts arrive together.

Failover handling. A promoted standby must present a certificate the consumers already trust — either a certificate whose subject alternative names include every publisher hostname in the topology, or per-node certificates all issued by the pinned CA. A divergent or missing certificate on the standby is a classic reason a verify-full consumer cannot reconnect after failover, since the hostname or chain no longer matches. Provision the standby’s TLS material and ssl configuration identically to the primary, and confirm the slot the consumer reattaches to is intact as covered in initializing replication slots.

Authoritative references