Introduction
Before version 9.19, pgAdmin 4 in server mode advertised a three-strike lockout for internal accounts while leaving several side entrances that never touched the counter. In the default configuration, an unauthenticated attacker could make unlimited password guesses against any internal account, including the administrator's. The same group of flaws let password changes skip pgAdmin's permission checks, let password resets reach accounts they should never touch, and let the About box show server configuration to the wrong user.
pgAdmin is the open-source administration, development and monitoring platform for PostgreSQL, maintained by the pgAdmin Development Team. It runs either as a desktop application or as a browser-accessible web application on Windows, macOS, Linux and containers. The project describes itself as the most popular and feature-rich open-source graphical PostgreSQL administration tool, although the sources we reviewed do not provide independent installation counts or market share figures. Because a server-mode instance is a multi-user web application that stores users' saved database server passwords, the strength of its login controls matters beyond pgAdmin itself.
Technical Information
Severity at a glance
- CVSS 3.1: 8.1 High,
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H(listed by SUSE under the PostgreSQL CNA and SUSE) - CVSS 4.0: 9.2 Critical,
CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N(PostgreSQL CNA assessment displayed by NVD; NVD has not supplied its own score) - Weaknesses: CWE-307 (Improper Restriction of Excessive Authentication Attempts) and CWE-863 (Incorrect Authorization)
The two scores come from different CVSS versions and should always be quoted with their version. Both describe a network attack requiring no privileges and no user interaction, with high attack complexity. The 4.0 vector also records attack requirements as present.
Root cause: one lockout policy, several unguarded paths
pgAdmin implements its own login, password change and password reset views. Those views count failed logins, lock the account once MAX_LOGIN_ATTEMPTS (3 by default) is reached, and check permissions. A lockout like this is only as strong as the guarantee that every authentication decision flows through that logic. The fix commits show that guarantee failed at three distinct boundaries: routing, authentication state and account identity.
1. Routing: Flask Security's stock views were still live. Flask Security registers its own /login, /verify, /change, /reset and /reset/<token> routes next to pgAdmin's views, and none of pgAdmin's checks ran there:
/loginchecked the credentials and logged the user in without pgAdmin's failed-login count or lock./verifychecked a signed-in user's password with no attempt limit./changechanged a password without pgAdmin's change-password permission and without reencrypting the user's saved server passwords./resetand/reset/<token>sent and applied password resets for accounts that are not internal or are locked.
Ordinary pgAdmin users sign in by posting the login form to /authenticate/login. Nothing in the normal interface submits to the Flask Security routes, which is exactly what made them a side entrance.
2. Authentication state: the counter looked for the wrong row, or never ran. The old _login() code in web/pgadmin/authenticate/__init__.py found the account to count against by exact username:
username = form.data['email'] user = User.query.filter_by(username=username, auth_source=INTERNAL).first()
Flask Security, however, checks the password against an email lookup that ignores case. Typing ADMIN@... therefore had the password checked against the real account, while the counting code found no match. The attempt was neither counted nor checked against the lock.
The counter also only incremented inside the if not auth_obj.validate() branch, and several configurations never reached it:
- With LDAP or webserver authentication also configured, the form always passed validation, so a wrong internal password was never counted.
- With Kerberos configured, a guess could get a Kerberos negotiate challenge back, and nothing was counted.
- With webserver authentication configured, a wrong guess could silently log in whatever identity the web server reported, again uncounted.
- A successful LDAP login with the same name reset the internal account's attempt count to zero, which could be used to keep guessing.
3. Identity: accounts sharing an email were interchangeable. Accounts from different authentication sources can share an email address, and Flask Security's lookup returned whichever row came first. A password reset could therefore be sent for, or applied to, an account from another source. Separately, the About box decided whether to show the server configuration by looking up the admin role by email:
admin = is_admin(current_user.email)
A user whose email matched that of an administrator's account was shown the server configuration.
Attack flow
Based on the advisory and the fix commits, the primary unauthenticated scenario against a vulnerable server-mode instance looks like this:
- The attacker reaches a pgAdmin 4 server-mode instance running a release before 9.19 and selects a target internal account, such as the administrator's email.
- Instead of posting to
/authenticate/login, the attacker submits credentials directly to Flask Security's/loginview. The password is checked, but pgAdmin's counter never increments and the lock is never consulted. - Alternatively, the attacker stays on the normal login path and changes the capitalization of the email between attempts, so the exact-match counting lookup never finds the account. In deployments that also list LDAP or webserver authentication, wrong internal passwords are not counted at all.
- The attacker repeats indefinitely. The threshold of three never triggers, so the legitimate user is never locked out and nothing visibly changes.
- If a guess is correct, the attacker holds a session as that user. For the administrator, Brandefense relays the CNA's note that the account can create, change and delete users.
Two caveats apply. Unlimited guesses do not amount to a passwordless takeover, because success still depends on the password being guessable. And not every path shares the same prerequisites: /verify and /change act on an already signed-in user, unlike the unauthenticated /login route.
Patch Information
The fix ships in pgAdmin 4 9.19, released 29 October 2026 according to the release notes. The CVE record lists three upstream commits, all by Ashesh Vashi, tracked under issue #10676 and applied in the order 58c531d, then 35c9235, then c6ef41a. Each commit closes a different bypass. Read in reverse order, the patches first block the side entrance, then fix the counter, then make sure the counter tracks the correct account.
1. Refusing Flask Security's own views (c6ef41a)
Rather than harden each stock view, the fix adds a single before_request hook in web/pgadmin/__init__.py that refuses any state-changing submission to them:
FLASK_SECURITY_FORM_ENDPOINTS = ( 'security.login', 'security.verify', 'security.change_password', 'security.forgot_password', 'security.reset_password' ) ... @app.before_request def refuse_flask_security_forms(): ... if request.endpoint in FLASK_SECURITY_FORM_ENDPOINTS and \ request.method not in ('GET', 'HEAD', 'OPTIONS'): return Response(status=405, headers={'Allow': 'GET, HEAD, OPTIONS'})
GET still works, because GET /login renders pgAdmin's login page, and that page's form posts to /authenticate/login. The 405 is returned as a plain Response rather than raised as an error, so anyone can send these requests without filling the logs with tracebacks.
2. Fixing the counter (35c9235)
This is the largest change. It rewrites _login() and AuthSourceManager.update_auth_sources() in web/pgadmin/authenticate/__init__.py.
Look up the account the same way Flask Security does. The account to count against is now found by email, ignoring case, exactly as Flask Security finds the account whose password it checks:
user = _security.datastore.find_user(case_insensitive=True, email=form.data['email']) if user is not None and user.auth_source != INTERNAL: user = None
Count failures after every source has been tried. The counting logic now lives in a _count_failed_login() helper. It runs when validation fails, and again when no authentication source succeeds (if user: attempts_msg = _count_failed_login(user)). The helper locks the account on the attempt that reaches the limit, not on the next login:
user.login_attempts += 1 # Lock now rather than at the next login: User.is_active depends on it. user.locked = user.login_attempts >= config.MAX_LOGIN_ATTEMPTS > 0 db.session.commit()
If a password was posted, skip sources that ignore passwords. Kerberos and webserver authentication do not check the submitted password. Any POST that includes a password now removes those sources from the list:
if 'internal_button' in request.form or \ request.form.get('password'): for auth_src in [KERBEROS, OAUTH2, WEBSERVER]: if auth_src in self.auth_sources: self.auth_sources.remove(auth_src) return
The OAuth2 button check runs before this one, so OAuth2 logins still work. A Kerberos login posts no password, so Kerberos is still tried for it.
Only an internal success resets the counter. The reset condition changed from if user: to:
if user and current_auth_obj['current_source'] == INTERNAL: user.login_attempts = 0
The lock applies only to internal authentication. A locked internal account no longer refuses the whole login. pgAdmin removes INTERNAL from the list of sources to try and logs the skip. If no other source remains, it shows the usual locked message. Otherwise, a user from another source with the same name can still sign in, which keeps attackers from locking LDAP users out with wrong guesses. The typed password is never checked against the locked account, and if the other sources also refuse it, the user sees the same neutral "may be locked" message whether or not the password was right. Without that, the lock itself would reveal when a guess was correct.
3. Selecting the right account (58c531d)
This commit replaces the stock datastore with a PgAdminUserDatastore. For email or username lookups, its find_user prefers the INTERNAL account. When case is ignored, it prefers an exact case match, then the oldest account. A missing value matches no account at all:
user = self.user_model.query.filter( match, self.user_model.auth_source == INTERNAL ).order_by( db.case((column == value, 0), else_=1), self.user_model.id ).first()
The find_user(case_insensitive=True, ...) call in the counter fix goes through this datastore, which is why this commit had to land first.
The same commit applies the internal-only rule to password recovery. forgot_password now sends instructions only when form.user is internal, and reset_password treats a token for any other account as invalid:
# Only an internal account has a password to reset. if user is not None and user.auth_source != INTERNAL: invalid, expired = True, False
The About box now checks the current user's own role instead of matching by email:
admin = current_user.has_role('Administrator')
Regression coverage
All three commits add tests that reproduce the original bypasses:
test_flask_security_views.py: POSTs to/login,/verify,/changeand/resetget a 405 and change neither the counter nor the password.test_internal_login_attempts.py: wrong passwords lock the account when LDAP or Kerberos is also configured and when the username is typed in a different case.test_locked_internal_account.py: checks the neutral message and that a same-named LDAP user can still sign in.test_webserver_password_login.py: a wrong password no longer falls through to the webserver identity.test_shared_email_lookup.pyandtest_reset_password_auth_source.py: login and password reset pick the internal account when another source shares its email.
The test runner's desktop-mode exclusion list also had a bug: excluding test_reset_password silently excluded test_reset_password_auth_source too. Exclusions now match exact module names. Most of the new tests run only in server mode, consistent with the advisory's statement that desktop mode is not affected.
Supplemental controls after upgrading
The published material does not document a complete configuration-only workaround for affected releases, and lowering the failed-login threshold does nothing against a route that bypasses the counter. Once on a fixed release, these documented controls complement the patch:
- Internal lockout: keep
MAX_LOGIN_ATTEMPTSenabled. Its default is 3, and setting it to 0 disables the feature. - Account recovery: administrators can clear the Locked switch in User Management to restore a legitimately locked user.
- Two-factor authentication: pgAdmin supports email and authenticator methods, and documents
MFA_FORCE_REGISTRATION = True. The email method requires mail server configuration. - External authentication: pgAdmin's lockout applies only to internal accounts, so apply brute-force protection at the directory, identity provider or reverse proxy.
- Webserver authentication: configure trusted proxies, strip inbound identity header variants, and consider the documented optional shared secret.
The project's security policy states that only the latest version receives security updates, so upgrading to 9.19 or the latest stable release is the remedy.
Detection Methods
No ready-made signatures exist for CVE-2026-116238. As of early November 2026, OffSeq's tracker indexed no public Nuclei template, no Metasploit or Exploit DB module, and no attack traffic from its Mirage honeypot sensors. Neither Ostorlab nor Brandefense reports a published IoC, victim or campaign. TheHackerWire reports the CVE is not in CISA's KEV catalog, with no confirmed public exploit code and no known ransomware campaign, and displays an EPSS estimate of 0.40% for the next 30 days. None of that proves exploitation has not happened privately, so detection rests on two things: finding exposed instances, and watching for the specific ways the lockout can be bypassed. Because the fix commits name each bypass path, they translate directly into hunting logic.
1. Find exposed instances (version and mode)
Only server mode is affected. Brandefense notes that server mode covers the dpage/pgadmin4 container, the PyPI package and APT/RPM web package installs. Any of these running a release before 9.19 is exposed if untrusted users can reach it. Ostorlab gives two quick version checks. The container logs its version at startup, so search the output of docker logs pgadmin for the Starting pgAdmin line. For a pip install, check the version reported by:
pip show pgadmin4
The Help → About box also shows the version. Do not trust floating Docker tags such as latest; confirm the actual running version.
2. Watch for POSTs to Flask Security's own routes
This is the most specific network-level signal. A normal sign-in posts to /authenticate/login. The pgAdmin interface does not send POSTs to /login, /verify, /change or /reset*. A GET /login is normal, because it renders pgAdmin's login page.
- Before patching: a burst of POSTs to these paths from one source, especially many POSTs to
/loginwith no resulting lockout, strongly suggests someone is exploiting the bypass. - After patching: the same requests receive HTTP 405 with an
Allow: GET, HEAD, OPTIONSheader. A run of 405s on these paths means someone is still probing for the old bypass.
The commit deliberately does not log this refusal as an error, because anyone can send one. The evidence therefore lives in your reverse proxy, load balancer or WAF logs rather than pgAdmin's own error log, so make sure those logs are retained.
3. Watch for case-variant usernames on the normal login path
If your proxy or WAF records POST bodies or form fields, look for repeated POST /authenticate/login attempts where the same email arrives in many casings, for example [email protected], [email protected] and [email protected] cycling against one account. Legitimate users rarely do this. An attacker rotating case to avoid the counter would produce exactly this pattern.
4. Check deployments that mix authentication sources
If AUTHENTICATION_SOURCES includes LDAP, Kerberos or webserver authentication alongside internal accounts, hunt for:
- Many login POSTs that carry a password and receive Kerberos 401 Negotiate challenges.
- A webserver-authenticated identity repeatedly posting another user's email.
- In LDAP or webserver configurations, repeated wrong internal passwords that never move the internal counter.
5. Compare failed-login volume with lockout state
Ostorlab describes the plainest behavioural test: a volume of failed logins that never triggered a lock is the bypass's own signature. pgAdmin keeps the counter in its configuration database. In the container this is /var/lib/pgadmin/pgadmin4.db, and each internal user row has login_attempts and locked fields. Line up failed authentication requests per account from your proxy logs against those two fields. With MAX_LOGIN_ATTEMPTS at its default of 3, an account that absorbed dozens or hundreds of failures but still shows a low login_attempts value and is not locked fits this CVE, as does a successful login following a long run of failures.
6. Monitoring after the upgrade
On 9.19, pgAdmin writes an INFO-level log line whenever a password is tried against a locked internal account:
'Internal authentication skipped for %s: the account is locked.'
The fix's tests confirm the typed password is never written to this log. Alert on repeats of this line for the same account, especially the initial administrator. Repeats show someone is still guessing against an account the lockout now protects.
7. Signs of a successful compromise
A guessed administrator account can create, change and delete users, so review User Management and any audit trail for unexplained accounts, role changes or deletions. Two side effects of the bypasses can also point to abuse:
- Broken saved passwords:
/changechanged passwords without reencrypting saved server passwords, so a user whose saved database passwords suddenly stop working may have had their password changed through this path. - Unexpected reset emails: the
/resetviews could send resets for accounts that are not internal or are locked. Reset emails nobody requested deserve investigation.
None of these are confirmed IoCs. They are the trails these specific bypasses would leave, so treat a hit as a lead rather than proof of compromise.
Affected Systems and Versions
- Product: pgAdmin 4, all versions before 9.19.
- Mode: server mode only. Desktop mode is not affected.
- Server mode distributions (per Brandefense): the
dpage/pgadmin4container, the PyPIpgadmin4package and APT/RPM web package installs. - Default configuration: vulnerable to unlimited guessing against internal accounts, including the administrator, with
MAX_LOGIN_ATTEMPTSat its default of 3, via the Flask Security views and case-variant usernames. - Additional paths: deployments whose
AUTHENTICATION_SOURCEScombine internal accounts with LDAP, Kerberos or webserver authentication. - Shared email configurations: deployments where accounts from different authentication sources share an email address are exposed to the misdirected reset and About box disclosure issues.
- Fixed version: pgAdmin 4 9.19 (release notes date 29 October 2026; announced 31 October 2026).
Vendor Security History
The pgAdmin project has shipped a steady run of security releases in 2026:
- 9.17 (4 August 2026): fixed 7 vulnerabilities, including missing authentication on routes, SQL injection, command injection and credential cloning.
- 9.18 (17 September 2026): fixed 4 vulnerabilities, including CVE-2026-86863, where a client-supplied header could assert a webserver identity, including an administrator identity, without a credential. That fix made header-based identity opt-in, restricted it to trusted proxies and optionally requires a shared secret.
- 9.19 (announced 31 October 2026): 15 security fixes, including CVE-2026-116238 and a separate OS command injection, CVE-2026-116241, which should not be conflated with this issue.
CVE-2026-86863 and CVE-2026-116238 both concern authentication boundaries but are distinct trust problems. The former is about trusting identity headers, the latter about enforcing internal authentication, reset and authorization checks consistently across routes and sources. Vulnerability counts alone do not measure product quality or remediation speed. The project accepts private reports at [email protected] and publishes security information on its website, which is a better tracking source than the GitHub advisory listing.
References
- NVD entry for CVE-2026-116238
- Official CVE record (CVE Services API)
- GitHub Advisory Database entry
- pgAdmin 4 v9.19 release announcement
- pgAdmin 4 9.19 release notes
- Fix commit c6ef41a: refuse Flask Security views
- Fix commit 35c9235: count every failed internal login
- Fix commit 58c531d: act only on the internal account
- Commit c6ef41a (GitHub API)
- Commit 35c9235 (GitHub API)
- Commit 58c531d (GitHub API)
- pgAdmin issue #10676
- SUSE CVE page
- TheHackerWire analysis
- IONIX threat center entry
- OffSeq threat radar entry
- Brandefense: pgAdmin 9.19 update
- Ostorlab blog on pgAdmin 9.19 issues
- CISA Known Exploited Vulnerabilities feed
- pgAdmin configuration files documentation
- pgAdmin User Management documentation
- pgAdmin Lock/Restore Account documentation
- pgAdmin two factor authentication documentation
- pgAdmin webserver authentication documentation
- pgAdmin 4 v9.17 release announcement
- pgAdmin 4 v9.18 release announcement
- pgAdmin security page
- pgAdmin GitHub security overview
- pgAdmin Development Team
- pgAdmin home page



