# Bitwarden: SCIM API Key Retrieval Skips Master-Password Re-Auth via && Short-Circuit
Bitwarden: SCIM API Key Retrieval Skips Master-Password Re-Auth
One!=in front of an&&. The first operand was the bypass, not the password check.
Second writeup in my Bitwarden series. This one is a re-authentication bypass on the Organization API key endpoints — a clean short-circuit bug where the master-password verification is *guarded* by a type check that, for one specific key type, skips the verification entirely.
- ➜Vendor: Bitwarden
- ➜Product:
bitwarden/server - ➜Severity: High (CVSS 8.1)
- ➜CVE: CVE-2026-43640
- ➜Report: HackerOne #3627893
- ➜Fix: bitwarden/server PR #7403 — commit `eb251d9b`
- ➜Fixed in: Self-hosted `v2026.4.1` (2026-04-20). Bitwarden Cloud deployed the fix the night before the report was resolved on 2026-05-06.
//TL;DR
Two endpoints in OrganizationsController.cs ask for the user's master password before handing back an organization API key:
POST /organizations/{id}/api-key
POST /organizations/{id}/rotate-api-key
Both endpoints had this guard around VerifySecretAsync:
if (model.Type != OrganizationApiKeyType.Scim
&& !await _userService.VerifySecretAsync(user, model.Secret))
C#'s && is short-circuiting. When model.Type == Scim, the left operand is false and VerifySecretAsync is never called. Execution falls into the else branch, which returns the SCIM key (or rotates it). Any non-empty string in MasterPasswordHash passes model validation, so a stolen session token is sufficient — no password needed.
The fix is one line per endpoint: drop the type guard, always verify the password.
//The vulnerable code
src/Api/AdminConsole/Controllers/OrganizationsController.cs, retrieval endpoint (vulnerable version):
[HttpPost("{id}/api-key")]
public async Task<ApiKeyResponseModel> ApiKey(string id, [FromBody] OrganizationApiKeyRequestModel model)
{
var orgIdGuid = new Guid(id);
if (!await HasApiKeyAccessAsync(orgIdGuid, model.Type))
throw new NotFoundException();
var organization = await _organizationRepository.GetByIdAsync(orgIdGuid);
if (organization == null) throw new NotFoundException();
if (model.Type == OrganizationApiKeyType.BillingSync ||
model.Type == OrganizationApiKeyType.Scim)
{
var productTier = organization.PlanType.GetProductTier();
if (productTier is not ProductTierType.Enterprise and not ProductTierType.Teams)
throw new NotFoundException();
}
var organizationApiKey =
await _getOrganizationApiKeyQuery.GetOrganizationApiKeyAsync(organization.Id, model.Type)
?? await _createOrganizationApiKeyCommand.CreateAsync(organization.Id, model.Type);
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null) throw new UnauthorizedAccessException();
// The bug.
if (model.Type != OrganizationApiKeyType.Scim
&& !await _userService.VerifySecretAsync(user, model.Secret))
{
await Task.Delay(2000);
throw new BadRequestException("MasterPasswordHash", "Invalid password.");
}
else
{
// Reached without password verification when Type == Scim.
return new ApiKeyResponseModel(organizationApiKey);
}
}
The rotation endpoint (/rotate-api-key) had the same conditional, with RotateApiKeyAsync in the else block.
▶What the request model "validates"
OrganizationApiKeyRequestModel extends SecretVerificationRequestModel:
public class SecretVerificationRequestModel : IValidatableObject
{
public string MasterPasswordHash { get; set; }
public string OTP { get; set; }
public string AuthRequestAccessCode { get; set; }
public string Secret => !string.IsNullOrEmpty(MasterPasswordHash)
? MasterPasswordHash : OTP;
public virtual IEnumerable<ValidationResult> Validate(...)
{
if (string.IsNullOrEmpty(Secret) && string.IsNullOrEmpty(AuthRequestAccessCode))
yield return new ValidationResult("MasterPasswordHash, OTP, or AccessCode must be supplied.");
}
}
Model validation insists *some* secret is present. It does not check that it's the *right* secret — that's VerifySecretAsync's job, and VerifySecretAsync is precisely what the short-circuit skipped. So "masterPasswordHash": "x" is enough to pass the model layer.
//Why the bypass exists
Looking at the original intent helps. The Bitwarden web client (scim.component.ts) calls these endpoints with masterPasswordHash: "N/A":
"After reviewing this one, it looks like it was originally done intentionally, since the client code supports it by setting the masterPasswordHash to 'N/A'. However, that design appears to be incorrect."
> — Bitwarden triager, HackerOne #3627893So the SCIM key was *deliberately* exempt from re-auth. The reason is presumably UX: the SCIM settings page lets an admin view/rotate the SCIM key without prompting for a password every time. The trade-off is that the master-password gate — which is specifically there to make a stolen session insufficient for high-value secret extraction — was disabled on the one key type that grants the most persistent, out-of-band access.
The asymmetry is what makes this dangerous:
| Key type | Auth required (vuln) | Caller role required |
|---|---|---|
| Default (0) | Master password | Owner |
| BillingSync (1) | Master password | Owner |
| Scim (2) | Session only | Admin or Custom + ManageScim |
SCIM has both the *lowest* re-auth bar (none) and the *lowest* role requirement (Admin, plus a Custom-role flag). It's also the only key that gives an attacker user provisioning — create/delete users, manage groups — outside the Bitwarden session model. A SCIM key works as long as it isn't rotated, regardless of whether the user changes their master password or revokes the session.
//Differential confirmation on Cloud
Source review is one thing; let it tell you what to expect, then watch the server prove it. The critical signal is *which exception you get for a wrong password*.
Test 1 — type=0 (Default), MasterPasswordHash="wrong":
HTTP 400 {"validationErrors":{"MasterPasswordHash":["Invalid password."]}}
→ VerifySecretAsync was called and rejected the bad hash. Expected.
Test 2 — type=2 (Scim), MasterPasswordHash="wrong":
HTTP 500 (server error)
→ VerifySecretAsync was *not* reached. The handler walked past the
auth gate and tripped on a downstream call (no SCIM key record on
a free-plan test org).
A 500 is rarely the response you want, but in this case it is conclusive: the only way to reach the code that crashed is to skip the password check. On an Enterprise/Teams org with SCIM enabled, the same request returns 200 with the live SCIM key — same control flow, just with a row to read at the end.
I did not run the destructive test on a real Enterprise tenant; the bypass behavior was already proven by the differential.
//Attack flow
1. Attacker compromises a session token belonging to any user with
ManageScim permission (Admin role, or Custom role with ManageScim).
Vector is up to the engagement: XSS, malicious extension, cookie theft,
stale device with token in storage, etc.
2. Retrieve the SCIM key without the password:
POST /api/organizations/{ORG_ID}/api-key
{ "type": 2, "masterPasswordHash": "x" }
→ 200, SCIM API key returned.
3. (Optional) Lock the legitimate integration out by rotating the key:
POST /api/organizations/{ORG_ID}/rotate-api-key
{ "type": 2, "masterPasswordHash": "x" }
→ Old key invalid. Only the attacker holds the new one.
4. Use the SCIM key directly against the SCIM endpoints to provision
or delete users. The key is not bound to the original session and
survives password changes, MFA enrollment, and session revocation.
The interesting property is persistence. Master-password re-auth on key retrieval exists precisely so that "attacker holds a session" doesn't equal "attacker holds a long-lived org credential." The short-circuit collapsed those two states for SCIM keys.
//The fix
PR #7403, commit `eb251d9b`. Same change in both methods:
- if (model.Type != OrganizationApiKeyType.Scim
- && !await _userService.VerifySecretAsync(user, model.Secret))
+ if (!await _userService.VerifySecretAsync(user, model.Secret))
{
await Task.Delay(2000);
throw new BadRequestException("MasterPasswordHash", "Invalid password.");
}
- else
- {
- var response = new ApiKeyResponseModel(organizationApiKey);
- return response;
- }
+
+ var response = new ApiKeyResponseModel(organizationApiKey);
+ return response;
Two type-based exemptions removed, the dead else collapsed, and tests added in the same PR. The web client side changes accordingly — SCIM management has to prompt for the master password the same way the other key types already did.
//Disclosure timeline
All times UTC.
- ➜2026-03-25 — Report submitted to HackerOne (#3627893).
- ➜2026-03-27 — Severity adjusted (Scope: Unchanged; Integrity: High — SCIM key persistence + lockout via rotation). Bitwarden notes the bypass was *intentional* in the original design (web client passes
"N/A"as the password) and confirms the design is wrong. Triaged. - ➜2026-04-08 — Patch lands on
main: PR #7403, commit `eb251d9b`. - ➜2026-04-20 — Fix included in self-hosted release `v2026.4.1`.
- ➜2026-05-05/06 — Bitwarden Cloud deploys the remediation; report resolved 2026-05-06 with bounty.
- ➜2026-05-08 — Public writeup.
//Takeaways
- ➜Read security guards as expressions, not as English.
if (X != Special && verify())doesn't say "verify, with a special case." It says "for the special case, skip verification entirely." IfXis attacker-controlled, that's the bypass. - ➜A server-side `MasterPasswordHash: "N/A"` shortcut is not a UX choice — it's a re-auth removal. If the client wants to pass a non-secret because the user already authenticated recently, the right primitive is a short-lived elevated-session token, not a string the server is supposed to ignore.
- ➜**High-impact key types deserve *more* re-auth, not less.** SCIM keys are out-of-band, long-lived, and grant user provisioning. Of all the key types in this controller, this is the one you'd most want to require fresh password proof for. The vulnerable code did the opposite.
- ➜Differential testing > "I can't run the full PoC". When the destructive path requires a paid tier, find the cheapest server-observable difference between "auth check ran" and "auth check skipped." Here, a 400 vs 500 was enough.
Thanks again to @mandreko-bitwarden and the Bitwarden team for the quick triage and fix.