Knock Knock, Who's There? Unmasking User Enumeration in ZITADEL
Jan 15, 2026·7 min read
Executive Summary (TL;DR)
ZITADEL versions prior to 4.9.1 and 3.4.6 were too honest for their own good. The application leaked user existence through inconsistent error handling in password reset flows and failed UI logic. If you sent a password reset request for a valid user, the system behaved differently than for an invalid one—leaking metadata via error messages or even the presence of password complexity policies. The fix? Lying to the user. The system now returns generic responses regardless of whether the account exists.
ZITADEL, a popular open-source identity management platform, suffered from a logic flaw that allowed unauthenticated attackers to confirm the existence of valid user accounts. By analyzing subtle discrepancies in error messages and UI behavior during password reset and login flows, attackers could harvest UserIDs and usernames, bypassing the platform's 'Ignore Unknown Usernames' security setting.
The Gatekeeper's Gossip
Identity Management Platforms (IAMs) like ZITADEL are the bouncers of the internet. They stand at the door, checking IDs, and deciding who gets into the club. But a good bouncer knows the first rule of security: Don't give away information for free. If someone walks up and asks, "Is Bob here?", the bouncer shouldn't check the guest list and say, "Yeah, but he's busy." They should stare blankly and say nothing.
ZITADEL failed this basic test. In an effort to be helpful to developers and users, the code was returning precise, granular error messages. While this is great for debugging, it is catastrophic for privacy. This vulnerability (CVE-2026-23511) isn't a buffer overflow or a remote code execution exploit that burns the server down. It's a User Enumeration flaw—a subtle information leak that allows an attacker to build a list of valid targets.
Why does this matter? Because knowing who to attack is half the battle. If I can verify that admin, root, or ceo@company.com are valid accounts, I can focus my brute-force or spraying attacks solely on them. I stop wasting time on dead ends. ZITADEL explicitly added a feature called "Ignore Unknown Usernames" to prevent exactly this, but thanks to some logical loopholes in the Login UI V2 and backend handlers, that feature was effectively bypassed.
The Anatomy of the Leak
The vulnerability relied on CWE-204: Observable Response Discrepancy. In plain English: the application acted differently depending on whether an account existed or not. This manifested in three distinct flavors within ZITADEL's architecture.
1. The Error Message slip-up
When an unauthenticated user attempts to verify a password reset code (the checkPWCode handler), the backend performs a lookup. If the UserID exists but the code is wrong, it returns an "Invalid Code" error. If the UserID doesn't exist, it returned a "User Not Found" error. The frontend simply rendered whatever error the backend threw at it. To an attacker, this is a binary oracle: Error A = Valid User, Error B = Invalid User.
2. The Password Policy Snitch
This is the most interesting vector. ZITADEL allows organizations to define custom password complexity policies (e.g., "Must have 8 chars, 1 symbol"). When the Login UI loads, it fetches this policy to show the user what rules they need to follow.
The code logic was: "Fetch user -> Get user's Org -> Get Org's Policy".
If the user didn't exist, the chain broke, and the backend returned nil (no policy). The UI would then display no requirements or a generic fallback different from the specific organization's policy. An attacker could simply watch the UI elements. Did the "Password Requirements" box appear? Yes? The user exists. No? The user is a ghost.
3. The Resend Logic
In the "Resend Verification Email" flow, if you requested a resend for a non-existent user, the system threw an error to the UI. If the user existed, it showed a success message ("Email sent"). This is the classic enumeration vector that almost every authentication system has suffered from at some point.
Code Autopsy: The Fix
Let's look at the Golang code responsible for this "honesty." The fix primarily involved sanitizing error returns and ensuring consistent execution paths regardless of user existence.
The Direct Error Leak
In the vulnerable version of the password reset handler, the code directly propagated the database error to the HTTP response.
// BEFORE: Vulnerable
_, err := l.command.SetPasswordWithVerifyCode(r.Context(), ...)
if err != nil {
// If err was "User Not Found", the attacker saw it.
l.renderInitPassword(w, r, authReq, data.UserID, "", err)
return
}The fix forces the error to be generic, masking the specific database result:
// AFTER: Patched (Commit b85ab69)
_, err := l.command.SetPasswordWithVerifyCode(r.Context(), ...)
if err != nil {
// Force the error to always be "Invalid Code"
l.renderInitPassword(w, r, authReq, data.UserID, "", zerrors.ThrowInvalidArgument(nil, "VIEW-KaGue", "Errors.User.Code.Invalid"))
return
}The Policy Loading Logic
The fix for the password policy leak was clever. Instead of returning nil when a user isn't found, the system now falls back to a Default Policy. This ensures the UI always receives a valid policy object, rendering the password requirements box consistently.
// AFTER: Patched Policy Logic
policy, err := l.query.PasswordComplexityPolicyByEntity(r.Context(), user.ResourceOwner)
if err != nil {
// If we can't find the user's specific policy, load the instance default
// This prevents the UI from behaving differently for invalid users
policy, err = l.query.DefaultPasswordComplexityPolicy(r.Context())
}This change ensures that valid and invalid users result in the exact same network traffic size and UI layout, blinding the oracle.
Knocking on Doors (Exploitation)
Exploiting this does not require complex memory corruption tools or zero-day gadgets. It requires a script, a wordlist, and patience. Here is how an attacker would weaponize this against a vulnerable ZITADEL instance (e.g., v4.9.0).
The Setup
We target the /login/password/init endpoint. We assume we have a list of UserIDs (which are often predictable or sequential in some legacy imports, though ZITADEL defaults to random strings) or we target the username resolution endpoints.
The Attack Loop
Refined Techniques
If the error messages are suppressed (partially), the attacker moves to Timing Analysis or Response Size Analysis.
- Response Size: If the valid user triggers the loading of a specific password policy (JSON blob), the response might be 1.5KB. If the invalid user returns
nil, the response might be 1.2KB. That 300-byte difference is a reliable fingerprint. - Timing: If the "User Not Found" error returns in 5ms (database index miss), but the "Invalid Code" error returns in 50ms (database hit + crypto hash verify), the timing delta reveals the user.
Silence is Golden (Mitigation)
The only way to fix enumeration vulnerabilities is to decouple the response from the internal state. The application must "lie" to the user.
Immediate Steps for Admins
- Upgrade: Update to ZITADEL v4.9.1 or v3.4.6 immediately. These versions contain the backported fixes that normalize error messages and policy loading.
- Rate Limiting: Even with the fix, you should apply aggressive rate limiting on public endpoints like
/loginand/password/reset. If a single IP makes 50 failed attempts in a minute, ban it. This doesn't fix the bug, but it makes exploitation painful. - WAF Rules: Configure your WAF to look for high-frequency requests to
/login/password/initthat return 4xx errors.
Developer Takeaways
If you are building auth systems, remember:
- Generic Errors: Never return "User not found". Always return "Invalid username or password" or "If your account exists, an email has been sent."
- Constant Time: Ensure that the code path for valid and invalid users takes roughly the same amount of time and generates the same amount of data.
- Default Fallbacks: Never let a UI element depend on the existence of a user record if that UI element is visible before authentication is complete.
Fix Analysis (2)
Technical Appendix
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:NAffected Systems
Affected Versions Detail
| Product | Affected Versions | Fixed Version |
|---|---|---|
ZITADEL ZITADEL | >= 4.0.0, <= 4.9.0 | 4.9.1 |
ZITADEL ZITADEL | >= 3.0.0, <= 3.4.5 | 3.4.6 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-204 |
| Attack Vector | Network (API/Web) |
| CVSS | 5.3 (Medium) |
| Privileges Required | None |
| Impact | Confidentiality (Metadata) |
| Exploit Status | PoC Available |
MITRE ATT&CK Mapping
Observable Response Discrepancy
Known Exploits & Detection
Vulnerability Timeline
Subscribe to updates
Get the latest CVE analysis reports delivered to your inbox.