A Vaultwarden SSO bug found five weeks after someone else found it, and what the duplicate tells you

TL;DR
Vaultwarden’s SSO login handler has two branches: one for new IdP identities and one that binds an IdP identity to an existing local Vaultwarden account by email match. The new-user branch checks the IdP’s email_verified claim before creating the account. The existing-user branch does not. With the documented default SSO_SIGNUPS_MATCH_EMAIL=true, an attacker who can register an IdP identity asserting the victim’s email — verified or not — receives a Vaultwarden session bound to the victim’s account, unless the victim has 2FA enabled.
Disclosed as GHSA-6x5c-84vm-5j56, CVE-2026-47164, high severity. Fixed in Vaultwarden 1.36.0. Reported independently by @ch1nhpd on 2026-03-22 and by me on 2026-04-29. Published 2026-05-19 as part of a four-advisory coordinated drop on Vaultwarden’s SSO and icon-proxy surfaces.
The bug itself is straightforward. The interesting part is the duplicate report. Same root cause, two independent finders, five weeks apart. That gap is a measurement of the audit attention this code path had been receiving from anyone, and the answer turns out to be: not much.
Why Vaultwarden SSO
Vaultwarden is a Rust reimplementation of the Bitwarden self-hosted server. Single maintainer (dani-garcia) plus a small contributor pool, ~270K Docker pulls per week at the time of writing, and increasingly the default “I want a password manager but not Bitwarden cloud” choice for self-hosters. The SSO support is more recent than the rest of the server, came in via a contributor fork (Timshel’s OIDCWarden), and lives in src/api/identity.rs and src/sso.rs. New code, recent code, security-load-bearing code. That’s the audit shape.
The published advisory catalog also tells you something. Four advisories landed on 2026-05-19 against Vaultwarden: this one (existing-user SSO binding), a CSRF in the SSO authorization flow (CVE-2026-47158), an org-enumeration disclosure in the SSO discovery endpoint (CVE-2026-47159), and an SSRF in the icon endpoint via decimal/hex/octal IP encoding (CVE-2026-47160). Three of the four are in the SSO layer. The maintainer triaged and patched all four in the same release window. The pattern is consistent with a code path that was getting first-pass external audit attention, late.
I picked Vaultwarden because the SSO layer was new enough that the obvious bugs hadn’t been swept yet, the threat model around email_verified is well-documented in OIDC Core 1.0 §5.1, and Bitwarden’s upstream has a different and provably safer model for the same flow. When upstream and a fork diverge on an identity-binding flow, the fork is usually the one to read.
The audit workflow
I gave Claude src/api/identity.rs and asked it to map the SSO login flow as a state machine: every branch in _sso_login(), every guard each branch performs, every state the user account can reach from each branch. The hypothesis I was testing was that one of those branches would skip a guard that another branch performs, because the branches were written by different patches at different times.
The output came back as a small table. Two top-level branches: None => (no local user with matching email, create new) and Some((user, sso_user)) => (local user exists, bind SSO identity to it). Under each, the list of checks performed. The email_verified claim showed up under the new-user branch and not under the existing-user branch. The 2FA gate showed up under both. The is_email_domain_allowed check showed up only under the new-user branch.
The first hit I wanted to verify was the email_verified one. The asymmetry between branches is the kind of thing the model can confidently misread, because the actual check might be performed upstream in the calling function and not show up in the per-branch view. I opened src/api/identity.rs and read _sso_login() end to end. The check was not upstream. The check was on one branch only. I read the OIDCWarden upstream and confirmed the same shape there too, with a slightly different line layout but the same missing check.
About forty minutes of model interaction and another hour of manual reading to confirm. The bug was real.

The bug
src/api/identity.rs, branch selection at the top of _sso_login():
let user_with_sso = match SsoUser::find_by_identifier(&user_infos.identifier, conn).await {
None => match SsoUser::find_by_mail(&user_infos.email, conn).await {
None => None,
Some((user, Some(_))) => err_silent!(...),
Some((user, None)) if user.private_key.is_some()
&& !CONFIG.sso_signups_match_email() => err_silent!(...),
Some((user, None)) => Some((user, None)),
},
Some((user, sso_user)) => Some((user, Some(sso_user))),
};
The path that returns Some((user, None)) binds the IdP identity to any existing Vaultwarden user that matches by email, as long as sso_signups_match_email() is true. That is the default.
Then the two branches that consume that result. New user, with the email_verified check:
None => {
if !CONFIG.is_email_domain_allowed(&user_infos.email) {
err!("Email domain not allowed", ...);
}
match user_infos.email_verified {
None if !CONFIG.sso_allow_unknown_email_verification() =>
err!("Your provider does not send email verification status. ..."),
Some(false) =>
err!("You need to verify your email with your provider before you can log in"),
_ => (),
}
let mut user = User::new(&user_infos.email, user_infos.user_name.clone());
...
}
Existing user, without the email_verified check:
Some((mut user, sso_user)) => {
let mut device = get_device(&data, conn, &user).await?;
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?;
if user.private_key.is_none() {
user.verified_at = Some(now);
...
}
if user.email != user_infos.email {
if CONFIG.mail_enabled() {
mail::send_sso_change_email(&user_infos.email).await?;
}
info!("User {} email changed in SSO provider from {} to {}", ...);
}
...
}
No user_infos.email_verified reference appears in this branch. No upstream guard in sso::exchange_code gates on email_verified for both flows.
The maintainer’s own config documents the threat:
sso_signups_match_email: bool, true, def, true;
/// Allow unknown email verification status |> Allowing this with `SSO_SIGNUPS_MATCH_EMAIL=true`
/// open potential account takeover.
sso_allow_unknown_email_verification: bool, true, def, false;
The warning conditions the danger on SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION=true. The bug here defeats the safe default: with SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION=false, the existing-user branch never reaches the gate.
The 2FA gate
The existing-user branch calls twofactor_auth before issuing the session. Inside that function:
let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await;
if twofactors.is_empty() {
enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?;
return Ok(None);
}
If the victim has no 2FA configured, the function returns Ok(None) and the takeover succeeds. If the victim has 2FA, the function blocks at the 2FA challenge. A 2FA-protected victim is not exploitable through this path.
This is a meaningful mitigation. It is also true that Vaultwarden does not enforce 2FA by default, and many self-hosted users do not enable it. The bug is real for the no-2FA cohort, which is a non-trivial fraction of the self-hosted user base.
Trust-model deviation from Bitwarden upstream
Bitwarden’s official server enforces existing-user SSO binding through an invite-acceptance flow. The org admin invites the email, the recipient must click an emailed link before SSO can attach. The email_verified IdP claim is not consulted because email ownership is proved by the invite-acceptance round-trip.
In bitwarden_license/src/Sso/Controllers/AccountController.cs, line 533 throws UserAlreadyExistsInviteProcess if no OrganizationUser row exists. Line 551 throws AcceptInviteBeforeUsingSSO if the row exists but is still in Invited status. Lines 556 to 562 (EnforceAllowedOrgUserStatus) further restrict to Accepted or Confirmed. The block is prefaced by a comment header reading “Critical Code Check Here” whose body says, plainly:
We want to ensure a user is not in the invited state explicitly. User’s in the invited state should not be able to authenticate via SSO.
Vaultwarden’s existing-user branch performs neither the invite-acceptance check nor an email_verified check. The bind succeeds based solely on the IdP-supplied email matching a row in the users table.
This is the kind of thing that happens when a fork reimplements a flow but doesn’t pick up the threat model that motivated the original. The check looked redundant if you didn’t know what it was doing. So it got left out.
The PoC
The reproducer is a one-shot Bash script in docker/run_poc.sh that brings up Vaultwarden and Keycloak in containers, creates the OIDC client, registers a victim, creates an attacker IdP identity with the victim’s email and emailVerified=false, and runs the SSO flow end to end. The resulting token-exchange response contained the victim’s encrypted vault and key material, along with a 2-hour access token and a 30-day refresh token bound to the victim’s user UUID.
Observed:
=== access_token claims (decoded) ===
"sub": "97e87705-2166-4d84-9090-088f0eb1d13c", <- victim's user UUID
"email": "victim@example.com",
"email_verified": true, <- Vaultwarden asserts true; IdP claim was false
"device": "00000000-0000-0000-0000-000000000001" <- attacker's newly-registered device
=== Capability probes ===
read profile (PII) HTTP 200
read full vault sync (encrypted ciphers) HTTP 200
list 2FA providers HTTP 200
list authorized devices HTTP 200
list emergency-access trustees HTTP 200
Negative control with SSO_SIGNUPS_MATCH_EMAIL=false blocked the attack at the token-exchange step with {"message":"Existing non SSO user with same email"}. The documented mitigation works; the documented safe default does not.
The captured Key and PrivateKey envelopes are encrypted under a key derived from the victim’s master password (PBKDF2-SHA256 600,000 iterations by default, or Argon2id). They are not directly decryptable, but they are suitable for offline brute-force against weak master passwords, and they exfiltrate cleanly.

What the duplicate report tells you
The advisory credits two reporters. ch1nhpd filed on 2026-03-22, I filed on 2026-04-29. The advisory was published on 2026-05-19 and assigned CVE-2026-47164. Five weeks separates the two reports of the same root cause.
The straightforward read of this is: I was late. The bug had already been reported when I found it. The fix had not yet shipped, so the report wasn’t redundant from the maintainer’s perspective, and GitHub’s advisory credits both reporters with their report dates, but a CVE generally goes to the first finder and the rest of us are footnotes. That’s the convention, and it’s the right convention.
The more interesting read is what the five-week gap measures. The bug had been sitting in an open-source repo with thousands of stars and meaningful security visibility for the entire period the SSO feature had been in the codebase. Two independent auditors with no contact with each other read the same file, asked the same general question (“does this branch enforce the guards I expect?”), and found the same bug within a small window of each other. Neither of us was the maintainer. Neither of us was the contributor. Neither of us had inside knowledge.
That gap is a proxy for how thin the external audit attention on Vaultwarden’s SSO code path had been. If the surface had been getting first-pass audit attention from anyone over the previous two years, the bug would have been reported before either of us got to it. It wasn’t. The five-week reproducer is a small lower bound on “how much audit time per quarter does this code get from people who are looking specifically for this class of bug.” The answer is: not enough to find it before March 2026.
This is useful information for the next audit. The targets where independent finders converge are the targets where the audited surface is much smaller than the code surface, and the gap between “code that exists” and “code that has been read with hostile intent” is wide. Self-hosted security-critical software in languages with small auditor communities is a recurring example. Vaultwarden is one. Other open-source SSO implementations are likely others.
I had two reactions to the duplicate. The first was reflexive disappointment, which I want to flag because it’s a bad reaction to have. The second was the more useful one: the duplicate is the cleanest possible signal that the audit method I used wasn’t novel. Someone else, plausibly using different tooling, found the same bug from the same surface. The bug was there to be found. The method generalizes.

What this method does well
Branch-divergence audit, which is what this turned out to be, generalizes. The hypothesis is that any flow with multiple entry points and shared downstream effects accumulates asymmetries between branches over time. Each branch is written by a different patch, each patch lands at a different point in the codebase’s evolution, and the maintainer reviewing each patch is not necessarily comparing it against the symmetric branches. The asymmetries are not bugs the day each branch lands. They become bugs when the threat model around the shared downstream effect tightens, and one branch picks up the tightening while another doesn’t.
The audit workflow is:
- Identify a flow with multiple branches that reach the same security-load-bearing operation.
- Enumerate the guards each branch performs upstream of that operation.
- Diff the guard sets. Investigate every guard present on one branch and absent on another.
- For each gap, ask whether the absence is a deliberate semantic difference, an unintended omission, or an upstream-shared guard that just happens to be invisible at this point in the flow.
Step 3 is where the model shines. “Enumerate every guard each of these branches performs” against an 18,000-line file is a tractable mechanical task for the model and a tedious one for a human. Step 4 is where the model gets dangerous: a confidently-stated “this gap is deliberate because [reasoning]” is exactly the kind of plausible synthesis the model will produce when the answer is actually “I’m guessing.” Treat step 4 as a question the model can pose to you, not a question the model can answer.
The other thing this method is good for: surfacing fork-versus-upstream divergence. Vaultwarden has Bitwarden as a reference implementation for almost every security-load-bearing flow. Asking “does the fork preserve the threat model that motivated the upstream check?” is a tractable diff question. The model is fine at running the diff. The judgment about whether the threat model still applies is yours.
Disclosure timeline
- 2026-03-22: @ch1nhpd reports the bug to dani-garcia.
- 2026-04-28: I confirm and reproduce against
vaultwarden/server:latest(commit62748100). - 2026-04-29: I submit my report as GHSA-j4j8-gpvj-7fqr.
- 2026-05-19: Vaultwarden 1.36.0 ships. Four advisories published: CVE-2026-47158 (CSRF), CVE-2026-47159 (SSO discovery enumeration), CVE-2026-47160 (icon SSRF), and CVE-2026-47164 (this bug).
- 2026-05-22: This writeup.
The four-advisory drop is its own signal. Three SSO bugs and an SSRF in the same release tells you the maintainer received roughly a quarter’s worth of accumulated external reports against the SSO and proxy surfaces and shipped fixes together. That’s healthy disclosure behavior. The pattern also confirms what the duplicate timing already implied: the SSO surface had been quietly accumulating findings.
The fix is in head and shipping with 1.36.0. Operators on older versions who cannot upgrade immediately should set SSO_SIGNUPS_MATCH_EMAIL=false to require explicit action before binding a new SSO identity to an existing local account. The IdP-side mitigation, as always, is to use an IdP that strongly enforces email domain ownership (Google Workspace, domain-verified Microsoft Entra) rather than one with self-service registration.
Full submission writeup and PoC are in my research archive. Originally published at tomryan.dev.

How this was written
This post was drafted from my notes by an AI model and then edited by me. The reasoning, decisions, and corrections are mine; the prose started from a machine. The underlying technical work this post describes is real.
Licensed CC-BY-4.0.