PostgreSQL Authentication — pg_hba, SCRAM-SHA-256, and SASL
Contents:
- Theoretical Background
- Common DBMS Design
- PostgreSQL’s Approach
- Source Walkthrough
- Source verification (as of 2026-06-05)
- Beyond PostgreSQL — Comparative Designs & Research Frontiers
- Sources
Theoretical Background
Section titled “Theoretical Background”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:
-
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.
-
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).
-
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.
-
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-PLUSvariant.
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.
Common DBMS Design
Section titled “Common DBMS Design”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.
Policy table separated from mechanism
Section titled “Policy table separated from mechanism”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.
Verifier storage, never cleartext
Section titled “Verifier storage, never cleartext”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.
Pluggable mechanism via a vtable
Section titled “Pluggable mechanism via a vtable”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.
A post-decision, pre-notification hook
Section titled “A post-decision, pre-notification hook”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 ↔ PostgreSQL mapping
Section titled “Theory ↔ PostgreSQL mapping”| Theory / convention | PostgreSQL name |
|---|---|
| Host-based policy table | pg_hba.conf → port->hba->auth_method |
| Policy lookup | hba_getauthmethod(port) in ClientAuthentication |
| Method dispatch | switch (port->hba->auth_method) in ClientAuthentication |
| Stored verifier | pg_authid.rolpassword (shadow_pass) |
| Verifier scheme sniff | get_password_type in crypt.c |
| Wire envelope (request) | AuthenticationRequest 'R' + AUTH_REQ_* code |
| Wire envelope (response) | PasswordMessage/SASLInitialResponse/SASLResponse ('p') |
| SASL driver | CheckSASLAuth in auth-sasl.c |
| Mechanism vtable | pg_be_sasl_mech (pg_be_scram_mech) |
| SCRAM state machine | scram_exchange (SCRAM_AUTH_INIT/SALT_SENT/FINISHED) |
| Server nonce | build_server_first_message (pg_strong_random) |
| Mutual-auth verifier | build_server_final_message (ServerSignature) |
| Channel binding | channel_binding_in_use, be_tls_get_certificate_hash |
| Mock authentication | mock_scram_secret + state->doomed |
| Constant-time compare | timingsafe_bcmp in verify_client_proof, md5_crypt_verify |
| Post-decision hook | ClientAuthentication_hook |
| Identity recording | set_authn_id → MyClientConnectionInfo.authn_id |
PostgreSQL’s Approach
Section titled “PostgreSQL’s Approach”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 */The dispatch: ClientAuthentication
Section titled “The dispatch: ClientAuthentication”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.
MD5: a single salted round
Section titled “MD5: a single salted round”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.
SCRAM-SHA-256 over SASL
Section titled “SCRAM-SHA-256 over SASL”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")andServerKey = HMAC(SaltedPassword, "Server Key").StoredKey = H(ClientKey)— the one extra hash that lets the server verify a proof without holdingClientKeyitself.
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.
Source Walkthrough
Section titled “Source Walkthrough”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 commit273fe94.
The SASL driver (libpq/auth-sasl.c)
Section titled “The SASL driver (libpq/auth-sasl.c)”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 SCRAM mechanism (libpq/auth-scram.c)
Section titled “The SCRAM mechanism (libpq/auth-scram.c)”The mechanism is registered as a pg_be_sasl_mech:
// pg_be_scram_mech — libpq/auth-scram.cconst 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_INIT →
SCRAM_AUTH_SALT_SENT → SCRAM_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.
Password verifiers (libpq/crypt.c)
Section titled “Password verifiers (libpq/crypt.c)”crypt.c is the scheme-aware layer over pg_authid.rolpassword:
get_role_password—SearchSysCache1(AUTHNAME, …), extractsrolpassword, enforcesrolvaliduntil. Returns NULL (with a log-onlylogdetail) for missing user, no password, or expired password. The NULL is what arms mock authentication upstream.get_password_type— sniffs the scheme:md5prefix +MD5_PASSWD_LEN+ hex charset →PASSWORD_TYPE_MD5; elseparse_scram_secretsuccess →PASSWORD_TYPE_SCRAM_SHA_256; elsePASSWORD_TYPE_PLAINTEXT.md5_crypt_verify— re-hashes the stored hash with the challenge salt andtimingsafe_bcmps against the client response.plain_crypt_verify— used byuaPassword; if the stored secret is SCRAM it callsscram_verify_plain_password(recompute ServerKey from the cleartext), if MD5 it hashes-and-compares, all constant-time.encrypt_password— theCREATE/ALTER ... PASSWORDpath; builds a SCRAM secret viapg_be_scram_build_secretor 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 */}Glue in auth.c
Section titled “Glue in auth.c”ClientAuthentication— policy lookup + dispatch + hook + terminal.auth_failed— vague client message, detailed server log,ERRCODE_INVALID_PASSWORDfor the password family.sendAuthRequest—pq_beginmessage(PqMsg_AuthenticationRequest)+pq_sendint32(areq)+ optional body; flushes for every code exceptAUTH_REQ_OKandAUTH_REQ_SASL_FIN(which ride out with the post-auth messages).recv_password_packet— reads a'p'PasswordMessage, bounds it byPG_MAX_AUTH_TOKEN_LENGTH, rejects empty passwords.CheckPasswordAuth/CheckPWChallengeAuth/CheckMD5Auth— the three password drivers.set_authn_id— records the authenticated identity exactly once intoMyClientConnectionInfo.authn_id; a second call isFATAL(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)”| Symbol | File | Line |
|---|---|---|
ClientAuthentication_hook (global) | libpq/auth.c | 223 |
auth_failed | libpq/auth.c | 239 |
set_authn_id | libpq/auth.c | 341 |
ClientAuthentication | libpq/auth.c | 379 |
sendAuthRequest | libpq/auth.c | 677 |
recv_password_packet | libpq/auth.c | 707 |
CheckPasswordAuth | libpq/auth.c | 788 |
CheckPWChallengeAuth | libpq/auth.c | 823 |
CheckMD5Auth | libpq/auth.c | 883 |
CheckSASLAuth | libpq/auth-sasl.c | 44 |
pg_be_scram_mech | libpq/auth-scram.c | 114 |
scram_get_mechanisms | libpq/auth-scram.c | 206 |
scram_init | libpq/auth-scram.c | 240 |
scram_exchange | libpq/auth-scram.c | 352 |
pg_be_scram_build_secret | libpq/auth-scram.c | 483 |
scram_verify_plain_password | libpq/auth-scram.c | 523 |
parse_scram_secret | libpq/auth-scram.c | 600 |
mock_scram_secret | libpq/auth-scram.c | 697 |
read_client_first_message | libpq/auth-scram.c | 913 |
verify_client_proof | libpq/auth-scram.c | 1149 |
build_server_first_message | libpq/auth-scram.c | 1202 |
read_client_final_message | libpq/auth-scram.c | 1266 |
build_server_final_message | libpq/auth-scram.c | 1412 |
scram_mock_salt | libpq/auth-scram.c | 1471 |
get_role_password | libpq/crypt.c | 38 |
get_password_type | libpq/crypt.c | 90 |
encrypt_password | libpq/crypt.c | 117 |
md5_crypt_verify | libpq/crypt.c | 202 |
plain_crypt_verify | libpq/crypt.c | 257 |
AUTH_REQ_* constants | include/libpq/protocol.h | 74 |
Source verification (as of 2026-06-05)
Section titled “Source verification (as of 2026-06-05)”Facts about the source at commit
273fe94, readable without external materials. Open questions follow.
Verified facts
Section titled “Verified facts”-
ClientAuthenticationis the single dispatch point, and every path funnels throughClientAuthentication_hookbefore the terminal message. Verified inClientAuthentication(auth.c): the hook is called with(port, status)after the method switch and theCheckCertAuthpost-check, and before eithersendAuthRequest(…, AUTH_REQ_OK, …)orauth_failed. This guarantees an audit/delay hook sees the real outcome of every method, includinguaTrust(status set toSTATUS_OKwithout any client round-trip). -
uaMD5may transparently run SCRAM. Verified inCheckPWChallengeAuth: MD5 is used only whenport->hba->auth_method == uaMD5 && pwtype == PASSWORD_TYPE_MD5; otherwiseCheckSASLAuth(&pg_be_scram_mech, …)runs. So anmd5hba line on a role with a SCRAM secret performs SCRAM, while ascramline 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, andscram_exchange. Whenshadow_passis NULL,scram_initbuilds a deterministic mock secret (mock_scram_secret→scram_mock_salt, seeded by username + cluster mock nonce) and setsdoomed = true. The full server-first / client-final exchange still runs, andverify_client_proofis invoked before thedoomedshort-circuit, so the cryptographic work is identical for existing and non-existing users. -
The server never stores or transmits
ClientKey. Verified inparse_scram_secret(secret formatSCRAM-SHA-256$<iter>:<salt>$<StoredKey>:<ServerKey>) andverify_client_proof(the server recovers a candidateClientKeyfrom the client’s proof and re-hashes it to compare againstStoredKey). Apg_authiddump yieldsStoredKey/ServerKeyonly, 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, andplain_crypt_verifyall usetimingsafe_bcmp(nevermemcmp/strcmp) for the secret-dependent comparison. -
set_authn_idenforces single-assignment. Verified inset_authn_id: a second call withMyClientConnectionInfo.authn_idalready set raisesFATAL(“authentication identifier set more than once”), defending against two providers each believing they authenticated the connection. -
AUTH_REQ_OKandAUTH_REQ_SASL_FINare not flushed bysendAuthRequest. Verified insendAuthRequest: thepq_flush()is guarded byif (areq != AUTH_REQ_OK && areq != AUTH_REQ_SASL_FIN). These two ride out with theParameterStatus/BackendKeyData/ReadyForQueryburst that follows, saving a syscall. -
Channel binding is advertised only under TLS and enforced by recomputation. Verified in
scram_get_mechanisms(SCRAM-SHA-256-PLUSappended only#ifdef USE_SSLandport->ssl_in_use) andread_client_final_message(server recomputes thec=value frombe_tls_get_certificate_hashandstrcmps). The gs2 flag handling inread_client_first_messagerejects they-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) raisesERRCODE_INVALID_PASSWORD(“empty password returned by client”), covering external systems (PAM/LDAP/RADIUS) where the catalog-level empty check does not apply.
Open questions
Section titled “Open questions”-
Interaction of
clientcert=verify-fullwith SCRAM channel binding.ClientAuthenticationrunsCheckCertAuthafter a successful password/SASL exchange whenclientcert == clientCertFull. Whether and how this composes withSCRAM-SHA-256-PLUSchannel binding (both touch the server certificate) is not traced here. Path: readCheckCertAuthinauth.candbe_tls_get_certificate_hashinbe-secure-openssl.c. -
scram_sha_256_iterationsGUC vs. stored iteration count. New secrets usescram_sha_256_iterations(defaultSCRAM_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. -
OAUTHBEARER mechanism (
pg_be_oauth_mech). PG18 adds an OAuth SASL mechanism that reuses the sameCheckSASLAuthdriver (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_authiddump does not yield directly-usable credentials, but a passive observer of a successful exchange who also later stealsStoredKeycan 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
WARNINGfromencrypt_passwordwhenever 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 ... PASSWORDre-hashing,password_encryptionGUC) belongs in apostgres-roles-acl.mdcompanion. -
The SASL framework as an extension seam.
CheckSASLAuthis fully mechanism-agnostic —pg_be_scram_mechandpg_be_oauth_mechare twopg_be_sasl_mechinstances. A third-party extension could, in principle, register a new mechanism, but PostgreSQL exposes no public registration API (the dispatch inClientAuthenticationis a hard-coded switch). Contrast with theClientAuthentication_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_hookin the wild. Real extensions use this hook for: failed-login rate limiting / exponential backoff (auth_delayin 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 fromPortwould round outpostgres-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_userpooler pattern and how it readsStoredKey/ServerKeyfrompg_authidis a real-world stress test of the verifier format.
Sources
Section titled “Sources”Protocol & cryptography specifications
Section titled “Protocol & cryptography specifications”- 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_messageis 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.confsyntax,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.c—ClientAuthentication,auth_failed,set_authn_id,sendAuthRequest,recv_password_packet,CheckPasswordAuth,CheckPWChallengeAuth,CheckMD5Auth,ClientAuthentication_hook.src/backend/libpq/auth-sasl.c—CheckSASLAuth(the SASL driver loop).src/backend/libpq/auth-scram.c—pg_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.c—get_role_password,get_password_type,encrypt_password,md5_crypt_verify,plain_crypt_verify.src/include/libpq/protocol.h—AUTH_REQ_*constants,PqMsg_AuthenticationRequest,PqMsg_PasswordMessage,PqMsg_SASLResponse.src/include/common/scram-common.h—SCRAM_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.
Cross-references (sibling module docs)
Section titled “Cross-references (sibling module docs)”postgres-wire-protocol.md— the FE/BE framing, startup handshake, andsendAuthRequest’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 feedsbe_tls_get_certificate_hashfor channel binding; theuaGSS/uaSSPIKerberos arms of the dispatch.postgres-hooks.md— the hook catalog includingClientAuthentication_hook.postgres-backend-lifecycle.md— howClientAuthenticationis reached frompostmaster→ backend startup →InitPostgres.