Feb 18, 2026·6 min read·3 visits
ZITADEL failed to mask invalid usernames in its Login UI V2 and API responses. By analyzing error messages, password policy prompts, and response timing, attackers could confirm valid accounts. Patched in versions 3.4.6 and 4.9.1.
ZITADEL, a prominent cloud-native Identity Provider (IdP), suffered from a logic flaw that turned its login and password reset interfaces into a user enumeration oracle. By failing to consistently enforce the 'IgnoreUnknownUsernames' security policy and leaking specific backend error states to the frontend, the system allowed unauthenticated attackers to validate the existence of user accounts. This vulnerability transforms the platform from a gatekeeper into a phonebook for attackers, facilitating targeted phishing and credential stuffing campaigns.
Identity Providers (IdPs) have one job: Keep the gate closed until someone proves who they are. They are the bouncers of the digital world. But imagine a bouncer who, when you ask for 'Alice', says "Alice isn't here right now," but when you ask for 'Bob', says "I've never heard of a Bob in my life." That is exactly what ZITADEL was doing.
User enumeration is often dismissed as 'Low Severity' (CVSS 5.3 in this case), but in the hands of a skilled attacker, it is the precursor to compromise. If I know your corporate email exists in the database, I am halfway to owning your account. I can launch targeted spear-phishing campaigns (sending you a fake 'Reset Password' email because I know you have an account) or brute-force your password without wasting cycles on invalid users.
CVE-2026-23511 isn't a memory corruption bug or a fancy RCE. It's a logic flaw in the 'Login UI V2' and backend error handling that disregarded the golden rule of authentication: Fail Generically. The system was configured to lie to attackers via the IgnoreUnknownUsernames setting, but due to poor implementation, it told the truth anyway.
The vulnerability stems from a disconnect between high-level security policies and low-level implementation details. ZITADEL has a feature called IgnoreUnknownUsernames. When enabled, this boolean flag is supposed to ensure that the system returns a "success-like" response (e.g., prompting for a password) even if the username doesn't exist. This creates a "Schrödinger's User" state where an attacker cannot verify existence.
However, in the React-based Login UI V2, the application logic effectively ignored this setting. When an invalid username was submitted, the backend threw a specific error, and the frontend faithfully rendered it. Instead of a generic "Check your credentials," the attacker received a "User not found" signal.
But wait, it gets worse. The flaw wasn't just in the login screen. It was also present in the Password Complexity Oracle. When a user attempts to set or reset a password, ZITADEL fetches the password complexity policy (e.g., "Must contain a symbol") specific to that user. If the user didn't exist, the lookup failed before retrieving the policy. Consequently, the UI would display the policy hints for valid users but omit them (or show default ones) for invalid users. This observable difference—the presence or absence of a "Must have 8 characters" label—became a binary oracle for account validity.
Let's look at the TypeScript code in apps/login/src/lib/server/loginname.ts. The developers needed to implement a check that explicitly swallows the error if the security policy is active. Before the patch, the error simply propagated.
Here is the fix logic that was introduced in commit c300d4cc6a2775ab17ddfe76492f24170f8b858d. Notice how they now force a redirect to the password page even if the user lookup failed:
// apps/login/src/lib/server/loginname.ts
const preventUserEnumeration = (organization: string | undefined) => {
// If the policy says "Don't tell them", we lie.
if (command.ignoreUnknownUsernames) {
// Redirect to password page as if the user exists
return { redirect: "/password?" + paramsPasswordDefault };
}
// Otherwise, tell the truth (legacy behavior)
return { error: t("errors.userNotFound") };
};On the backend (Go), the issue was passing raw errors to the frontend. In internal/api/ui/login/mail_verify_handler.go, specific database errors like NotFound were leaking. The fix involved catching these specific errors and wrapping them in a generic InvalidArgument error code:
// internal/api/ui/login/mail_verify_handler.go
if err != nil {
logging.WithError(err).Warn("error verifying email code")
// PATCH: Sanitize the error.
// Instead of returning the DB error, return "Errors.User.Code.Invalid"
l.renderMailVerification(
w, r, authReq, userID, "", password != "",
zerrors.ThrowInvalidArgument(nil, "LOGIN-WSdsg", "Errors.User.Code.Invalid")
)
return
}This is a classic defensive programming pattern: Sanitize at the Boundary. You can have detailed errors in your logs, but never send them to the client.
Exploiting this does not require special tools—curl or Burp Suite Intruder is sufficient. The attack workflow focuses on identifying the Response Discrepancy.
admin@target.com, fake@target.com).policy object in the JSON response or the rendered HTML.Even if the system redirects both users to /password, the valid user page loads the organization's specific password complexity rules (e.g., "Must have 12 chars"). The invalid user page might load a default fallback or nothing at all. By diffing the HTML response size or specific DOM elements (e.g., <div id="password-requirements">), the attacker can sort the valid users from the noise.
You might be thinking, "So they know my username. Big deal." In the context of modern attacks, it is a big deal.
email:password pairs from the dark web. If they try to stuff credentials into your ZITADEL instance, 99% of those emails won't exist in your system. If the system tells them "User not found," they can discard that entry instantly. If the system is silent, they have to waste time trying passwords. This vulnerability speeds up stuffing attacks by orders of magnitude.finance-approvals@company.com is a valid account, I can craft a highly specific phishing email targeting that role. If I know j.smith exists but john.smith doesn't, I know exactly how to address the target.The remediation is straightforward: Upgrade. ZITADEL patched this in versions 3.4.6 and 4.9.1.
If you are running a self-hosted instance, pull the latest container image immediately. The fix works by forcing the application to behave identically regardless of user existence:
If you cannot patch immediately, your only mitigation is to rely on WAF rules to rate-limit IP addresses making high-frequency login attempts, but this won't stop a "low-and-slow" enumeration attack.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N| Product | Affected Versions | Fixed Version |
|---|---|---|
ZITADEL ZITADEL | 2.0.0 - 2.71.19 | 2.71.20 (Implied/Check Vendor) |
ZITADEL ZITADEL | < 3.4.6 | 3.4.6 |
ZITADEL ZITADEL | 4.0.0 - 4.9.0 | 4.9.1 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-204 (Observable Response Discrepancy) |
| CVSS v3.1 | 5.3 (Medium) |
| Attack Vector | Network (AV:N) |
| Privileges | None (PR:N) |
| User Interaction | None (UI:N) |
| Exploit Maturity | None (No public PoC) |
Observable Response Discrepancy