Nginx UI: Authentication bypass: password login does not enforce a passkey-only second factor (2FA bypass)
CVE-2026-107808 · GHSA-45gv-9wjv-xh7p · GO-2026-6704 · published 9 Oct 2026
How serious is it
- Severity: high, CVSS score 8.1 out of 10.
- No exploit prediction (EPSS) score yet.
- Not on the CISA list of exploited vulnerabilities.
- No public exploit in Exploit-DB.
Is your app affected by CVE-2026-107808?
Paste this prompt into Claude Code, Cursor or any AI coding tool: it runs a free Shippable scan of your project and fixes what it finds. Built with Lovable, Bolt or v0? Scan your live app. Or upload your package file.
Who is affected
github.com/0xJacky/Nginx-UI(Go) >= 1.9.10-0.20250517140552-daee3ac7ade1, < 1.9.10-0.20260728074558-95cd21b70814
How to check
- Run
go list -m github.com/0xJacky/Nginx-UIin your project to see the installed version, including copies pulled in by other packages. - Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.
How to fix
Copies a prompt for Claude Code, Cursor or Codex: it checks your lockfiles for this and upgrades it.
- Upgrade
github.com/0xJacky/Nginx-UIto 1.9.10-0.20260728074558-95cd21b70814 or later, commit the lockfile and redeploy. - If a vulnerable version was live, check your logs and rotate the secrets that server could read.
The advisory
## Summary
nginx-ui supports two second-factor methods — TOTP (OTP) and WebAuthn passkeys —
and reports an account as 2FA-enabled when **either** is configured. However, the
password login endpoint (`POST /api/login`) only enforces a second factor when a
**TOTP secret** is present. An account that has registered a **passkey but no
TOTP** is logged in after password verification alone — the passkey is never
requested. This silently downgrades a passkey-protected account to single-factor
(password-only) authentication.
## Details
The account's 2FA policy treats passkeys as a valid factor (`model/user.go`):
```go
func (u *User) EnabledOTP() bool { return len(u.OTPSecret) != 0 }
func (u *User) EnabledPasskey() bool { /* true if a passkey row exists */ }
func (u *User) Enabled2FA() bool { return u.EnabledOTP() || u.EnabledPasskey() }
func (u *User) AfterFind(_ *gorm.DB) error {
u.EnabledTwoFA = u.Enabled2FA() // exposed to the UI as `enabled_2fa`
return nil
}
```
But the login handler only checks `EnabledOTP()` (`api/user/auth.go`, `Login`):
```go
u, err := user.Login(json.Name, json.Password)
...
if u.EnabledOTP() { // <-- only TOTP is enforced
if json.OTP == "" && json.RecoveryCode == "" {
c.JSON(http.StatusOK, LoginResponse{Message: "The user has enabled 2FA", Code: Enabled2FA}) // 199
user.BanIP(clientIP)
return
}
if _, err = user.VerifyOTP(u, json.OTP, json.RecoveryCode); err != nil { /* ... */ }
secureSessionID = user.SetSecureSessionID(u.ID)
}
// Passkey-only accounts fall through to here and receive a full session token:
accessToken, err := user.GenerateJWT(u)
```
No branch requires a WebAuthn assertion during password login when
`EnabledPasskey()` is true. The root cause is the mismatch between the
**policy definition** (`Enabled2FA()` = OTP **or** passkey) and the
**enforcement check** (`EnabledOTP()` only).
The same `EnabledOTP()`-only gating in `RequireSecureSession()`
(`internal/middleware/secure_session.go`) means passkey-only users are also
exempted from step-up on sensitive actions.
## Proof of Concept
Tested against nginx-ui built from source (`go build -tags unembed`) on
`127.0.0.1:9000`, with WebAuthn configured and a victim account that has a
registered passkey and **no** TOTP. The vulnerable code is identical on the
production `main` branch (`api/user/auth.go` `Login` gates on `u.EnabledOTP()`
only; verified at commit `6c86e5a`, 2026-05-17).
Account state advertised by `GET /api/2fa_status`:
```json
{"enabled":true,"otp_status":false,"passkey_status":true, ...}
```
Attacker logs in with **password only** (`POST /api/login`, encrypted params as
the client normally sends):
```
HTTP 200
{"message":"ok","code":200,"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...."}
```
`code: 200` + a valid JWT = an authenticated session, with the passkey never
used. For comparison, the identical account with a TOTP secret instead correctly
returns the second-factor challenge:
```
HTTP 200
{"message":"The user has enabled 2FA","code":199}
```
| Account state | `POST /api/login` (password only) |
|---|---|
| Passkey registered, no TOTP | `code 200` + JWT — **2FA NOT enforced** |
| TOTP registered | `code 199` — 2FA challenge enforced |
This isolates the defect: the login path enforces OTP but ignores passkeys.
## Impact
- Any account protected **only** by a passkey is reduced to password-only
authentication. An attacker who obtains the password (phishing, reuse, leak)
gains full access despite the registered security key.
- In nginx-ui all authenticated users are effectively administrators and the
terminal feature grants a host shell, so account takeover leads to full
control of the managed nginx instance / host.
- Users are given a false sense of security: the UI shows the account as
2FA-enabled while the second factor is not enforced at login.
## Remediation
- Gate the second-factor decision on `u.Enabled2FA()` (not `u.EnabledOTP()`) in
`Login`, in both SSO callbacks, and in `RequireSecureSession()`.
- For a passkey-only user logging in with a password, return a
"passkey assertion required" challenge and complete login only after a
successful WebAuthn assertion (the `begin_passkey_login` / `finish_passkey_login`
flow already exists and should be required as the second step).
- Centralize session-token issuance so no login entry point can skip the 2FA
decision.
## Affected components
- `api/user/auth.go` — `Login`
- `model/user.go` — `EnabledOTP`, `EnabledPasskey`, `Enabled2FA`, `AfterFind`
- `api/user/2fa.go` — `get2FAStatus`
- `internal/middleware/secure_session.go` — `RequireSecureSession`
Check your whole app
Dependencies are only half of it. Free and read-only: before you push, run npx shippable-dev scan localhost:3000 in your project folder, no account needed. For your live app, you prove it's yours with a one-line tag first.
References
- github.com/0xJacky/nginx-ui/security/advisories/GHSA-45gv-9wjv-xh7p
- github.com/0xJacky/nginx-ui/commit/95cd21b70814e5d9a48a359aa238aeea1ac97429
- github.com/0xJacky/nginx-ui/releases/tag/v2.5.0
- github.com/advisories/GHSA-45gv-9wjv-xh7p
- github.com/0xJacky/nginx-ui
Merged from GitHub Advisory Database, OSV. Last updated 24 Oct 2026. All vulnerabilities · RSS · JSON