Introduction
A patient portal link sent by email was never meant to be a reusable key, yet in affected OpenEMR releases a single captured token could open fresh portal sessions for up to two weeks. Each session exposed a patient's medications, lab results, SSN, and prescriptions. The flaw lives in the component meant to enforce one-time use, and it ignored the very policy flags that callers set to lock tokens down.
OpenEMR is an open source electronic medical record and medical practice management platform licensed under the GNU GPL and supported by the nonprofit OpenEMR Foundation. Project announcements have estimated use by more than 100,000 medical providers serving more than 200 million patients across more than 100 countries, with translation into 36 languages. Because it handles protected health information at that scale, authentication defects in its patient portal have direct confidentiality consequences.
Technical Information
The central issue in CVE-2026-124445 is a gap between a token's declared policy and its enforced behavior. OpenEMR's src/Common/Auth/OneTimeAuth.php already stored the right policy flags on every token (enforce_onetime_use, max_access_count, and enforce_auth_pin), along with an access_count column in the onetime_auth table. The code consuming those tokens only ever checked the PIN flag.
The authentication path validates token format, database existence, portal account existence, and expiration. It does not enforce the policy fields that should restrict consumption. decodePortalOneTime() retrieves onetime_actions without applying enforce_onetime_use, max_access_count, or the existing access_count. processOnetime() checks only the PIN requirement, while updateOnetime() merely increments the access counter. It never deletes or revokes the consumed token. The counter exists, but counting accesses does not prevent access.
The result is not simply an expired token being accepted. A token can stay within its expiration window while being reused against its intended usage policy. In the documented FaxSMS invoice case, the caller specifies enforce_onetime_use=true, yet the token remains usable for its full 14 day lifetime.
The advisory documents three distinct exploitation mechanisms:
- Token replay: With a valid captured token (a PIN is additionally required only where enforced), usage restrictions are ignored and consumed tokens remain valid, so repeated consumption produces fresh portal sessions.
- PIN guessing: With a captured invoice token but no PIN, the absence of application level throttling or lockout allows repeated attempts. The PIN is generated with all distinct digits, shrinking the real space to 151,200 permutations rather than one million.
- Loose PIN comparison: The vulnerable check used PHP's loose
!=operator, so numeric string equivalents bypass exact matching.
These distinctions matter operationally. Stronger PIN checking alone does not repair replay in workflows that require no PIN, and token usage limits alone do not replace protection against PIN guessing. The advisory recommends separate controls for these paths.
Established sessions expose patient information including identifying details, medications, laboratory results, documents, and prescriptions, along with patient facing messaging, appointment, and payment functions. The advisory limits integrity impact to patient facing actions, states that core medical records require clinician approval, and reports no availability impact in its technical analysis. This narrower description differs from the CNA vector, which assigns high availability impact.
Proof of Concept
A verified public proof of concept exists in the OpenEMR GitHub Security Advisory GHSA-xg3m-9pfq-c8r6, cross referenced by NVD and VulnCheck. No standalone exploit script or repository is published. The PoC consists of detailed, reproducible step by step HTTP procedures and reproduction output in the official advisory, reproduced end to end for the first two variants and in a PHP/unit harness for all three.
V1, token replay. Obtain a valid service_auth token (a 32 character alphanumeric query parameter). Capture vectors cited include email interception, Referer leakage, web server logs, shared machine browser history, or HTTP MitM. Then loop:
GET /portal/index.php?service_auth=<token>&site=defaultto retrieve a CSRF token and the autologin form.POST /portal/index.phpwithservice_auth=<token>, the CSRF token, and (only ifenforce_auth_pin=true) the PIN.
Each POST establishes a fresh, complete patient portal session. The token can be replayed for its full lifetime, up to 14 days for the FaxSMS invoice flow even though the caller set enforce_onetime_use=true. The advisory's end to end output shows repeated successful uses with incrementing access_count (1, 2, 3...), each returning 302 -> /portal/home.php?...landOn=MakePayment and exposing PHI via endpoints such as portal/patient/api/patient/1, get_medications, and get_lab_results. The advisory reports one token consumed 10 times, reaching access_count=10 while remaining valid, and a separate reproduction creating five independent complete portal sessions.
V2, PIN brute force. Starting from a captured invoice token without the PIN (the split leakage case), repeat the same GET plus POST flow while iterating login_pin over candidate values. A wrong PIN returns HTTP 302 to the login page; the correct PIN returns a 302 to the portal target and establishes a session. Because the PIN is generated with substr(str_shuffle(str_shuffle("0123456789")), 0, 6) (all distinct digits), the real space is P(10,6) = 151,200 rather than 10^6. With no throttle or lockout, the advisory estimates exhaustion at roughly 2 to 4 hours at 10 req/s within the 14 day window (each attempt is two HTTP requests). Its harness solved a PIN in 51 attempts in 0.15s and showed 10 consecutive failures without lockout. A production WAF or reverse proxy rate limiter may constrain throughput, so laboratory behavior should not be treated as a guaranteed production attack duration.
V3, PIN loose comparison (type juggling). The vulnerable check is:
if ($auth['pin'] != $_POST['login_pin'] ?? null) {
Under PHP 8.x numeric string comparison, equivalents bypass the check. Confirmed bypasses from the advisory's harness:
"012345" != "12345" -> false (bypass)
"056789" != "56789" -> false (bypass)
"123456" != "123456e0" -> false (bypass)
"123456" != "123456.0" -> false (bypass)
"123456" != "0123456" -> false (bypass)
"123456" != "654321" -> true (correctly blocked)
This mainly shrinks the brute force space for leading zero PINs (about 10% of PINs) from 151,200 to 15,120. Where email interception discloses both the token and PIN, the loose comparison issue contributes little, so the three mechanisms should not be treated as equally important in every incident.
Patch Information
The vulnerability is fixed in OpenEMR 8.4.0, released 2026-09-13. The maintainers' advisory GHSA-xg3m-9pfq-c8r6 lists < 8.4.0 as vulnerable and 8.4.0 as patched, and the 8.4.0 release notes include it as a Critical item. On the rel-840 branch the code arrived in commit 586c7f6f9bd0927debcf9a73d1e5f665a53d8bee (PR #13959), which bundles six security fixes picked from master. Only the changes to src/Common/Auth/OneTimeAuth.php, portal/index.php, library/globals.inc.php, and the new test tests/Tests/Isolated/Common/Auth/OneTimeAuthPolicyTest.php belong to this CVE. Because the commit message labels the master fixes only as "Merge commit from fork", the exact upstream SHA cannot be identified from public data.
1. Usage limits enforced at decode time (fixes token replay). A new static method, usageLimitReached(), decides whether a token is spent by comparing the token's onetime_actions against the existing access_count column:
public static function usageLimitReached(array $actions, int $accessCount, int $maxPinAttempts = self::MAX_PIN_ATTEMPTS): bool { // One-time tokens are spent by the first consumption attempt. if (!empty($actions['enforce_onetime_use']) && $accessCount > 0) { return true; } // Explicit cap set by the caller (0 = unlimited). $maxAccessRaw = $actions['max_access_count'] ?? 0; $maxAccess = is_numeric($maxAccessRaw) ? (int) $maxAccessRaw : 0; if ($maxAccess > 0 && $accessCount >= $maxAccess) { return true; } // Hard safety cap on PIN-protected tokens, independent of the flags above, // so PIN guessing is always bounded. if (!empty($actions['enforce_auth_pin']) && $accessCount >= $maxPinAttempts) { return true; } return false; }
decodePortalOneTime() calls this check before it builds its return value, throwing if the limit is reached:
if (self::usageLimitReached($actions, $accessCount, $this->pinAttemptLimit())) { $this->systemLogger->error("Onetime token refused: usage limit reached", ['token' => $tokenFingerprint]); throw new OneTimeAuthException("Onetime token usage limit reached"); }
Both portal entry points go through decodePortalOneTime(): the GET that renders the autologin form (logUpdate: false) and the POST that logs in (processOnetime()). A used up token is now refused at both steps, which closes the gap where the GET path could be called repeatedly for fresh CSRF tokens. The maintainers chose not to delete or revoke token rows as the advisory had suggested. Checking the flags against the incrementing counter stops both replay and guessing without any schema change.
2. PIN guessing capped and uncloseable (fixes brute force). The third branch of usageLimitReached() applies to every PIN protected token. The default cap is a new constant:
public const MAX_PIN_ATTEMPTS = 5;
Administrators can change it with a new global setting, portal_onetime_max_pin_attempts (default 5). The helper pinAttemptLimit() falls back to the constant whenever the setting is 0, blank, or negative, so the throttle cannot be switched off by accident:
$configured = $this->globalsBag->getInt('portal_onetime_max_pin_attempts'); return $configured > 0 ? $configured : self::MAX_PIN_ATTEMPTS;
This shrinks the attack from about 151,200 unthrottled guesses over a 14 day window to at most a handful. The FaxSMS invoice token sets both enforce_onetime_use and enforce_auth_pin, so it now allows exactly one attempt. One side effect: a patient who mistypes the PIN on an invoice link burns it and needs a new one.
3. Strict PIN comparison (fixes type juggling). The loose != check is replaced with a type checked, constant time comparison:
- if ($auth['pin'] != $_POST['login_pin'] ?? null) { + if (!self::pinMatches($auth['pin'], $_POST['login_pin'] ?? null)) {
public static function pinMatches(mixed $expected, mixed $submitted): bool { if (!is_string($submitted) || !is_scalar($expected)) { return false; } return hash_equals((string)$expected, $submitted); }
hash_equals() compares raw bytes, so leading zero, scientific notation, decimal, and zero padded forms all fail, and its constant time behavior removes a timing side channel. The is_string guard rejects non string input such as arrays from login_pin[]= parameters. The old line also had an operator precedence bug: != binds tighter than ??, so the null fallback never applied as intended. PHPStan had flagged this, and the patch removes that entry from the baseline.
Portal front end handling. In portal/index.php, the GET handler now wraps decodePortalOneTime() in try/catch blocks. An expired, used up, or invalid token is logged with portalLog(), the portal session cookie is destroyed, and the user is redirected to the login page with an &oe (expired) or &oi (invalid/consumed) flag.
Tests. The new OneTimeAuthPolicyTest covers each variant: one time tokens refuse a second use, max_access_count blocks at the cap, PIN tokens are capped even with no other flags set, the cap is configurable, tokens with no limits stay reusable, and a data provider confirms each type juggling form from the advisory now fails while the exact PIN, leading zeros included, still matches.
Affected Systems and Versions
- Affected: OpenEMR 7.0.1 through versions before 8.4.0.
- Unaffected/Patched: OpenEMR 8.4.0.
The CVE record names openemr as both vendor and product, with VulnCheck as the assigning CNA. The vulnerable component is src/Common/Auth/OneTimeAuth.php. The CNA assigned a CVSS 3.1 score of 9.8 and a CVSS 4.0 score of 9.3, both Critical. NVD has not supplied an independent assessment.
Vendor Security History
OpenEMR has a documented record of both security findings and remediation. HHS's January 31, 2023 alert describes three earlier vulnerability classes: unauthenticated file read, authenticated local file inclusion, and authenticated reflected XSS. According to HHS, Sonar reported the vulnerabilities on October 24, 2022, and OpenEMR released version 7.0.0 containing all three patches on November 30, 2022. Earlier reporting from August 8, 2018 likewise describes multiple serious OpenEMR vulnerabilities, coordinated withholding of the researchers' report until remediation, and an update released on August 7. This history shows substantial security findings alongside documented remediation activity. It does not establish that those flaws caused actual breaches. For the present vulnerability, the official 8.4.0 release names the advisory explicitly, and the project publishes a security reporting process that prefers private GitHub vulnerability reports and accepts email at [email protected].
References
- NVD: CVE-2026-124445
- CVE Record (MITRE CVE Services)
- OpenEMR GitHub Security Advisory GHSA-xg3m-9pfq-c8r6
- GitHub Advisory API (GHSA-xg3m-9pfq-c8r6)
- VulnCheck Advisory: OpenEMR OneTimeAuth Token Replay and PIN Brute Force
- Fix commit 586c7f6f9bd0927debcf9a73d1e5f665a53d8bee
- Fix commit API view
- OpenEMR 8.4.0 Release
- OpenEMR 8.4.0 Release API view
- HHS HC3 Sector Alert: Multiple Vulnerabilities in OpenEMR (January 31, 2023)
- Healthcare IT News: OpenEMR patches security bugs that put millions of patient records at risk
- OpenEMR Achieves ONC Certification with Release 7.0
- OpenEMR Security Policy


![CVE-2026-119155: Unauthenticated Time-Based SQL Injection in wpForo Forum via href[locale], with Patch Analysis](/_next/image?url=https%3A%2F%2Fstorage.googleapis.com%2Fzeropath-fe-assets%2Fblog-assets%2Fcve-2026-119155.png&w=3840&q=75)
