Skip to content

PostgreSQL Authentication — pg_hba, SCRAM-SHA-256, and SASL

Contents:

Authentication answers one question — is the client who it claims to be? — and it must answer it before the server grants any access to data. It is distinct from authorization (what may this identity do?, which PostgreSQL handles later through role privileges and row-level security). A database authentication subsystem sits at a uniquely exposed position: it runs on an unauthenticated connection, processes attacker-controlled bytes, and any bug — a buffer over-read, a timing leak, a username oracle — is reachable before a single privilege check. The design space is shaped by four tensions:

  1. Secret-at-rest vs. secret-on-the-wire. A naive scheme sends the cleartext password and compares it to a stored cleartext copy. Both ends are then catastrophic to leak. Better schemes store a verifier (a one-way transform of the password) and never transmit the password itself. The strongest schemes are augmented PAKE-flavored: a server compromise does not directly yield credentials usable against other servers, and a passive wire eavesdropper learns nothing replayable.

  2. Challenge-response vs. replayable token. If the server sends a fresh random nonce (challenge) and the client must fold it into its response, a captured response cannot be replayed against a later session. MD5 authentication is a weak challenge-response (one salt round); SCRAM is a strong one (HMAC over client+server nonces with an iterated salt).

  3. Mutual authentication. A one-way scheme proves the client to the server. A mutual scheme also proves the server to the client, which defeats a man-in-the-middle who has only stolen the verifier. SCRAM is mutual: the final server message is a signature the client verifies.

  4. Channel binding. Even mutual authentication over TLS can be relayed by an attacker who terminates one TLS session and opens another. Channel binding cryptographically ties the authentication exchange to the specific TLS channel (e.g., a hash of the server certificate), so a relayed exchange fails. This is the SCRAM-SHA-256-PLUS variant.

Database System Concepts (Silberschatz et al.) frames authentication as the gate of the access-control chapter and stresses that password storage must use a one-way hash with a per-user salt to resist precompiled-dictionary (“rainbow table”) attacks. Architecture of a Database System (Hellerstein et al., §“Process Models” and §“Admission Control”) places the authentication handshake inside the per-connection backend setup, before the query processor is reachable — exactly where PostgreSQL runs ClientAuthentication.

The canonical modern scheme is SCRAM (Salted Challenge Response Authentication Mechanism, RFC 5802), carried inside the SASL framework (Simple Authentication and Security Layer, RFC 4422). SASL is a meta protocol: it standardizes the envelope (mechanism negotiation, a sequence of opaque challenge/response blobs, a success/failure terminal) and lets the mechanism (SCRAM-SHA-256, GSSAPI, OAUTHBEARER, …) define the blob contents. PostgreSQL implements the server side of SCRAM over its own AUTH_REQ_SASL* envelope, which is itself layered on the v3 wire protocol’s AuthenticationRequest 'R' and PasswordMessage 'p' frames.

Authentication subsystems across engines converge on a recognizable set of patterns. Naming them first lets PostgreSQL’s auth.c read as choices within a shared space.

Almost every server splits policy (which method applies to which client?) from mechanism (how does method X actually run?). The policy is a host-based access table keyed on connection attributes — source address, database, requested role, TLS state. PostgreSQL’s pg_hba.conf (Host-Based Authentication) is the archetype: each line is type database user address method [options], matched top-to-bottom, first match wins. The match result is a single auth_method enum (uaTrust, uaMD5, uaSCRAM, uaGSS, …) that a dispatch switch turns into a mechanism call.

The role catalog stores a verifier, tagged with its scheme so the server knows how to check it. PostgreSQL’s pg_authid.rolpassword holds either a md5<hex> string or a SCRAM-SHA-256$<iter>:<salt>$<storedkey>:<serverkey> string; get_password_type sniffs the prefix. A plaintext password is never stored (and a plaintext type is an error path only).

Challenge-response with a fresh server nonce

Section titled “Challenge-response with a fresh server nonce”

To defeat replay, the server contributes randomness to every exchange. MD5 sends a 4-byte salt; SCRAM sends an 18-byte base64-encoded nonce that is concatenated with the client’s own nonce. The response is a function of both nonces plus the secret, so it is single-use.

Constant-time comparison and the username oracle

Section titled “Constant-time comparison and the username oracle”

Two subtle leaks plague naive implementations. First, comparing a computed hash to the expected one with memcmp leaks, through timing, how many leading bytes matched — so secure code uses a constant-time compare (timingsafe_bcmp). Second, returning “no such user” faster (or differently) than “wrong password” turns the login form into a username enumeration oracle. The defense is mock authentication: when the user does not exist or has no usable secret, the server fabricates a plausible-but-doomed verifier and runs the entire exchange anyway, so the client cannot distinguish “wrong user” from “wrong password” by timing or message shape.

A clean implementation expresses each SASL mechanism as a small interface — advertise your names, initialize state, process one message — and a generic driver loops over the message exchange. PostgreSQL’s pg_be_sasl_mech struct (get_mechanisms, init, exchange, max_message_length) is exactly this; CheckSASLAuth is the mechanism-agnostic driver, and pg_be_scram_mech / pg_be_oauth_mech are two implementations.

Auditing, rate-limiting, and fail2ban-style delays want to observe the authentication outcome before the client is told. The conventional spot is a single hook fired after the status is known but before the AuthenticationOk/error is sent. PostgreSQL’s ClientAuthentication_hook is invoked with (port, status) at precisely that point.

Theory / conventionPostgreSQL name
Host-based policy tablepg_hba.confport->hba->auth_method
Policy lookuphba_getauthmethod(port) in ClientAuthentication
Method dispatchswitch (port->hba->auth_method) in ClientAuthentication
Stored verifierpg_authid.rolpassword (shadow_pass)
Verifier scheme sniffget_password_type in crypt.c
Wire envelope (request)AuthenticationRequest 'R' + AUTH_REQ_* code
Wire envelope (response)PasswordMessage/SASLInitialResponse/SASLResponse ('p')
SASL driverCheckSASLAuth in auth-sasl.c
Mechanism vtablepg_be_sasl_mech (pg_be_scram_mech)
SCRAM state machinescram_exchange (SCRAM_AUTH_INIT/SALT_SENT/FINISHED)
Server noncebuild_server_first_message (pg_strong_random)
Mutual-auth verifierbuild_server_final_message (ServerSignature)
Channel bindingchannel_binding_in_use, be_tls_get_certificate_hash
Mock authenticationmock_scram_secret + state->doomed
Constant-time comparetimingsafe_bcmp in verify_client_proof, md5_crypt_verify
Post-decision hookClientAuthentication_hook
Identity recordingset_authn_idMyClientConnectionInfo.authn_id

After the startup packet is parsed (see postgres-wire-protocol.md), the backend calls ClientAuthentication(port) exactly once. That function is the spine of this document: it looks up the policy, runs the chosen mechanism, fires the hook, and sends the terminal AuthenticationOk or dies with auth_failed. Everything else — SASL driver, SCRAM mechanism, the crypt verifiers — hangs off the dispatch switch.

The authentication frames ride on the wire protocol’s 'R' (AuthenticationRequest) and 'p' (a shared type byte used for PasswordMessage, SASLInitialResponse, and SASLResponse). The 32-bit AUTH_REQ_* code in the 'R' body tells the client which sub-protocol is in play:

// AUTH_REQ_* — include/libpq/protocol.h
#define AUTH_REQ_OK 0 /* User is authenticated */
#define AUTH_REQ_PASSWORD 3 /* Password */
#define AUTH_REQ_MD5 5 /* md5 password */
#define AUTH_REQ_GSS 7 /* GSSAPI without wrap() */
#define AUTH_REQ_SASL 10 /* Begin SASL authentication */
#define AUTH_REQ_SASL_CONT 11 /* Continue SASL authentication */
#define AUTH_REQ_SASL_FIN 12 /* Final SASL message */

ClientAuthentication first resolves policy via hba_getauthmethod(port) (which fills port->hba), then performs any pre-auth clientcert checks, then switches on port->hba->auth_method:

// ClientAuthentication — libpq/auth.c (condensed dispatch)
hba_getauthmethod(port);
/* ... clientcert pre-checks ... */
switch (port->hba->auth_method)
{
case uaReject: /* explicit "reject" line → FATAL */
case uaImplicitReject: /* no matching hba line → FATAL */
/* ereport(FATAL, ...) with host/user/encryption detail */
case uaMD5:
case uaSCRAM:
status = CheckPWChallengeAuth(port, &logdetail);
break;
case uaPassword:
status = CheckPasswordAuth(port, &logdetail);
break;
case uaCert: /* fall through, treated like uaTrust + cert */
case uaTrust:
status = STATUS_OK;
break;
case uaGSS: case uaSSPI: case uaLDAP: case uaPAM:
case uaRADIUS: case uaPeer: case uaIdent: case uaOAuth:
/* ... delegated to method-specific helpers ... */
}

The terminal logic is uniform regardless of method: the hook fires, then either AUTH_REQ_OK goes out or the process dies.

// ClientAuthentication — libpq/auth.c (terminal, condensed)
if (ClientAuthentication_hook)
(*ClientAuthentication_hook) (port, status);
if (status == STATUS_OK)
sendAuthRequest(port, AUTH_REQ_OK, NULL, 0);
else
auth_failed(port, status, logdetail);

auth_failed issues a deliberately vague client-facing message (“password authentication failed for user “%s"") while logging the matched pg_hba.conf file/line and any logdetail to the server log only — the asymmetry is intentional, so the client learns nothing useful for probing.

flowchart TD
    A["ClientAuthentication(port)"] --> B["hba_getauthmethod<br/>fills port->hba"]
    B --> C{"clientcert<br/>required?"}
    C -- "yes, invalid" --> Z["FATAL"]
    C -- "ok / n/a" --> D{"auth_method?"}
    D -- "uaReject / uaImplicitReject" --> Z
    D -- "uaTrust / uaCert" --> OK["status = STATUS_OK"]
    D -- "uaPassword" --> P["CheckPasswordAuth<br/>(plaintext)"]
    D -- "uaMD5 / uaSCRAM" --> PW["CheckPWChallengeAuth"]
    D -- "uaGSS/SSPI/LDAP/...<br/>(adjacent — see cross-refs)" --> EXT["method helper"]
    P --> H
    PW --> H
    OK --> H
    EXT --> H["ClientAuthentication_hook(port, status)"]
    H --> R{"status == OK?"}
    R -- "yes" --> S["sendAuthRequest AUTH_REQ_OK"]
    R -- "no" --> F["auth_failed → FATAL"]

Figure 1 — ClientAuthentication control flow. Policy lookup (hba_getauthmethod) precedes the method dispatch. Every path converges on the ClientAuthentication_hook and then either AUTH_REQ_OK or auth_failed. GSS/SSPI/LDAP/PAM/RADIUS/ident/peer are real arms of the switch but adjacent to this doc’s scope — see the cross-references.

Choosing MD5 vs. SCRAM: CheckPWChallengeAuth

Section titled “Choosing MD5 vs. SCRAM: CheckPWChallengeAuth”

uaMD5 and uaSCRAM both route through CheckPWChallengeAuth, which decides the actual mechanism from the stored secret’s type, not from the hba line alone. The rule: an md5 hba line uses MD5 only if the stored secret is genuinely an MD5 hash; otherwise it upgrades to SCRAM. A scram line always uses SCRAM (and will fail a user who only has an MD5 secret).

// CheckPWChallengeAuth — libpq/auth.c (condensed)
shadow_pass = get_role_password(port->user_name, logdetail);
if (!shadow_pass)
pwtype = Password_encryption; /* user missing: blend in */
else
pwtype = get_password_type(shadow_pass);
if (port->hba->auth_method == uaMD5 && pwtype == PASSWORD_TYPE_MD5)
auth_result = CheckMD5Auth(port, shadow_pass, logdetail);
else
auth_result = CheckSASLAuth(&pg_be_scram_mech, port, shadow_pass, logdetail);
if (auth_result == STATUS_OK)
set_authn_id(port, port->user_name);

When get_role_password returns NULL (no such user, no password, or an expired password), shadow_pass stays NULL and is threaded through to the mechanism, which runs a doomed exchange. The Assert(auth_result != STATUS_OK) after the call encodes the invariant that a NULL secret can never authenticate.

CheckMD5Auth generates a 4-byte random salt, sends AUTH_REQ_MD5 with it, and verifies the response with md5_crypt_verify:

// CheckMD5Auth — libpq/auth.c (condensed)
if (!pg_strong_random(md5Salt, 4)) { /* LOG; STATUS_ERROR */ }
sendAuthRequest(port, AUTH_REQ_MD5, md5Salt, 4);
passwd = recv_password_packet(port);
if (passwd == NULL) return STATUS_EOF;
if (shadow_pass)
result = md5_crypt_verify(port->user_name, shadow_pass, passwd,
md5Salt, 4, logdetail);
else
result = STATUS_ERROR;

The verifier re-derives the expected response by MD5-hashing the stored hash (minus its md5 prefix) with the salt, then compares in constant time. MD5 is deprecated in PG18 — encrypt_password emits a WARNING whenever an MD5 secret is set — but still supported.

uaSCRAM (and the SCRAM-upgraded uaMD5 path) calls CheckSASLAuth(&pg_be_scram_mech, …). This is where the SASL envelope and the SCRAM mechanism meet. The driver lives in auth-sasl.c; the mechanism in auth-scram.c. The exchange is three protocol messages after the mechanism-list advertisement:

flowchart TD
    A["CheckSASLAuth: get_mechanisms<br/>advertise list"] --> B["send AUTH_REQ_SASL<br/>(SCRAM-SHA-256-PLUS, SCRAM-SHA-256)"]
    B --> C["recv SASLInitialResponse 'p'<br/>selected_mech + client-first"]
    C --> D["mech->init<br/>scram_init: load secret or mock+doom"]
    D --> E["mech->exchange #1<br/>scram_exchange SCRAM_AUTH_INIT"]
    E --> F["read_client_first_message<br/>build_server_first_message"]
    F --> G["send AUTH_REQ_SASL_CONT<br/>r=nonce,s=salt,i=iters → CONTINUE"]
    G --> H["recv SASLResponse 'p'<br/>client-final + proof"]
    H --> I["mech->exchange #2<br/>scram_exchange SCRAM_AUTH_SALT_SENT"]
    I --> J["read_client_final_message<br/>verify_final_nonce"]
    J --> K{"verify_client_proof<br/>and not doomed?"}
    K -- "no" --> L["PG_SASL_EXCHANGE_FAILURE<br/>→ STATUS_ERROR → auth_failed"]
    K -- "yes" --> M["build_server_final_message<br/>v=ServerSignature"]
    M --> N["send AUTH_REQ_SASL_FIN<br/>→ SUCCESS → STATUS_OK"]
    N --> O["ClientAuthentication: AUTH_REQ_OK"]

Figure 2 — the SCRAM-SHA-256 SASL exchange as the driver/mechanism call flow. The driver (CheckSASLAuth) owns the AUTH_REQ_SASL* envelope and the message loop; the mechanism (scram_exchange) owns the SCRAM cryptography. The first client message is a SASLInitialResponse carrying the chosen mechanism name; subsequent ones are bare SASLResponse payloads. On success the driver sends AUTH_REQ_SASL_FIN carrying the server’s signature, which the client verifies to authenticate the server.

The cryptographic heart is the stored-key / server-key split. The pg_authid secret is SCRAM-SHA-256$<iter>:<salt>$<StoredKey>:<ServerKey>. The derivation chain, all SHA-256-based, is:

  • SaltedPassword = PBKDF2(SASLprep(password), salt, iterations) — the iterated salting that makes offline brute force expensive.
  • ClientKey = HMAC(SaltedPassword, "Client Key") and ServerKey = HMAC(SaltedPassword, "Server Key").
  • StoredKey = H(ClientKey) — the one extra hash that lets the server verify a proof without holding ClientKey itself.

The client proves knowledge of the password by sending ClientProof = ClientKey XOR ClientSignature, where ClientSignature = HMAC(StoredKey, AuthMessage) and AuthMessage is the concatenation of client-first-bare, server-first, and client-final-without-proof. The server, holding only StoredKey, computes the same ClientSignature, XORs it back out of the proof to recover a candidate ClientKey, hashes it, and compares the result to its stored StoredKey in constant time:

flowchart LR
    P["password"] --> SP["SaltedPassword<br/>= PBKDF2(pw, salt, iter)"]
    SP --> CK["ClientKey<br/>= HMAC(SP, 'Client Key')"]
    SP --> SK["ServerKey<br/>= HMAC(SP, 'Server Key')"]
    CK --> ST["StoredKey<br/>= H(ClientKey)"]
    ST --> STORE[("pg_authid:<br/>iter,salt,StoredKey,ServerKey")]
    SK --> STORE
    PROOF["client ClientProof"] --> XOR["XOR with<br/>HMAC(StoredKey, AuthMessage)"]
    ST --> XOR
    XOR --> CAND["candidate ClientKey"]
    CAND --> HH["H(candidate)"]
    HH --> CMP{"timingsafe_bcmp<br/>== StoredKey?"}
    STORE --> CMP
    CMP -- "yes" --> OKK["authenticated"]
    CMP -- "no" --> FAIL["fail"]

Figure 3 — SCRAM-SHA-256 key derivation and proof verification. The left chain is what pg_be_scram_build_secret computes once at CREATE/ALTER ... PASSWORD time and stores in pg_authid. The right path is what verify_client_proof does on every login: recover a candidate ClientKey from the proof and re-hash it to match StoredKey. Because only StoredKey/ServerKey are stored, a pg_authid dump cannot be replayed to log in — the attacker would still need to invert SHA-256 to recover ClientKey. This is the augmented property from §Theory.

Anchor on symbol names, not line numbers. Use git grep -n '<symbol>' src/backend/libpq/ to relocate; line numbers in the table below are hints scoped to commit 273fe94.

CheckSASLAuth is mechanism-agnostic. It advertises the mechanism list, then loops, reading 'p' messages and feeding their payload to mech->exchange until the mechanism returns a terminal status. The first message is special — a SASLInitialResponse that names the selected mechanism and carries an optional initial client response:

// CheckSASLAuth — libpq/auth-sasl.c (condensed)
mech->get_mechanisms(port, &sasl_mechs);
appendStringInfoChar(&sasl_mechs, '\0'); /* terminate list */
sendAuthRequest(port, AUTH_REQ_SASL, sasl_mechs.data, sasl_mechs.len);
initial = true;
do {
pq_startmsgread();
mtype = pq_getbyte();
if (mtype != PqMsg_SASLResponse) { /* EOF or PROTOCOL_VIOLATION */ }
pq_getmessage(&buf, mech->max_message_length);
if (initial) {
selected_mech = pq_getmsgrawstring(&buf);
opaq = mech->init(port, selected_mech, shadow_pass); /* may doom */
inputlen = pq_getmsgint(&buf, 4);
input = (inputlen == -1) ? NULL : pq_getmsgbytes(&buf, inputlen);
initial = false;
} else {
inputlen = buf.len;
input = pq_getmsgbytes(&buf, buf.len);
}
result = mech->exchange(opaq, input, inputlen,
&output, &outputlen, logdetail);
if (output) {
if (result == PG_SASL_EXCHANGE_SUCCESS)
sendAuthRequest(port, AUTH_REQ_SASL_FIN, output, outputlen);
else
sendAuthRequest(port, AUTH_REQ_SASL_CONT, output, outputlen);
}
} while (result == PG_SASL_EXCHANGE_CONTINUE);
return (result == PG_SASL_EXCHANGE_SUCCESS) ? STATUS_OK : STATUS_ERROR;

Note the invariant guard: a PG_SASL_EXCHANGE_FAILURE that nonetheless produced output is forbidden by SASL and triggers an elog(ERROR, "output message found after SASL exchange failure"). The driver never inspects the SCRAM payload — that opacity is the whole point of the SASL layer.

Key symbols:

  • CheckSASLAuth — the driver loop; advertise → init → exchange* → terminal.
  • pg_be_sasl_mech — the mechanism vtable type (get_mechanisms, init, exchange, max_message_length).
  • PqMsg_SASLResponse — the 'p' type byte expected for every client message in the loop.

The mechanism is registered as a pg_be_sasl_mech:

// pg_be_scram_mech — libpq/auth-scram.c
const pg_be_sasl_mech pg_be_scram_mech = {
scram_get_mechanisms,
scram_init,
scram_exchange,
PG_MAX_SASL_MESSAGE_LENGTH
};

scram_get_mechanisms advertises SCRAM-SHA-256-PLUS first (only when port->ssl_in_use, since channel binding needs TLS) and then SCRAM-SHA-256, NUL-separated:

// scram_get_mechanisms — libpq/auth-scram.c (condensed)
#ifdef USE_SSL
if (port->ssl_in_use) {
appendStringInfoString(buf, SCRAM_SHA_256_PLUS_NAME);
appendStringInfoChar(buf, '\0');
}
#endif
appendStringInfoString(buf, SCRAM_SHA_256_NAME);
appendStringInfoChar(buf, '\0');

scram_init parses the client’s chosen mechanism (setting channel_binding_in_use), then loads the stored secret via parse_scram_secret. If the secret is missing or not SCRAM, it falls back to mock_scram_secret and sets state->doomed = true — the exchange will run to completion and fail, indistinguishable from a wrong password:

// scram_init — libpq/auth-scram.c (condensed, mock fallback)
if (!got_secret) {
mock_scram_secret(state->port->user_name, &state->hash_type,
&state->iterations, &state->key_length,
&state->salt, state->StoredKey, state->ServerKey);
state->doomed = true;
}

scram_exchange is the two-state machine (SCRAM_AUTH_INITSCRAM_AUTH_SALT_SENTSCRAM_AUTH_FINISHED). The doomed check is applied after verify_client_proof runs, on purpose — the proof is computed even in a doomed exchange to keep timing uniform:

// scram_exchange — libpq/auth-scram.c (condensed)
switch (state->state) {
case SCRAM_AUTH_INIT:
read_client_first_message(state, input);
*output = build_server_first_message(state);
state->state = SCRAM_AUTH_SALT_SENT;
result = PG_SASL_EXCHANGE_CONTINUE;
break;
case SCRAM_AUTH_SALT_SENT:
read_client_final_message(state, input);
if (!verify_final_nonce(state)) { /* PROTOCOL_VIOLATION */ }
/* NB: proof computed even when doomed, to thwart timing attacks */
if (!verify_client_proof(state) || state->doomed) {
result = PG_SASL_EXCHANGE_FAILURE;
break;
}
*output = build_server_final_message(state);
result = PG_SASL_EXCHANGE_SUCCESS;
state->state = SCRAM_AUTH_FINISHED;
break;
}

build_server_first_message generates the server nonce with pg_strong_random (18 raw bytes, base64-encoded) and emits r=<clientnonce><servernonce>,s=<salt>,i=<iterations>:

// build_server_first_message — libpq/auth-scram.c (condensed)
if (!pg_strong_random(raw_nonce, SCRAM_RAW_NONCE_LEN)) { /* ERROR */ }
encoded_len = pg_b64_encode(raw_nonce, SCRAM_RAW_NONCE_LEN,
state->server_nonce, encoded_len);
state->server_first_message =
psprintf("r=%s%s,s=%s,i=%d",
state->client_nonce, state->server_nonce,
state->salt, state->iterations);

verify_client_proof is the core check: HMAC the auth-message tuple under StoredKey to get ClientSignature, XOR with the client’s ClientProof to recover a candidate ClientKey, hash it once (scram_H), and compare to the stored StoredKey with timingsafe_bcmp:

// verify_client_proof — libpq/auth-scram.c (condensed)
pg_hmac_init(ctx, state->StoredKey, state->key_length);
pg_hmac_update(ctx, client_first_message_bare, ...);
pg_hmac_update(ctx, (uint8 *) ",", 1);
pg_hmac_update(ctx, server_first_message, ...);
pg_hmac_update(ctx, (uint8 *) ",", 1);
pg_hmac_update(ctx, client_final_message_without_proof, ...);
pg_hmac_final(ctx, ClientSignature, state->key_length);
for (i = 0; i < state->key_length; i++)
state->ClientKey[i] = state->ClientProof[i] ^ ClientSignature[i];
scram_H(state->ClientKey, state->hash_type, state->key_length,
client_StoredKey, &errstr);
if (timingsafe_bcmp(client_StoredKey, state->StoredKey, state->key_length) != 0)
return false;
return true;

build_server_final_message computes ServerSignature = HMAC(ServerKey, auth-message) and returns v=<base64>. The client recomputes the same HMAC from its own ServerKey and checks the match — that is the mutual-authentication step that proves the server holds the verifier.

Channel binding is enforced inside read_client_final_message. When channel_binding_in_use, the server recomputes the expected c= value from be_tls_get_certificate_hash and strcmps it against what the client sent; when not in use, the c= value must be exactly biws ("n,," base64) or eSws ("y,," base64) and must match the original cbind_flag:

// read_client_final_message — libpq/auth-scram.c (condensed, cbind)
channel_binding = read_attr_value(&p, 'c');
if (state->channel_binding_in_use) {
cbind_data = be_tls_get_certificate_hash(state->port, &cbind_data_len);
/* cbind_input = "p=tls-server-end-point,," || cbind_data, then base64 */
if (strcmp(channel_binding, b64_message) != 0)
ereport(ERROR, (errmsg("SCRAM channel binding check failed")));
} else {
if (!(strcmp(channel_binding, "biws") == 0 && state->cbind_flag == 'n') &&
!(strcmp(channel_binding, "eSws") == 0 && state->cbind_flag == 'y'))
ereport(ERROR, (errmsg("unexpected SCRAM channel-binding attribute ...")));
}

The gs2 flag parsing in read_client_first_message rejects the downgrade attack: a client that selected SCRAM-SHA-256-PLUS must send p=; a client sending y (supports binding but thinks the server doesn’t) is rejected when the server does support binding.

Key symbols: scram_get_mechanisms, scram_init, scram_exchange, read_client_first_message, build_server_first_message, read_client_final_message, verify_final_nonce, verify_client_proof, build_server_final_message, parse_scram_secret, mock_scram_secret, scram_mock_salt, pg_be_scram_build_secret, scram_verify_plain_password.

crypt.c is the scheme-aware layer over pg_authid.rolpassword:

  • get_role_passwordSearchSysCache1(AUTHNAME, …), extracts rolpassword, enforces rolvaliduntil. Returns NULL (with a log-only logdetail) for missing user, no password, or expired password. The NULL is what arms mock authentication upstream.
  • get_password_type — sniffs the scheme: md5 prefix + MD5_PASSWD_LEN + hex charset → PASSWORD_TYPE_MD5; else parse_scram_secret success → PASSWORD_TYPE_SCRAM_SHA_256; else PASSWORD_TYPE_PLAINTEXT.
  • md5_crypt_verify — re-hashes the stored hash with the challenge salt and timingsafe_bcmps against the client response.
  • plain_crypt_verify — used by uaPassword; if the stored secret is SCRAM it calls scram_verify_plain_password (recompute ServerKey from the cleartext), if MD5 it hashes-and-compares, all constant-time.
  • encrypt_password — the CREATE/ALTER ... PASSWORD path; builds a SCRAM secret via pg_be_scram_build_secret or warns on MD5.
// md5_crypt_verify — libpq/crypt.c (condensed)
pg_md5_encrypt(shadow_pass + strlen("md5"), md5_salt, md5_salt_len,
crypt_pwd, &errstr);
if (strlen(client_pass) == strlen(crypt_pwd) &&
timingsafe_bcmp(client_pass, crypt_pwd, strlen(crypt_pwd)) == 0)
retval = STATUS_OK;
else
retval = STATUS_ERROR; /* logdetail: "Password does not match" */
// plain_crypt_verify — libpq/crypt.c (condensed, SCRAM branch)
switch (get_password_type(shadow_pass)) {
case PASSWORD_TYPE_SCRAM_SHA_256:
if (scram_verify_plain_password(role, client_pass, shadow_pass))
return STATUS_OK;
/* else logdetail + STATUS_ERROR */
case PASSWORD_TYPE_MD5:
pg_md5_encrypt(client_pass, (uint8 *) role, strlen(role),
crypt_client_pass, &errstr);
/* timingsafe_bcmp vs shadow_pass */
}
  • ClientAuthentication — policy lookup + dispatch + hook + terminal.
  • auth_failed — vague client message, detailed server log, ERRCODE_INVALID_PASSWORD for the password family.
  • sendAuthRequestpq_beginmessage(PqMsg_AuthenticationRequest) + pq_sendint32(areq) + optional body; flushes for every code except AUTH_REQ_OK and AUTH_REQ_SASL_FIN (which ride out with the post-auth messages).
  • recv_password_packet — reads a 'p' PasswordMessage, bounds it by PG_MAX_AUTH_TOKEN_LENGTH, rejects empty passwords.
  • CheckPasswordAuth / CheckPWChallengeAuth / CheckMD5Auth — the three password drivers.
  • set_authn_id — records the authenticated identity exactly once into MyClientConnectionInfo.authn_id; a second call is FATAL (two providers fighting).
  • ClientAuthentication_hook — the extension point.

Position hints (as of 2026-06-05, REL_18 273fe94)

Section titled “Position hints (as of 2026-06-05, REL_18 273fe94)”
SymbolFileLine
ClientAuthentication_hook (global)libpq/auth.c223
auth_failedlibpq/auth.c239
set_authn_idlibpq/auth.c341
ClientAuthenticationlibpq/auth.c379
sendAuthRequestlibpq/auth.c677
recv_password_packetlibpq/auth.c707
CheckPasswordAuthlibpq/auth.c788
CheckPWChallengeAuthlibpq/auth.c823
CheckMD5Authlibpq/auth.c883
CheckSASLAuthlibpq/auth-sasl.c44
pg_be_scram_mechlibpq/auth-scram.c114
scram_get_mechanismslibpq/auth-scram.c206
scram_initlibpq/auth-scram.c240
scram_exchangelibpq/auth-scram.c352
pg_be_scram_build_secretlibpq/auth-scram.c483
scram_verify_plain_passwordlibpq/auth-scram.c523
parse_scram_secretlibpq/auth-scram.c600
mock_scram_secretlibpq/auth-scram.c697
read_client_first_messagelibpq/auth-scram.c913
verify_client_prooflibpq/auth-scram.c1149
build_server_first_messagelibpq/auth-scram.c1202
read_client_final_messagelibpq/auth-scram.c1266
build_server_final_messagelibpq/auth-scram.c1412
scram_mock_saltlibpq/auth-scram.c1471
get_role_passwordlibpq/crypt.c38
get_password_typelibpq/crypt.c90
encrypt_passwordlibpq/crypt.c117
md5_crypt_verifylibpq/crypt.c202
plain_crypt_verifylibpq/crypt.c257
AUTH_REQ_* constantsinclude/libpq/protocol.h74

Facts about the source at commit 273fe94, readable without external materials. Open questions follow.

  • ClientAuthentication is the single dispatch point, and every path funnels through ClientAuthentication_hook before the terminal message. Verified in ClientAuthentication (auth.c): the hook is called with (port, status) after the method switch and the CheckCertAuth post-check, and before either sendAuthRequest(…, AUTH_REQ_OK, …) or auth_failed. This guarantees an audit/delay hook sees the real outcome of every method, including uaTrust (status set to STATUS_OK without any client round-trip).

  • uaMD5 may transparently run SCRAM. Verified in CheckPWChallengeAuth: MD5 is used only when port->hba->auth_method == uaMD5 && pwtype == PASSWORD_TYPE_MD5; otherwise CheckSASLAuth(&pg_be_scram_mech, …) runs. So an md5 hba line on a role with a SCRAM secret performs SCRAM, while a scram line on a role with only an MD5 secret runs SCRAM against a SCRAM-incapable secret and fails.

  • Mock authentication is symmetric in both timing and message shape. Verified across CheckPWChallengeAuth, scram_init, and scram_exchange. When shadow_pass is NULL, scram_init builds a deterministic mock secret (mock_scram_secretscram_mock_salt, seeded by username + cluster mock nonce) and sets doomed = true. The full server-first / client-final exchange still runs, and verify_client_proof is invoked before the doomed short-circuit, so the cryptographic work is identical for existing and non-existing users.

  • The server never stores or transmits ClientKey. Verified in parse_scram_secret (secret format SCRAM-SHA-256$<iter>:<salt>$<StoredKey>:<ServerKey>) and verify_client_proof (the server recovers a candidate ClientKey from the client’s proof and re-hashes it to compare against StoredKey). A pg_authid dump yields StoredKey/ServerKey only, which are not directly usable as login credentials.

  • All credential comparisons are constant-time. Verified: verify_client_proof, verify_final_nonce, scram_verify_plain_password, md5_crypt_verify, and plain_crypt_verify all use timingsafe_bcmp (never memcmp/strcmp) for the secret-dependent comparison.

  • set_authn_id enforces single-assignment. Verified in set_authn_id: a second call with MyClientConnectionInfo.authn_id already set raises FATAL (“authentication identifier set more than once”), defending against two providers each believing they authenticated the connection.

  • AUTH_REQ_OK and AUTH_REQ_SASL_FIN are not flushed by sendAuthRequest. Verified in sendAuthRequest: the pq_flush() is guarded by if (areq != AUTH_REQ_OK && areq != AUTH_REQ_SASL_FIN). These two ride out with the ParameterStatus/BackendKeyData/ ReadyForQuery burst that follows, saving a syscall.

  • Channel binding is advertised only under TLS and enforced by recomputation. Verified in scram_get_mechanisms (SCRAM-SHA-256-PLUS appended only #ifdef USE_SSL and port->ssl_in_use) and read_client_final_message (server recomputes the c= value from be_tls_get_certificate_hash and strcmps). The gs2 flag handling in read_client_first_message rejects the y-flag downgrade when the server supports binding.

  • Empty passwords are rejected at the wire. Verified in recv_password_packet: buf.len == 1 (just the NUL terminator) raises ERRCODE_INVALID_PASSWORD (“empty password returned by client”), covering external systems (PAM/LDAP/RADIUS) where the catalog-level empty check does not apply.

  1. Interaction of clientcert=verify-full with SCRAM channel binding. ClientAuthentication runs CheckCertAuth after a successful password/SASL exchange when clientcert == clientCertFull. Whether and how this composes with SCRAM-SHA-256-PLUS channel binding (both touch the server certificate) is not traced here. Path: read CheckCertAuth in auth.c and be_tls_get_certificate_hash in be-secure-openssl.c.

  2. scram_sha_256_iterations GUC vs. stored iteration count. New secrets use scram_sha_256_iterations (default SCRAM_SHA_256_DEFAULT_ITERATIONS), but verification uses the iteration count stored in the secret. The migration story when an admin lowers/ raises the GUC (old secrets keep their old count until re-set) is noted but not exercised here.

  3. OAUTHBEARER mechanism (pg_be_oauth_mech). PG18 adds an OAuth SASL mechanism that reuses the same CheckSASLAuth driver (uaOAuth → CheckSASLAuth(&pg_be_oauth_mech, …)). Its validator-module interface and token flow are out of scope here; path: libpq/auth-oauth.c.

Beyond PostgreSQL — Comparative Designs & Research Frontiers

Section titled “Beyond PostgreSQL — Comparative Designs & Research Frontiers”

Pointers, not analysis. Each bullet is a starting handle for a follow-up document.

  • SCRAM vs. true augmented PAKE (OPAQUE). SCRAM is “augmented” only in the weak sense that a pg_authid dump does not yield directly-usable credentials, but a passive observer of a successful exchange who also later steals StoredKey can mount an offline dictionary attack, and the server learns enough to impersonate the client to a third server that shares the verifier. Modern asymmetric PAKEs (OPAQUE, RFC draft; CPace) close these gaps: the server never sees anything password-equivalent even transiently. A comparison of OPAQUE’s pre-computation resistance against SCRAM’s iterated-salt model would clarify what PG18 does and does not protect against.

  • MD5 deprecation timeline. PG18 emits a WARNING from encrypt_password whenever an MD5 secret is set (md5_password_warnings), signaling removal. The unsalted-by-username weakness (PostgreSQL’s MD5 salts by role name, so the same password under two roles yields different hashes, but the challenge salt is only 4 bytes) makes it brute-forceable. Tracing the catalog upgrade path (ALTER ROLE ... PASSWORD re-hashing, password_encryption GUC) belongs in a postgres-roles-acl.md companion.

  • The SASL framework as an extension seam. CheckSASLAuth is fully mechanism-agnostic — pg_be_scram_mech and pg_be_oauth_mech are two pg_be_sasl_mech instances. A third-party extension could, in principle, register a new mechanism, but PostgreSQL exposes no public registration API (the dispatch in ClientAuthentication is a hard-coded switch). Contrast with the ClientAuthentication_hook, which is a public seam but only observes outcomes. A study of what a “pluggable auth mechanism” API would need (cf. Linux PAM, SASL libraries like Cyrus) is a natural design note.

  • ClientAuthentication_hook in the wild. Real extensions use this hook for: failed-login rate limiting / exponential backoff (auth_delay in contrib is the in-tree example, named here only as a reference — contrib is out of scope), security-event auditing (pgaudit), and IP allow/deny overlays. The hook’s (port, status) signature and its firing point (post-decision, pre-notification) make it the canonical place; a catalog of community hooks and what each reads from Port would round out postgres-hooks.md.

  • Connection pooler / proxy authentication. PgBouncer, Odyssey, and the upcoming in-core connection pooling all have to either pass SCRAM through transparently or terminate it and re-authenticate to the backend, which interacts badly with channel binding (the pooler’s TLS cert ≠ the backend’s). The auth_query/auth_user pooler pattern and how it reads StoredKey/ServerKey from pg_authid is a real-world stress test of the verifier format.

  • RFC 5802 — Salted Challenge Response Authentication Mechanism (SCRAM) SASL and GSS-API Mechanisms. The message grammar quoted in read_client_first_message / read_client_final_message / build_server_first_message is lifted from this RFC.
  • RFC 4422 — Simple Authentication and Security Layer (SASL). The envelope (CheckSASLAuth) follows this framework.
  • RFC 5929 — Channel Bindings for TLS (tls-server-end-point).
  • PostgreSQL documentation, “Client Authentication” chapter — pg_hba.conf syntax, password_encryption, SCRAM/channel-binding configuration.

PostgreSQL source (under /data/hgryoo/references/postgres, REL_18 273fe94)

Section titled “PostgreSQL source (under /data/hgryoo/references/postgres, REL_18 273fe94)”
  • src/backend/libpq/auth.cClientAuthentication, auth_failed, set_authn_id, sendAuthRequest, recv_password_packet, CheckPasswordAuth, CheckPWChallengeAuth, CheckMD5Auth, ClientAuthentication_hook.
  • src/backend/libpq/auth-sasl.cCheckSASLAuth (the SASL driver loop).
  • src/backend/libpq/auth-scram.cpg_be_scram_mech, scram_get_mechanisms, scram_init, scram_exchange, read_client_first_message, build_server_first_message, read_client_final_message, verify_client_proof, verify_final_nonce, build_server_final_message, parse_scram_secret, mock_scram_secret, scram_mock_salt, pg_be_scram_build_secret, scram_verify_plain_password.
  • src/backend/libpq/crypt.cget_role_password, get_password_type, encrypt_password, md5_crypt_verify, plain_crypt_verify.
  • src/include/libpq/protocol.hAUTH_REQ_* constants, PqMsg_AuthenticationRequest, PqMsg_PasswordMessage, PqMsg_SASLResponse.
  • src/include/common/scram-common.hSCRAM_SHA_256_NAME, SCRAM_SHA_256_PLUS_NAME, SCRAM_RAW_NONCE_LEN, key-length constants.

Textbook chapters (under knowledge/research/dbms-general/)

Section titled “Textbook chapters (under knowledge/research/dbms-general/)”
  • Database System Concepts (Silberschatz et al.) — access control, password storage with salted one-way hashes, authentication vs. authorization.
  • Architecture of a Database System (Hellerstein et al.), §“Process Models” / §“Admission Control” — placement of the authentication handshake in per-connection backend setup.
  • postgres-wire-protocol.md — the FE/BE framing, startup handshake, and sendAuthRequest’s place in it; the 'R' / 'p' message types.
  • postgres-tls-gssapi.md — (planned) be-secure-openssl.c / be-secure-gssapi.c; TLS setup that precedes auth and feeds be_tls_get_certificate_hash for channel binding; the uaGSS/uaSSPI Kerberos arms of the dispatch.
  • postgres-hooks.md — the hook catalog including ClientAuthentication_hook.
  • postgres-backend-lifecycle.md — how ClientAuthentication is reached from postmaster → backend startup → InitPostgres.