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.
The account's 2FA policy treats passkeys as a valid factor (model/user.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):
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.
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:
{"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.
u.Enabled2FA() (not u.EnabledOTP()) in
Login, in both SSO callbacks, and in RequireSecureSession().begin_passkey_login / finish_passkey_login
flow already exists and should be required as the second step).api/user/auth.go — Loginmodel/user.go — EnabledOTP, EnabledPasskey, Enabled2FA, AfterFindapi/user/2fa.go — get2FAStatusinternal/middleware/secure_session.go — RequireSecureSession{
"cwe_ids": [
"CWE-287",
"CWE-305",
"CWE-308"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-09T17:08:15Z",
"nvd_published_at": null,
"severity": "HIGH"
}