SevyDevy

Guide · Backend

PgBouncer's October 2026 Authentication Bypass Explained: Upgrade to 1.26.1

18 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • The PgBouncer authentication bypass, CVE-2026-107350, let a client who knows one SCRAM user's password log in as another SCRAM user, even an admin. A CVE is a public ID for one security flaw.
  • PgBouncer is a connection pooler: a small program between your app and PostgreSQL that shares a few database connections among many clients. SCRAM is a login where the client proves it knows the password without sending it.
  • The bug affects 1.17.0 to 1.26.0. During a SCRAM login to a database that does not exist, PgBouncer accepted a second StartupMessage, the message that names the user. The user switched, but the proof was checked against the first user's keys.
  • PgBouncer 1.26.1, released on 14 October 2026, fixes it and two denial-of-service bugs, which freeze PgBouncer for everyone. Those two only bite with a non-default setting, and each has a config workaround.
  • Beginners can stop after the section on how the bypass works. Engineers get a tested exposure-check script, why a distro version string can mislead, the 1.26 upgrade traps and what the advisory does not say.

What goes wrong when a database pooler trusts a second login message?

A login that should only read reports can end up with admin rights. The PgBouncer authentication bypass did exactly that in versions 1.17.0 to 1.26.0. Anyone who knew one SCRAM user's password could log in as another SCRAM user with more power.

Picture a common setup. A food ordering app runs eight app servers on PostgreSQL, with PgBouncer 1.25.2 in front of the database. The setup is made up, but the bug is real.

Three logins pass through the team's PgBouncer, and all three use SCRAM. app_user is the app's own login. report_reader is a read-only login whose password sits in a spreadsheet shared with 14 analysts. pgb_admin can run admin commands such as SHUTDOWN.

On 1.25.2, anyone holding the report_reader password could have logged in as pgb_admin. One SHUTDOWN at 12:30 pm would stop every order in the lunch rush. This guide follows the team from that finding to a checked fix.

What is the PgBouncer authentication bypass fixed in October 2026?

It is a login flaw tracked as CVE-2026-107350, fixed in PgBouncer 1.26.1 on 14 October 2026. With one SCRAM user's password, a client could log in as another SCRAM user, including admins and superusers on the PostgreSQL server behind it. A superuser is a PostgreSQL user that bypasses every permission check except the right to log in.

The CVE record scores it 8.8 out of 10 on CVSS 3.1, the common severity scale, and rates it High. There is no workaround: the record lists none, so the fix is the upgrade.

What does a connection pooler like PgBouncer do?

A connection pooler keeps a small set of open database connections and lends them to many clients. PgBouncer does this for PostgreSQL, so the database is not flooded with connections it has to manage.

PgBouncer has three pool modes. In session mode, a client keeps one server connection until it disconnects. In transaction mode, it holds one only for a transaction. Statement mode returns it after each query.

The team's eight app servers open up to 50 connections each, so PgBouncer sees 400 clients. In transaction mode it serves them with 40 real PostgreSQL connections. It also has a built-in admin console, a special database named pgbouncer.

Users listed in admin_users can run every console command, such as SHUTDOWN and PAUSE. Users in stats_users can only run read-only SHOW commands. That split is what the bypass breaks.

The team's database path

Every login stops at PgBouncer first. Notice that PgBouncer, not PostgreSQL, decides who the user is, using the secrets in auth_file.

Diagram connections:

  • 8 app servers to PgBouncer 1.25.2 (Log in and send queries)
  • PgBouncer 1.25.2 to auth_file (Look up the user's secret)
  • PgBouncer 1.25.2 to Admin console (Built-in admin database)
  • PgBouncer 1.25.2 to PostgreSQL (Pooled connections)
  • 8 app servers: Up to 400 client connections in total
  • PgBouncer 1.25.2: Checks logins, shares 40 server connections
  • auth_file: User names and SCRAM secrets
  • Admin console: pgbouncer database: SHOW, PAUSE, SHUTDOWN
  • PostgreSQL: Orders, menus and customers

What is a StartupMessage in the PostgreSQL protocol?

A StartupMessage is the first real message a client sends on a new PostgreSQL connection. It names the user, the database and the protocol version, plus optional settings for the session.

The server answers with an authentication request, and the client replies with its proof. In the flow PostgreSQL's protocol docs describe, the StartupMessage comes once per connection, before authentication.

How does SCRAM authentication work in PostgreSQL?

SCRAM-SHA-256 is a challenge-response login: the client proves it knows the password without sending the password. PostgreSQL's docs call it the most secure of the password methods it currently provides.

The server first lists the login methods it accepts. The client picks SCRAM and sends a first message with a random value. The server replies with a challenge holding that user's salt, a random value mixed into the password. The client then sends a proof.

The server checks the proof against keys stored for that user. These SCRAM secrets are made from the password, but they are not the password. PgBouncer reads them from auth_file, or fetches them with auth_query, a lookup in the database.

A normal SCRAM login through PgBouncer

Message names come from the PostgreSQL protocol docs. Notice that the user named in the StartupMessage decides whose salt and keys are used from step 2 onwards.

Diagram connections:

  • StartupMessage to AuthenticationSASL (User is now fixed)
  • AuthenticationSASL to SASLInitialResponse
  • SASLInitialResponse to AuthenticationSASLContinue
  • AuthenticationSASLContinue to SASLResponse
  • SASLResponse to AuthenticationSASLFinal (Proof matches stored keys)
  • StartupMessage: Client → PgBouncer: user, database, settings
  • AuthenticationSASL: PgBouncer → Client: login methods it accepts
  • SASLInitialResponse: Client → PgBouncer: picks SCRAM, sends a random value
  • AuthenticationSASLContinue: PgBouncer → Client: challenge with the user's salt
  • SASLResponse: Client → PgBouncer: proof of the password
  • AuthenticationSASLFinal: PgBouncer → Client: done, then AuthenticationOk

How does the PgBouncer authentication bypass work?

PgBouncer accepted a second StartupMessage in the middle of a SCRAM login, and that message switched the login user. The changelog and the CVE record both describe this.

The record lists the conditions. The client asks for a database that does not exist. The attacker knows the password of a user listed in auth_file. Both that user and the target log in with SCRAM.

Pause and predict: the name changed halfway through the login. Whose keys does PgBouncer use to check the final proof?

The first user's keys. The attacker builds the proof with the password they know, so it matches. PgBouncer then finishes the login as the second user, the one the attacker never had a password for.

The bypass, step by step

Notice the split at the decision box: the proof is checked for report_reader, but the login lands as pgb_admin. Simplified; the record does not publish the exact message order or describe the session that follows.

Diagram connections:

  • First StartupMessage to SCRAM exchange begins
  • SCRAM exchange begins to Second StartupMessage
  • Second StartupMessage to Proof sent
  • Proof sent to Proof matches?
  • Proof matches? to Logged in as pgb_admin (Yes)
  • Proof matches? to Login refused (No)
  • First StartupMessage: Client → PgBouncer: report_reader, missing database
  • SCRAM exchange begins: PgBouncer → Client: challenge for report_reader
  • Second StartupMessage: Client → PgBouncer: user is now pgb_admin
  • Proof sent: Client → PgBouncer: made with report_reader's password
  • Proof matches?: Checked against report_reader's keys
  • Logged in as pgb_admin: PgBouncer → Client: with pgb_admin's rights
  • Login refused: PgBouncer → Client: error

Think of an office reception desk. You fill in a visitor form with your name, and the guard checks your ID against that form. Halfway through, you hand over a new form with the manager's name.

Mapped back: the visitor form is the StartupMessage, the ID check is the SCRAM proof, and the guard is PgBouncer. The building pass is the logged-in user's rights. The guard finishes checking your ID, then prints a pass with the manager's name.

Where the picture breaks: a real guard looks at your face, not at maths. SCRAM checks a calculation over stored keys, and that calculation was correct. The flaw was only in which name PgBouncer kept afterwards.

What just happened? Nothing in the SCRAM protocol was weak here. The proof matched the keys it was checked against. PgBouncer simply attached the result to the wrong user, because it let a message from step one arrive again later.

What are the two PgBouncer denial-of-service bugs in 1.26.1?

Both trap PgBouncer in an endless loop, so it stops answering every client. A denial-of-service bug, or DoS, is any flaw that stops a service from serving its users. Each needs a non-default setting. With that setting in place, the options loop needs no password, and the packet loop needs none from 1.23.0 onwards.

One loop hurts everyone because PgBouncer's docs say it is single-threaded and uses one CPU core per instance. When that one thread spins, no other client gets a turn until someone restarts the process.

An empty option name: CVE-2026-107349

A StartupMessage can carry optional settings called startup parameters. One of them, options, carries command-line style flags such as -c geqo=off. PgBouncer rejects parameters it does not track, unless you list them in ignore_startup_parameters.

In 1.20.0 to 1.26.0, an empty parameter name inside the options startup parameter, such as -c=, made PgBouncer loop forever. The CVE record places the loop in the ignore_startup_parameters lookup.

It applies only when ignore_startup_parameters is set to a non-empty value that does not include options. The record's workaround is to add options to that list, or set the list to empty. CVSS 3.1 score: 7.5, High.

The team lists extra_float_digits and search_path there, and options is missing, so they are exposed. Adding options closes it today, before the upgrade.

That workaround has a side effect. PgBouncer's docs say unknown parameters inside options are then ignored, while tracked ones still apply. A client that sets some other -c flag through options loses that setting.

A packet-size overflow: CVE-2026-107348

In 1.21.0 to 1.26.0, an integer overflow in the code that grows the packet buffer could loop forever. An integer overflow happens when a number passes the largest value its storage can hold and wraps around.

A client triggers it with a packet header that claims a length above 2^31 bytes, which is 2,147,483,648 bytes or 2 GiB (gibibytes). The record says this works before login from 1.23.0 onwards.

It only matters when max_packet_size is set to 2147483649 or higher. The default is 2147483647, which is not affected, and the record's workaround is to keep it there or lower. CVSS 3.1 score: 5.9, Medium.

This toy code is not PgBouncer's. It only shows how a wrapped number can trap a growth loop. Predict the second line before you run it.

python
# A toy model, not PgBouncer's code: a buffer doubles until a packet fits.
# The size is kept in 32 bits, like an unsigned int in C.
LIMIT = 2**32

def grow(size, needed, max_steps=40):
    for step in range(max_steps):
        if size >= needed:
            return f"fits after {step} doublings: {size} bytes"
        size = (size * 2) % LIMIT  # 2**31 doubled wraps to 0
    return f"gave up after {max_steps} steps, size is {size}"

print(grow(4096, 100_000))     # a normal 100,000-byte packet
print(grow(4096, 2**31 + 5))   # a header claiming just over 2**31 bytes

Run with python3 on 3.13, it prints "fits after 5 doublings: 131072 bytes", then "gave up after 40 steps, size is 0". The size reaches 2^31, doubles to 2^32, wraps to 0 and never grows again.

The team never changed max_packet_size, so they are not exposed to this one.

Which PgBouncer versions are affected, and how severe is each CVE?

Versions 1.17.0 to 1.26.0 have the bypass, 1.20.0 to 1.26.0 the options loop, and 1.21.0 to 1.26.0 the packet loop. Every range ends before 1.26.1, the only fixed upstream release as of 19 October 2026.

The three CVEs fixed in PgBouncer 1.26.1, from the CVE records (CVSS 3.1)
CVEEffectAffected versionsOnly whenWorkaround before upgradingScore
CVE-2026-107350Log in as another SCRAM user, admins included1.17.0 to 1.26.0Attacker knows one auth_file SCRAM password; target uses SCRAMNone8.8 High
CVE-2026-107349Endless loop, no password needed1.20.0 to 1.26.0ignore_startup_parameters set without optionsAdd options to the list, or empty it7.5 High
CVE-2026-107348Endless loop, before login from 1.23.01.21.0 to 1.26.0max_packet_size at 2147483649 or higherKeep max_packet_size at 2147483647 or lower5.9 Medium

Is 1.26.0 enough? No. The 1.26.0 release already fixed an earlier packet-buffer overflow, CVE-2026-6668, but the CVE-2026-107348 record still lists 1.26.0 as affected. Only 1.26.1 closes all three.

How do you check whether your PgBouncer is exposed?

Check three things on every instance: the version, the auth settings and the two DoS settings. The admin console shows all of them, and a short script can read the config file.

Read the version and settings from the console

Connect to the pgbouncer database as an admin or stats user and run SHOW VERSION. On our test machine, Ubuntu 24.04's packaged PgBouncer printed one row reading "PgBouncer 1.22.0". After the upgrade, you want to see "PgBouncer 1.26.1".

bash
psql -h 127.0.0.1 -p 6432 -U pgb_admin -d pgbouncer -c "SHOW VERSION;"
psql -h 127.0.0.1 -p 6432 -U pgb_admin -d pgbouncer -At -c "SHOW CONFIG;" | grep -E '^(auth_type|ignore_startup_parameters|max_packet_size|admin_users|stats_users)\|'

The second command prints one row per setting as key|value|default|changeable. On a default install, max_packet_size shows 2147483647 in both the value and default columns. A blank ignore_startup_parameters value means the options loop cannot reach you.

Do not trust the version number alone

SHOW VERSION prints the upstream number. Debian's security FAQ explains that Debian backports security fixes into the version it shipped, instead of moving to the new upstream release. So a patched Debian package can still report an old upstream number.

As of 19 October 2026, Debian's security tracker lists the Debian 12 and 13 packages as still vulnerable and unstable's 1.26.1-1 package as fixed. On our Ubuntu 24.04 test machine, the only pgbouncer package was 1.22.0-1build4, inside the affected range. That is one mirror snapshot, not Ubuntu's verdict. Read your distribution's security notice for each CVE and compare the full package build.

Run a read-only config check

This script reads pgbouncer.ini and auth_file and changes nothing. It works out which users log in with SCRAM from auth_type and the secret type in auth_file, then tests the three conditions from the CVE records.

python
# Read-only check of one PgBouncer config against the three October 2026 CVEs.
# Usage: python3 audit_pgbouncer.py /etc/pgbouncer/pgbouncer.ini 1.25.2
import configparser, sys

ini_path, version = sys.argv[1], sys.argv[2]
cp = configparser.ConfigParser(interpolation=None, strict=False)
cp.read(ini_path)
pgb = cp["pgbouncer"]

ver = tuple(int(x) for x in version.split("."))
fixed = (1, 26, 1)
auth_type = pgb.get("auth_type", "md5").strip()
ignored = [p.strip() for p in pgb.get("ignore_startup_parameters", "").split(",") if p.strip()]
max_packet = int(pgb.get("max_packet_size", "2147483647"))

kinds = {}  # user -> kind of secret in auth_file (second quoted field)
for line in open(pgb["auth_file"]) if "auth_file" in pgb else []:
    parts = line.split('"')
    if len(parts) >= 4:
        secret = parts[3]
        if secret.startswith("SCRAM-SHA-256$"):
            kinds[parts[1]] = "scram"
        elif secret.startswith("md5") and len(secret) == 35:
            kinds[parts[1]] = "md5"
        else:
            kinds[parts[1]] = "plain"

if auth_type == "scram-sha-256":
    scram = [u for u, k in kinds.items() if k in ("scram", "plain")]
elif auth_type == "md5":
    scram = [u for u, k in kinds.items() if k == "scram"]
else:
    scram = None  # hba and other types are not worked out here
    print(f"auth_type={auth_type}: check auth_hba_file and auth methods by hand")

print("SCRAM users in auth_file:", "unknown" if scram is None else (", ".join(scram) or "none"))
print("admin_users:", pgb.get("admin_users", "").strip() or "none")
if (1, 17, 0) <= ver < fixed and scram is None:
    print("CVE-2026-107350: unknown (check SCRAM users by hand)")
elif (1, 17, 0) <= ver < fixed and scram:
    print("CVE-2026-107350: exposed (needs one known SCRAM password)")
if (1, 20, 0) <= ver < fixed and ignored and "options" not in ignored:
    print("CVE-2026-107349: exposed (add options to ignore_startup_parameters)")
if (1, 21, 0) <= ver < fixed and max_packet >= 2147483649:
    print("CVE-2026-107348: exposed (lower max_packet_size)")

On the team's config with version 1.25.2, it prints four lines. The first two list the SCRAM users (all three) and the admin user. Then come "CVE-2026-107350: exposed" and "CVE-2026-107349: exposed". With 1.26.1, only the first two lines appear.

The secret-type rules come from PgBouncer's docs. With auth_type set to scram-sha-256, SCRAM secrets and plain-text passwords both log in with SCRAM. With md5, the default, a user with a SCRAM secret still gets SCRAM. The record adds a third route: auth_type hba, which loads each login's method from rules in auth_hba_file, when those rules pick SCRAM.

For hba and other auth types, the script prints unknown and leaves the SCRAM check to you. It does not read auth_hba_file, follow auth_query or read other config files pulled in by %include lines. The record says users fetched by auth_query can also be targets, so check their auth method by hand. The script expects an upstream version such as 1.25.2; for a distro build, use the package notice instead.

What else changes when you upgrade from PgBouncer 1.25 to 1.26.1?

Three changes from 1.26.0 come along with the fix, and two of them can surprise a team coming from 1.25.x. Read them before you schedule the rollout.

Changes a 1.25.x user picks up by moving to 1.26.1 (from the PgBouncer changelog)
ChangeSinceWhat to do
Online restart (-R, also called takeover) removed1.26.0, deprecated since 1.20.0Use rolling restarts with so_reuseport (lets several processes share one port) instead
search_path tracked per client on PostgreSQL 18 and above1.26.0List search_path in ignore_startup_parameters to keep the 1.25 behaviour
ignore_startup_parameters wins over tracked parameters again1.26.1, bug introduced in 1.26.0Nothing, if you skip straight from 1.25.x to 1.26.1
Fixes for CVE-2026-19888, CVE-2026-6668 and CVE-2026-66691.26.0Nothing; you get them with 1.26.1

search_path is the PostgreSQL setting that decides which schemas, or named groups of tables, a query looks in first. Tracking keeps each client's value separate on shared server connections.

For the team, two rows matter. Their runbook used -R, so they switch to rolling restarts. Their search_path line in ignore_startup_parameters keeps working, because they jump straight to 1.26.1.

A rolling restart, supported since 1.23.0, runs two or more PgBouncer processes on the same port with so_reuseport. You stop one at a time with SHUTDOWN WAIT_FOR_CLIENTS while the others keep accepting connections.

How do you patch PgBouncer and check your auth settings?

Apply the two DoS workarounds now if you are exposed, then upgrade every instance to 1.26.1 one at a time. Afterwards, shrink who can log in and who has admin rights.

  1. Apply the DoS workarounds

    If ignore_startup_parameters is set, add options to it. Keep max_packet_size at 2147483647 or lower. Reload PgBouncer and confirm with SHOW CONFIG. On 1.22.0, a reload that could not read the file still replied RELOAD but kept the old value, so trust SHOW CONFIG.

  2. Upgrade one instance at a time

    Install 1.26.1 from upstream source or a fixed distro build. Use a rolling restart, since online restart with -R no longer exists in 1.26.

  3. Confirm each instance

    Run SHOW VERSION and expect PgBouncer 1.26.1, or check the fixed package build. Re-run the config check script; it should print no CVE lines.

  4. Shrink admin and stats lists

    Keep admin_users and stats_users to the few logins that need them. The bypass could reach any SCRAM user, admins included.

  5. Rotate shared passwords

    After the upgrade, change any password many people know, like the team's report_reader password in the spreadsheet.

  6. Close the network path

    Let only your app servers reach the PgBouncer port. With the risky setting, the options loop needs only a network connection, and so does the packet loop from 1.23.0.

PgBouncer's log shows each login as a line like "login attempt: db=pgbouncer user=pgb_admin" on 1.22.0. Review console logins by admin users since you ran an affected version. A bypassed login may look normal, so absence proves nothing.

What does the PgBouncer advisory not tell you yet?

Several details are missing from the public record as of 19 October 2026. Plan around them instead of guessing.

  • The exact point in the SCRAM exchange where the second StartupMessage was accepted is not published. Our third diagram is a simplification.
  • No indicators of compromise, meaning log patterns that prove an attack, are published. Triage data from CISA, the US cyber defence agency, in the CVE record lists exploitation as none.
  • Each CVE record links only to the PgBouncer changelog, not to a fix commit. Debian's security tracker names the upstream commit, starting 55233b21, as part of the 1.26.1 release.

How do you explain the PgBouncer authentication bypass in a system design interview?

Tell it as a story about trust inside a shared proxy, in five moves.

  1. Place the pooler

    Draw the app, PgBouncer and PostgreSQL, and say the pooler decides who each client is before any query runs.

  2. Describe login as a sequence

    A login is a fixed order of messages: StartupMessage, then the SCRAM exchange. Each message is valid only at its own step.

  3. State the bug

    A second StartupMessage mid-login changed the user, while the proof was still checked against the first user's keys.

  4. Name the blast radius

    One known password could reach admins and backend superusers. The DoS bugs stall every client, because one PgBouncer process uses one core.

  5. Give the fix and the layers

    Upgrade to 1.26.1, keep admin lists short, stop shared passwords and limit who can reach the pooler's port.

Questions people ask

Is upgrading to PgBouncer 1.26.1 the only fix for CVE-2026-107350?
Yes, the upgrade, or a distro package with the fix backported, is the only fix. The CVE record lists 1.17.0 up to, but not including, 1.26.1 as affected and gives no workaround. Fewer shared SCRAM passwords lower the risk during rollout, but they do not close the flaw.
Is PgBouncer 1.26.0 safe from these bugs?
No. All three CVE records list 1.26.0 as affected. 1.26.0 fixed an earlier packet-buffer overflow, CVE-2026-6668, but CVE-2026-107348 is a separate entry that still covers it. Move to 1.26.1, the newest release in the changelog as of 19 October 2026.
Does the PgBouncer authentication bypass affect PostgreSQL itself?
No, the flaw is in PgBouncer, not in PostgreSQL. The damage can still reach your data, because the record says the impersonated user may be a superuser on the PostgreSQL server behind PgBouncer. A PostgreSQL upgrade alone does not help.
Can someone use the bypass without knowing any password?
No. The attacker must know the password of one user listed in auth_file, and both users must log in with SCRAM. The DoS bugs differ. With the matching setting, the options loop needs no password, and the packet loop needs none from 1.23.0 onwards.
Am I affected if my auth_type is md5?
You may be. PgBouncer's docs say that when auth_type is md5 and a user has a SCRAM secret in auth_file, SCRAM is used automatically. Check the secret type of each user: one starting with SCRAM-SHA-256 means that user logs in with SCRAM. Upgrading is the safe answer either way.
What is the difference between SCRAM and md5 logins in PostgreSQL?
SCRAM is the stronger method. Both avoid sending the password in plain form, but PostgreSQL's docs say md5 gives no protection if someone steals the stored hash. The PostgreSQL 18 docs say MD5 password support is deprecated and will be removed in a future release.
Why does one stuck loop stop every PgBouncer client?
Because PgBouncer is single-threaded and uses one CPU core per instance, according to its docs. When that one thread spins in an endless loop, nothing else gets a turn. Every client waits until someone restarts the process, which is why both DoS bugs are rated as availability flaws.

Sources and further reading

Practise this with Lala

Practise