April 21, 2025·4 min read·engineeringpostgresqlsecurityauthentication

PgBouncer 1.24.1 includes password expiry in authentication queries

CVE-2025-2291 comes from fetching a password hash without enforcing its expiry. The fix changes the default authentication query, while custom queries need a corresponding policy change.

Fetching a password hash from PostgreSQL does not reproduce PostgreSQL's entire authentication decision. The hash answers whether a supplied password matches a stored secret. Its expiry is separate metadata, and a component that authenticates clients itself must account for that metadata too. PgBouncer 1.24.1 corrects this omission in its default authentication query.

The PgBouncer release notice dates the release to April 16. The PostgreSQL community announcement describes CVE-2025-2291 as a password-expiry bypass affecting configurations that combine auth_user with PostgreSQL's VALID UNTIL. It directs operators with custom authentication queries to change those queries as well.

I default to PostgreSQL for relational work and value its explicit role semantics. A pooler introduces a second place where those semantics must be implemented. This fix is a useful example because a valid credential lookup can still omit a condition that the database would enforce during password authentication.

Password expiry belongs to the password

The CREATE ROLE reference defines VALID UNTIL as the time after which a role's password is no longer valid. Omitting it leaves the password valid indefinitely. The clause does not expire the role itself, and PostgreSQL does not enforce it for authentication methods that do not use a password.

That distinction narrows the intended protection. A password deadline does not mean that every way of using the role disappears at the same instant. The April defect concerns password authentication through the pooler. Treating expiry as general account revocation would assign the setting a broader meaning than the database documents.

For a pooler, the relevant question is consequently more precise than whether the role still exists. It must determine whether the password can still be used for this authentication decision. Merely finding a row for the role cannot settle that question, because continued role existence and continued password validity are independent facts.

The catalog stores the missing condition separately

PostgreSQL's pg_authid documentation lists rolpassword and rolvaliduntil as distinct columns. The former holds the encrypted password representation, while the latter holds the password deadline or null for no expiration. rolcanlogin separately records whether the role can be an initial login identity.

The catalog is shared across the cluster and is not publicly readable because it contains passwords. This matters when an external component uses it as a credential source. Reading the password field gives that component sensitive material without automatically performing every check associated with using it.

The older pg_shadow view exposes the same distinction through passwd and valuntil. It includes login roles and exists for compatibility. A custom authentication function built around that view still has to interpret password expiry. Choosing another catalog representation does not make the policy implicit.

The default query now withholds expired secrets

The configuration reference shipped with 1.24.1 shows the corrected default query:

SELECT rolname,
       CASE WHEN rolvaliduntil < now()
            THEN NULL
            ELSE rolpassword
       END
FROM pg_authid
WHERE rolname=$1 AND rolcanlogin;

The query retains a role-name lookup parameter and excludes roles without login permission. Its new conditional returns null instead of the stored password when the deadline has passed. This places the expiry decision at the point where PgBouncer obtains the credential material.

The distinction is visible in the query structure. An expired password does not cause PostgreSQL to delete the role or erase its stored secret. The authentication lookup suppresses that secret for the current decision. This permits the underlying catalog to retain information while preventing the lookup from treating an expired credential as usable.

The release's changelog entry explains the resulting deployment choices. Operators using the old default can upgrade to 1.24.1 or configure the corrected query on an older release. An explicitly configured custom query requires its own edit. Updating the executable does not rewrite policy text that the operator supplied.

The lookup identity needs carefully limited authority

The versioned configuration reference says auth_user runs the query for users absent from auth_file. Its own password comes from that file when needed. The query normally executes in the target database, so a custom function must exist in each database where that lookup runs, unless configuration directs authentication elsewhere.

Direct catalog access requires administrative rights. The reference recommends a non-superuser lookup identity calling a SECURITY DEFINER function instead. This creates a narrower interface to the privileged data, but it also makes the function's definition part of authentication policy. Its result must encode the same expiry decision as the default query. Reviewing only the outer query would miss an unchanged function that continues returning the stored secret after the password deadline.

The CREATE FUNCTION documentation explains that a security-definer function runs with its owner's privileges. It recommends excluding untrusted writable schemas from the function's search path and controlling execution grants. Those requirements concern what privileged code resolves and who may invoke it. They remain relevant even when the returned password respects its deadline.

I find the explicit query useful because it makes the missing policy inspectable. The expiry condition can be reviewed alongside the credential lookup, without assuming that the backend connection's authentication stands in for the client's. The identity running the lookup and the identity requesting access have different purposes in this design.

A binary update and a policy update can have different scopes

The April release also repairs PAM authentication broken in 1.24.0 by reverting PAM support in the HBA file. That correction is separate from the expiry predicate. Deployments need to distinguish the authentication method they use from the query that supplies password material.

For CVE-2025-2291, the decisive artifact is the effective authentication query, including any function it calls. A corrected default covers deployments that inherit that default. A custom query continues to express its own behavior, which is why the announcement explicitly includes custom-query changes in remediation. PostgreSQL's password deadline becomes effective through PgBouncer only when the credential lookup applies it.