PgBouncer 1.26.1, released on October 14, 2026, fixes three CVEs, and one of them is an authentication bypass rather than a crash: on versions 1.17.0 through 1.26.0, anyone who knows the password of a single user in auth_file can log in as any other user that authenticates with SCRAM, admins and backend superusers included. There's no 1.25.x backport on the downloads page, so the fix means moving to 1.26.1, and if you skipped 1.26.0 you also pick up its behaviour changes, one of which breaks the old -R restart.
The three CVEs in 1.26.1
All three come from the PgBouncer changelog. The GitHub security advisories page for the project had nothing published when we checked, so the changelog is the primary record.
CVE-2026-107350 is the serious one. PgBouncer 1.17.0 through 1.26.0 "processed a second StartupMessage sent during SCRAM authentication when the client connected to a non-existent database." An attacker who knows one auth_file user's password can use that to log in as someone else. Both the attacker's user and the target must authenticate with SCRAM, but the target can come from either auth_file or auth_query. That last part widens it a lot: if you use auth_query, every role in the database is a potential target.
CVE-2026-107349 is an unauthenticated denial of service in 1.20.0 through 1.26.0. A startup options parameter with an empty option name, such as -c=, makes PgBouncer spin forever on one CPU core and stop serving everyone. It is only reachable when ignore_startup_parameters is non-empty but doesn't contain options. That's a very common setup, because many people add extra_float_digits there for older JDBC drivers and stop.
CVE-2026-107348 is an integer overflow in packet buffer growth in 1.21.0 through 1.26.0. A packet header declaring a length above 2^31 hangs the process. It only applies if you raised max_packet_size to 2147483649 or more; the default of 2147483647 isn't affected. Since 1.23.0 it's reachable before authentication.
PgBouncer handles every client on a single thread, a point the 1.26.0 notes make about the similar CVE-2026-6668. A spin or a hang isn't one stuck connection. It's the whole pooler down until someone kills the process.
Are you exposed?
Three facts from the PgBouncer configuration reference decide most of it.
- auth_type = scram-sha-256 is the obvious SCRAM case. The default, md5, also switches to SCRAM for any user whose stored secret is a SCRAM secret, so "we use md5" doesn't get you out of CVE-2026-107350.
- ignore_startup_parameters defaults to empty, which is safe for CVE-2026-107349. Any non-empty value without options is exposed.
- max_packet_size defaults to 2147483647, which is safe for CVE-2026-107348.
The md5 point matters more than it looks. PostgreSQL's password_encryption setting defaults to scram-sha-256, so roles created on a modern server have SCRAM secrets, and those are what auth_query returns. A setup that still says md5 in pgbouncer.ini is often doing SCRAM in practice.
To check a config quickly, we wrote this script. It takes the running PgBouncer version and the path to pgbouncer.ini, reads the settings above, and looks inside auth_file for SCRAM secrets:
import configparser
import sys
from pathlib import Path
def ver(s):
return tuple(int(p) for p in s.split("."))
def check(version, ini_path):
v = ver(version)
cp = configparser.ConfigParser(interpolation=None, strict=False)
cp.read(ini_path)
pgb = cp["pgbouncer"] if cp.has_section("pgbouncer") else {}
auth_type = pgb.get("auth_type", "md5").strip()
auth_file = pgb.get("auth_file", "").strip()
ignore = [p.strip() for p in pgb.get("ignore_startup_parameters", "").split(",") if p.strip()]
max_packet = int(pgb.get("max_packet_size", "2147483647"))
# md5 also switches to SCRAM for users whose stored secret is SCRAM.
scram_in_file = False
if auth_file:
f = Path(ini_path).parent / auth_file
scram_in_file = f.exists() and "SCRAM-SHA-256$" in f.read_text()
scram = auth_type in ("scram-sha-256", "hba") or (auth_type == "md5" and scram_in_file)
findings = []
if ver("1.17.0") <= v <= ver("1.26.0") and auth_file and scram:
findings.append("CVE-2026-107350: auth_file user can log in as other SCRAM users")
if ver("1.20.0") <= v <= ver("1.26.0") and ignore and "options" not in ignore:
findings.append("CVE-2026-107349: unauthenticated '-c=' option spins a CPU core")
if ver("1.21.0") <= v <= ver("1.26.0") and max_packet >= 2147483649:
findings.append("CVE-2026-107348: oversized packet header hangs the process")
if v < ver("1.26.0"):
findings.append("also exposed to the 1.26.0 CVEs (CVE-2026-19888, -6668, -6669)")
if ignore and "search_path" in ignore and v == ver("1.26.0"):
findings.append("note: ignore_startup_parameters=search_path has no effect on 1.26.0")
return findings
if __name__ == "__main__":
version, ini = sys.argv[1], sys.argv[2]
found = check(version, ini)
print(f"{ini} @ {version}:")
for line in found or ["no 1.26.1 CVE conditions matched"]:
print(" -", line)
It's deliberately conservative. It treats auth_type = hba as possibly SCRAM because the real method lives in the HBA file, and it ignores the "non-existent database" condition in CVE-2026-107350 because the client chooses the database name. A match means "patch this", not "someone has broken in".
We ran it against two sample configs. The first is a typical transaction-pooling setup with auth_type = scram-sha-256, a SCRAM secret in userlist.txt, and ignore_startup_parameters = extra_float_digits. On 1.24.1 (paths shortened):
a/pgbouncer.ini @ 1.24.1:
- CVE-2026-107350: auth_file user can log in as other SCRAM users
- CVE-2026-107349: unauthenticated '-c=' option spins a CPU core
- also exposed to the 1.26.0 CVEs (CVE-2026-19888, -6668, -6669)
The second uses the default auth_type = md5, an auth_user with a SCRAM secret in auth_file, auth_query for everyone else, and ignore_startup_parameters = extra_float_digits,search_path,options. On 1.26.0:
b/pgbouncer.ini @ 1.26.0:
- CVE-2026-107350: auth_file user can log in as other SCRAM users
- note: ignore_startup_parameters=search_path has no effect on 1.26.0
Listing options keeps that config out of the CPU-spin bug, but the md5 setting doesn't protect it from the impersonation bug. The same first config on 1.26.1 reports no 1.26.1 CVE conditions matched.
To find the version you're running, the usage docs give pgbouncer -V for the binary and SHOW VERSION; on the admin console (psql -p 6432 pgbouncer, as a user listed in admin_users or stats_users). We didn't have a PgBouncer binary in this environment, so those two commands are taken from the docs rather than run here. Check the running process, not the package on disk. They differ after an upgrade that nobody restarted.
Upgrading from 1.25 or earlier
If you're on anything older than 1.26.0, you're also missing the fixes in that release, including CVE-2026-19888, an unauthenticated crash from a SCRAM client-final-message with no nonce. The changelog says that bug dates back to SCRAM support in 1.11.0 and that it works even for users that don't exist. Every pre-1.26 PgBouncer that accepts SCRAM logins can be knocked over remotely. That alone is reason to upgrade even if the 1.26.1 conditions above don't match.
Jumping straight to 1.26.1 brings in the 1.26.0 behaviour changes, and two of them can bite:
- PgBouncer now tracks every parameter PostgreSQL reports, including search_path on PostgreSQL 18 and later and default_transaction_read_only on 14 and later. That fixes a real leak in transaction pooling, where one client's SET search_path affected whoever got the server connection next. If you had search_path in ignore_startup_parameters, 1.26.0 silently ignored that setting. 1.26.1 restores the 1.25 behaviour, and the notes say other search_path tracking bugs reported since 1.26.0 will be fixed in a later release.
- Online restart (pgbouncer -R) is gone, along with the SHOW FDS and SUSPEND admin commands. If your upgrade runbook uses -R, it will fail on 1.26.
The supported replacement for -R is the rolling restart described in the usage docs. Run two or more PgBouncer processes on the same port with so_reuseport = 1. Send one of them SHUTDOWN WAIT_FOR_CLIENTS (or SIGTERM). Wait for clients to reconnect elsewhere, which usually happens as your application pool recycles connections, and the process exits once it has no clients. Start it again on the new binary, then repeat for the next one. If you run a single process today, a security patch is a decent excuse to set up a second one so the next upgrade doesn't need downtime.
If you can't upgrade today, two of the three 1.26.1 bugs have config-only mitigations: add options to ignore_startup_parameters (or empty it), and keep max_packet_size at or below its default. The config reference says listing options makes PgBouncer ignore unknown parameters inside it, while parameters it tracks are still honoured, and its own example value is options,extra_float_digits. The changelog gives no config workaround for the impersonation bug, so a setup where an auth_file user's password is known to anyone outside the admin team should upgrade first. Packages from apt.postgresql.org and yum.postgresql.org are often newer than what your distribution ships, so check them before building from source.
No comment for this article.