PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 33: Security

Client authentication and TLS

Level 6: Design and Security30 min

Before a role can run a single query, the server decides whether the connection is allowed and how the client must prove who it is. pg_hba.conf, password storage and TLS settings are where most real-world PostgreSQL breaches and lockouts begin.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Read pg_hba.conf rules the way the server does: top to bottom, first match wins
  • Store passwords as SCRAM-SHA-256 and retire MD5
  • Require TLS on the server and verify the server certificate on the client

conceptThe concept

A new connection passes three gates:

  1. listen_addresses and the port decide whether the server accepts the TCP connection at all.
  2. pg_hba.conf picks the authentication method. Each line is type database user address method [options]. The server checks lines from top to bottom and uses the first line whose type, database, user and address all match. If that method fails, there is no fallback to later lines.
  3. The method runs, then the role must have LOGIN and CONNECT on the database.
TypeMatches
localUnix-domain socket connections
hostTCP, with or without TLS
hostssl / hostnosslTCP only with TLS / only without TLS
hostgssencTCP with GSSAPI encryption
MethodUse
scram-sha-256Default choice for passwords: the password never crosses the wire and the stored verifier cannot be replayed
md5Legacy. Deprecated in PostgreSQL 18; a role with a SCRAM verifier still authenticates with SCRAM on an md5 line
peerLocal socket only: the OS user name must match the role (great for the postgres admin account)
certClient TLS certificate; the certificate CN maps to the role
gss, ldap, radius, pam, oauthCentral identity: Kerberos, directory bind, and from PostgreSQL 18 OAuth bearer tokens
trust, passwordNever on a network line: trust skips authentication, password sends cleartext

TLS is separate from authentication. ssl = on plus a certificate and key lets clients encrypt; a hostssl line forces it. The client decides how much to verify with sslmode: require encrypts but trusts any certificate, verify-ca checks the chain, verify-full also checks the host name. Only verify-full stops a man-in-the-middle.

whyWhy it matters

The most common incident pattern is a host all all 0.0.0.0/0 trust line added "temporarily" during a migration. The second is an application that connects with sslmode=prefer (the libpq default) and silently falls back to plaintext. Both are configuration, not code, so they survive every code review.

Use caseReal-world scenario

A payments database allows the application subnet with SCRAM over TLS, the DBA team only through a bastion with client certificates, and the postgres OS account through the local socket with peer. Everything else hits a final reject line, and the hba file is in version control with a CI check against pg_hba_file_rules.

Try itExamples

Run and edit these in the interactive lesson.

The rules the server loaded
SELECT line_number, type, database, user_name, address, auth_method
FROM pg_hba_file_rules
ORDER BY line_number;
This in-browser server trusts everything because nothing can reach it. A real server should show scram-sha-256, peer or cert here, and end with a reject line.
How a password is stored
BEGIN;
CREATE ROLE app_login LOGIN PASSWORD 'correct horse battery';
SELECT rolname,
       split_part(rolpassword, '$', 1) AS scheme,
       split_part(split_part(rolpassword, '$', 2), ':', 1) AS iterations
FROM pg_authid WHERE rolname = 'app_login';
SET LOCAL password_encryption = 'md5';
CREATE ROLE old_login LOGIN PASSWORD 'secret';
SELECT rolname, left(rolpassword, 3) AS prefix FROM pg_authid WHERE rolname = 'old_login';
ROLLBACK;
PostgreSQL 18 warns when it stores an MD5 hash. An MD5 hash is the password equivalent: anyone who reads it can log in to any md5 line.
Authentication and TLS settings
SELECT name, setting
FROM pg_settings
WHERE name IN ('listen_addresses', 'password_encryption', 'scram_iterations', 'authentication_timeout',
               'ssl', 'ssl_min_protocol_version', 'md5_password_warnings')
ORDER BY name;
Is this session encrypted?
SELECT a.pid, a.usename, a.client_addr, s.ssl, s.version, s.cipher
FROM pg_stat_activity a
LEFT JOIN pg_stat_ssl s ON s.pid = a.pid
WHERE a.pid = pg_backend_pid();
Empty ssl columns: this browser session has no network connection at all. On a server, replace the WHERE clause with backend_type = 'client backend' and alert on any row where ssl is false.

Specific lines first, a catch-all reject last. +dba means members of the dba role. After editing: SELECT pg_reload_conf(); then check pg_hba_file_rules for errors.

A production pg_hba.conf
# TYPE    DATABASE   USER        ADDRESS          METHOD
local     all        postgres                     peer
local     all        all                          scram-sha-256
hostssl   payments   app_rw      10.20.0.0/16     scram-sha-256
hostssl   all        +dba        10.99.1.10/32    cert clientcert=verify-full
hostssl   replication replicator 10.20.5.0/24     scram-sha-256
host      all        all         0.0.0.0/0        reject
host      all        all         ::/0             reject
Server TLS and a verifying client
# postgresql.conf
ssl = on
ssl_cert_file = '/etc/postgresql/tls/server.crt'
ssl_key_file  = '/etc/postgresql/tls/server.key'   # mode 0600, owned by postgres
ssl_ca_file   = '/etc/postgresql/tls/ca.crt'        # to verify client certificates
ssl_min_protocol_version = 'TLSv1.2'

# client connection string
postgresql://app_rw@db1.payments.internal:5432/payments?sslmode=verify-full&sslrootcert=/etc/ssl/ca.crt

internalsUnder the hood

The postmaster parses pg_hba.conf at start and on reload (SELECT pg_reload_conf() or SIGHUP). A file with an error is not loaded: the previous rules stay in force and the error appears in the log and in the error column of pg_hba_file_rules, so always check that view after editing. A SCRAM verifier in pg_authid.rolpassword looks like SCRAM-SHA-256$iterations:salt$StoredKey:ServerKey; the server can check a proof without ever knowing the password. scram_iterations (PostgreSQL 16+) raises the cost for new passwords.

Avoid / preferDo this, not that

Avoid
host  all  all  0.0.0.0/0  trust
Prefer
hostssl  payments  app_rw  10.20.0.0/16  scram-sha-256
host     all       all     0.0.0.0/0     reject

trust lets anyone who can reach the port connect as any role, including the superuser. Scope by database, role and subnet, and finish with reject.

Avoid
postgresql://app@db1/payments?sslmode=require
Prefer
postgresql://app@db1/payments?sslmode=verify-full&sslrootcert=ca.crt

require encrypts but accepts any certificate, so an attacker in the path can present their own. verify-full checks the chain and the host name.

PitfallsCommon mistakes

  • Adding a broad rule above a narrow one and wondering why the narrow one never applies: first match wins.
  • Editing pg_hba.conf and forgetting to reload, or reloading a file with a typo so the old rules stay active.
  • Switching password_encryption to scram-sha-256 and expecting existing MD5 hashes to change: each role must set its password again.
  • Relying on the libpq default sslmode=prefer, which silently falls back to an unencrypted connection.

LimitsWhen not to use it

Do not build your own login layer with a shared database role and an application-side password table for human users; use per-person roles with central identity (LDAP, Kerberos, certificates or OAuth) so access can be revoked in one place. Client certificates are also overkill for short-lived local development databases.

SecuritySecurity notes

Keep pg_hba.conf, server keys and certificates readable only by the postgres OS user. Rotate passwords to SCRAM before PostgreSQL removes MD5 support, monitor pg_stat_ssl for unencrypted sessions, and log failed connection attempts (they appear as FATAL lines) so password spraying is visible.

ChallengeTry it yourself

List every pg_hba rule that would accept a TCP connection with trust, which is the first thing to fix on any server.

Show one solution
One solution
SELECT line_number, type, database, user_name, address, auth_method
FROM pg_hba_file_rules
WHERE auth_method = 'trust' AND type LIKE 'host%'
ORDER BY line_number;

InterviewQuestions you should be able to answer

A user gets "no pg_hba.conf entry for host". Walk through the fix.
The message means no line matched the combination of connection type, database, user and client address, so the server rejected it before checking any password. Read the exact values from the error (host address, user, database, SSL on or off), compare them with pg_hba_file_rules in line order, add or fix a specific line above any catch-all reject, reload with pg_reload_conf(), and confirm the error column in pg_hba_file_rules is empty. Then retest from the client.
Why SCRAM-SHA-256 instead of MD5?
MD5 stores md5(password + username); that hash is enough to log in, so a leaked pg_authid or backup is a leaked credential, and the challenge is weak against replay and offline attacks. SCRAM stores a salted, iterated verifier, proves knowledge of the password without sending it, and supports channel binding with TLS. PostgreSQL 14 made SCRAM the default and PostgreSQL 18 deprecates MD5 passwords.
What is the difference between sslmode=require and verify-full?
require only insists on encryption; it does not check who is on the other end, so a man-in-the-middle with any certificate succeeds. verify-ca checks the certificate is signed by a trusted CA. verify-full also checks that the certificate matches the host name the client dialled. Production clients should use verify-full with the CA certificate distributed to them.

QuizCheck your understanding

5 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation