# Bitwarden: A Low-Privilege Member Could Take Over Another Member's Vault (POST /auth-requests/admin-request)
Bitwarden: Full Vault Takeover via the Admin Auth-Request Endpoint
The server looked up the victim by the email in my request body — then never checked that email was mine.
The latest writeup in my Bitwarden series, and my favorite of the set: a Broken Object-Level Authorization (BOLA) on the Trusted-Device-Encryption auth-request endpoint that lets a lowest-privilege org member walk away with another member's full vault key — and, via a second path, a fully authenticated session as that member. No victim password, no victim API key, no victim secret at any point.
- ➜Vendor: Bitwarden
- ➜Product:
bitwarden/server - ➜Severity: High (CVSS 8.7)
- ➜CVE: CVE-2026-60104
- ➜Report: HackerOne #3676230
- ➜Fix: PR #7615 — commit `dcf4c486` (PM-35401)
- ➜Fixed in: server release `v2026.6.0` (2026-06-10)
//TL;DR
POST /auth-requests/admin-request creates a Trusted-Device-Encryption auth request. It resolves the target user from a caller-supplied email field, but never checks that email belongs to the authenticated caller. So a Custom member with nothing but "create collection" can create a device-trust request carrying the victim's email and the attacker's own RSA public key.
After a routine Owner/Admin approval, the victim's vault key — now re-encrypted to the attacker's public key — is handed back from a completely unauthenticated endpoint. The attacker RSA-decrypts it locally and holds the victim's 64-byte vault key.
That was the original report — and on its own, a leaked key is not yet a compromise. You still need the victim's encrypted data, and an attacker's own token only ever reads the attacker's own vault. The second path below closes exactly that gap, turning the key leak into a full account takeover.
Preconditions. Attacker and victim share an org with SSO + Trusted Device Encryption, and the attacker knows one of the victim's device ids (leaked passively via SignalR — my report #3671056; the server's known-device check needs a real device id). Path A additionally needs the victim enrolled in Account Recovery and an Owner/Admin to approve; Path B instead needs the victim to approve a login prompt on that device.
//The vulnerable code
AuthRequestService.CreateAuthRequestAsync resolves the target user from the request body's email, then seals the auth request to inputs the caller fully controls — without ever checking that the caller behind the bearer token is that user:
// Target user comes from the ATTACKER-supplied email in the body.
var user = await _userRepository.GetByEmailAsync(model.Email);
// ...nothing here compares user.Id against the authenticated _currentContext.UserId...
var authRequest = new AuthRequest
{
PublicKey = model.PublicKey, // ATTACKER's RSA key — the vault key is sealed to this
UserId = user.Id, // VICTIM — chosen purely by the attacker's email
AccessCode = model.AccessCode, // ATTACKER's code — later replayed as the login password
Type = model.Type.GetValueOrDefault(),
// ...RequestDeviceIdentifier, OrganizationId, etc.
};
The bearer token identifies the attacker; model.Email picks the victim; model.PublicKey is the attacker's. Three inputs that should collapse to one identity, and nothing asserts user.Id == _currentContext.UserId. (For an AdminApproval request the method also enumerates the caller's own orgs — via GetManyByUserAsync(_currentContext.UserId) — to notify their admins, but that never re-anchors the request back to the caller either.)
//Path A — the key leak (the original report)
- 1.Attacker authenticates with their own lowest-privilege Custom credentials.
- 2.Attacker generates a throwaway RSA-2048 keypair.
- 3.Attacker
POSTs anadmin-requestwith the victim's email and their own public key:
POST /auth-requests/admin-request HTTP/2
Authorization: Bearer <ATTACKER token>
Content-Type: application/json
{ "email": "[email protected]", "publicKey": "<ATTACKER pubkey>",
"deviceIdentifier": "<victim device id>", "accessCode": "<ATTACKER code>", "type": 2 }
- 4.In the org's Device approvals panel the request shows only [email protected] — indistinguishable from a real one. An Owner/Admin approves. The approval machinery decrypts the victim's user key (through the Account-Recovery reset-password-key chain) and re-encrypts it to the public key on the request — the attacker's.
- 5.Attacker retrieves the result with no Authorization header at all:
GET /auth-requests/<id>/response?code=<ATTACKER code> HTTP/2
{ "requestApproved": true, "key": "4.<victim key sealed to attacker pubkey>",
"publicKey": "<ATTACKER pubkey>" }
- 6.Attacker RSA-OAEP-decrypts
keywith their private key → the victim's raw 64-byte vault key.
The human approver is the launderer here: they see a legitimate victim email and never see that the key is being sealed to a stranger's public key.
//Path B — the missing link (full session, only attacker creds)
There's a fair objection to Path A on its own: an attacker authenticated as themselves only pulls their own /sync, so does holding the victim's key actually get you their data?
Yes — because the same endpoint never pinned the request type. It happily accepted type = 0 (AuthenticateAndUnlock) through the identical BOLA. And an approved AuthenticateAndUnlock request is exactly what the identity server will trade for an access token:
// ResourceOwnerPasswordValidator — auth-request grant path
if (authRequest.IsValidForAuthentication(user.Id, context.Password))
{
validatorContext.ValidatedAuthRequest = authRequest;
return true; // master-password check SKIPPED for this grant
}
IsValidForAuthentication only wants Approved == true, the right type, a matching UserId, and AccessCode == password — and the attacker chose the access code in step 3. The device check passes because the attacker already has the victim's device id (a passive SignalR ContextId leak, my related report #3671056). So the token request carries the victim's own device id:
POST /identity/connect/token
grant_type=password&[email protected]&password=<ATTACKER code>
&AuthRequest=<id>&deviceIdentifier=<victim device id>&scope=api offline_access
returns a victim-scoped access token. Now GET /sync hands over the victim's full ciphertext, and the key from Path A decrypts it — personal items, plus every org key in profile.organizations[], so all ciphers across every org the victim belongs to.
▶The full chain — note the credential column
| Step | Actor | Action | Credential used |
|---|---|---|---|
| 1 | Attacker | Authenticate (Custom role) | Attacker API key |
| 2 | Attacker | Capture victim device id via SignalR | Attacker token |
| 3 | Attacker | Generate RSA-2048 keypair | none (local) |
| 4 | Attacker | admin-request, type=0, victim's email | Attacker token (BOLA) |
| 5 | Victim | Approves the login push on their own device | Victim device (UI) |
| 6 | Attacker | GET /auth-requests/{id}/response?code=… | none (anonymous) |
| 7 | Attacker | RSA-decrypt → victim's 64-byte vault key | Attacker RSA key |
| 8 | Attacker | POST /connect/token with AuthRequest param | Attacker-chosen access code |
| 9 | Attacker | GET /sync → decrypt entire vault | Victim-scoped token from step 8 |
No victim password, API key, or other secret is ever used. The org owner isn't involved in Path B at all.
//The fix
PR #7615, commit `dcf4c486`. Two guards, one per path.
Pin the request type on the admin endpoint (kills Path B's token trade):
[HttpPost("admin-request")]
public async Task<AuthRequestResponseModel> PostAdminRequest([FromBody] AuthRequestCreateRequestModel model)
{
+ if (model.Type != AuthRequestType.AdminApproval)
+ {
+ throw new BadRequestException("Invalid AuthRequestType. Expected AdminApproval.");
+ }
var authRequest = await _authRequestService.CreateAuthRequestAsync(model);
And assert the target matches the caller in the service (kills the core BOLA — essentially the check I suggested, down to the same error string):
var user = await _userRepository.GetByEmailAsync(model.Email);
// ...
+ // Ensure authenticated user id matches target user (allows anon scenarios still)
+ if (_currentContext.UserId.HasValue && user!.Id != _currentContext.UserId.Value)
+ {
+ throw new BadRequestException("User or known device not found.");
+ }
The UserId.HasValue gate keeps the legitimate anonymous self-service auth-request flow working (no token → nothing to compare) while forcing any authenticated caller to act only on their own account. Tests for both guards landed in the same PR.
//Disclosure timeline
All times UTC.
- 1.2026-04-15 — Reported to HackerOne (#3676230): BOLA + attacker-pubkey key exfiltration via the anonymous response endpoint.
- 2.2026-04-16 — Bitwarden confirms the BOLA and the unauthenticated key retrieval; asks how the attacker reaches the victim's ciphertext with only their own token.
- 3.2026-04-17 — I demonstrate Path B: the endpoint didn't enforce the request type, so an approved
type=0request can be exchanged at/connect/tokenfor a victim-scoped session — full vault, zero victim credentials. - 4.2026-04-20 — Triaged and routed to engineering.
- 5.2026-05-21 — Fix merged to
main(PR #7615, commitdcf4c486). - 6.2026-06-10 — Shipped in server release
v2026.6.0. - 7.2026-06-26 — Resolved and bounty awarded.
- 8.2026-07-08 — Public writeup.
- 9.2026-07-09 — CVE-2026-60104 assigned by VulnCheck (CNA).
//Takeaways
- ➜Two identities in one handler is a BOLA waiting to happen. One comes from the token, one from the body. If the code never asserts they're equal, an attacker will make them disagree. The one-line fix —
user.Id == currentContext.UserId— is the check that should exist the moment a request body carries an identifier the caller could have chosen. - ➜An endpoint that doesn't pin the request type inherits every other type's powers.
admin-requestwas meant forAdminApproval, but it silently acceptedAuthenticateAndUnlock— a different, far more dangerous state machine that ends in a mintable session token. - ➜A stolen key is only half of a compromise — you also need the ciphertext. An encryption key and the encrypted data it unlocks usually sit behind different access checks, so leaking one is not automatically leaking the other. This became a full takeover only because a second flaw — the unpinned request type — also yielded a victim-scoped session to fetch that data. Always chase the key all the way to the plaintext; on its own it may prove nothing.
- ➜Approvals launder attacker input. A human clicking "Approve" re-encrypts to whatever public key rides along on the request. They see the victim's email and trust it; they never see the swapped key.
Thanks to @mandreko-bitwarden and the Bitwarden security team for the sharp triage and the fix.