Summary
Bitwarden Sends can be gated so that only a recipient of an emailed 6-digit one-time code may open the shared link. On self-hosted installations that code is the only barrier between a link holder and the Send's plaintext content — and the redemption path applies no attempt counter, no per-attempt delay, and, because the generic IP rate limiter is only registered for cloud deployments, no rate limiting at all. The 5-minute code window can additionally be renewed on demand by the attacker. A holder of a leaked link can therefore exhaust the 1,000,000-code keyspace online in minutes and decrypt the Send with the key carried in the link itself.
Affected Products
- Bitwarden Server, self-hosted deployments (official Docker images and source builds): affected. Verified against
ghcr.io/bitwarden/lite:2026.9.0and source HEAD8b5fea2e0(2026-09-21); all releases since thesend_accessemail-OTP gate was introduced (2024–2025) are presumed affected. - Bitwarden Cloud (bitwarden.com SaaS): no permission to attack, but rate-limits assumed.
- No fixed release exists as of 2026-11-06: the vendor has not committed to a change (see Technical Analysis). Self-hosted operators should apply the operator mitigations listed under Remediation.
Affected Scope
- Product
- Bitwarden Server — self-hosted deployments.
- Component
- Identity service — anonymous `send_access` extension grant; email-OTP access gate for Sends.
- Affected function
- `SendEmailOtpRequestValidator.ValidateAsync` (OTP redemption path of the `send_access` grant); supporting: `OtpTokenProvider.ValidateTokenAsync` (code generation, validation, cache deletion) and the Identity `Startup` rate-limiter registration.
- Affected versions
- Verified against `ghcr.io/bitwarden/lite:2026.9.0` and source HEAD `8b5fea2e0` (2026-09-21). All releases since the `send_access` email-OTP gate was introduced (2024–2025) are presumed affected. No fixed release exists as of 2026-11-06.
- Safe version
- None — unpatched upstream as of publication. Bitwarden Cloud (SaaS) behaves as practically mitigated by its per-IP rate limiter (~50 guesses per 5-minute window), but this is a deployment property, not a code fix.
- CWE
- CWE-307: Improper Restriction of Excessive Authentication Attempts (primary); CWE-645 (secondary, per the internal audit's classification).
Technical Summary
Bitwarden Sends can be gated so that only a recipient of an emailed 6-digit one-time code may open the shared link. On self-hosted installations that code is the only barrier between a link holder and the Send's plaintext content — and the redemption path applies no attempt counter, no per-attempt delay, and, because the generic IP rate limiter is only registered for cloud deployments, no rate limiting at all. The 5-minute code window can additionally be renewed on demand by the attacker. A holder of a leaked link can therefore exhaust the 1,000,000-code keyspace online in minutes and decrypt the Send with the key carried in the link itself.
Prerequisites and Permissions
No account, authentication, mailbox access, or user interaction is required. The attacker needs exactly three things: (1) the Send's access URL, which yields send_id and the sendKey decryption key — the link is normally distributed as a bearer secret; (2) one email address on the Send's recipient allow list, such as the attacker's own address as a former recipient; (3) network reachability to the self-hosted instance. Enabling email-OTP gating is a sender-side premium feature; the attacker plays no part in that configuration.
Affected Locations
src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs:56-92—ValidateAsync: immediateBuildErrorResult()on a wrong OTP (no delay, no counter, no lockout); lines 68-86 regenerate and re-email the OTP when the request omitsotp, resetting the 5-minute window.src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/OtpTokenProvider.cs:49-74— 6 numeric digits, 5-minute TTL, constant-time comparison, cache entry deleted only on success; failures never recorded.src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/OtpTokenProviderOptions.cs:14,26—TokenLength = 6,TokenNumeric = true.src/Identity/Startup.cs:190-195—CustomIpRateLimitMiddlewareregistered onlyif (!globalSettings.SelfHosted); self-hosted installs have no limiter.- Request parameters:
POST /identity/connect/tokenform fieldsgrant_type=send_access,client_id=send,scope=api.send.access,send_id(base64url GUID from the link),email(allow-listed recipient),otp(6-digit guess).
Reproducible Proof
Run only against a Bitwarden instance you operate, in an isolated lab, at modest request rates, and stop at the first accepted code.
- From the Send link
…/#send/{accessId}/{sendKey}, note both fragments. - Trigger code generation —
POST /identity/connect/token, form-encoded, withoutotp:
`` curl -X POST https://SELFHOST/identity/connect/token \ -d "grant_type=send_access&client_id=send&scope=api.send.access&send_id=ACCESSID&email=RECIPIENT" ``
The response is a 400 of type email_and_otp_required, and the recipient receives the emailed code.
- Redeem guesses by repeating the request with
&otp=NNNNNNadded. Wrong guesses return400immediately — no delay, no failure counter, no429on a stock self-hosted install. On200, the body carries theaccess_token. - Fetch the Send with that token —
POST /api/sends/access/withAuthorization: Bearer …— and decrypt the returned type-2 EncString (2.{iv}|{ct}|{mac}, AES-256-CBC + HMAC-SHA256) with the 64-bytesendKeyfrom the link fragment.
Measured on a stock, unmodified ghcr.io/bitwarden/lite:2026.9.0 instance: a sweep reached the correct code (601006) after 603,684 guesses in 234 seconds (≈2,580 guesses/s, zero 429 responses, one OTP window), retrieved the Send (HTTP 200), and decrypted it to a known plaintext marker — full chain confirmed. At that rate the full 10⁶ keyspace takes ≈6.5 minutes; because a request without otp regenerates the code and resets the 5-minute window, the sweep is not deadline-bound even at lower rates.
Vulnerable Code
class="tok-comment">// src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs
var otpResult = await otpTokenProvider.ValidateTokenAsync(
requestOtp, SendAccessConstants.OtpToken.TokenProviderName,
SendAccessConstants.OtpToken.Purpose, uniqueIdentifierForTokenCache);
if (!otpResult)
{
return BuildErrorResult(); class="tok-comment">// no delay, no counter, no lockout
}
class="tok-comment">// src/Identity/Startup.cs:190-195 — the only generic throttle is not installed on self-host:
if (!globalSettings.SelfHosted)
{
class="tok-comment">// Rate limiting
app.UseMiddleware<CustomIpRateLimitMiddleware>();
}Severity
high
CVSS
7.5
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
EPSS
0.00
Technical Analysis
A Bitwarden Send is an encrypted text or file blob shared via https://{vault}/#/send/{accessId}/{sendKey}. The sendKey URL fragment is the decryption key (64 bytes: AES-256 key + HMAC key), so anyone holding the link can decrypt the content once they can fetch it. For Sends with AuthType = Email, the server additionally requires an emailed 6-digit OTP before issuing an anonymous send_access token: a token request without otp generates and emails the code; the same request with otp returns the access token; the token then retrieves the ciphertext from POST /api/sends/access/. The OTP exists precisely for the threat model of a leaked link — and that is the adversary who defeats it.
Three controls that normally prevent online OTP guessing are all absent on this path. First, no attempt counter: the OTP validator deletes the cached code only on success and never records failures (OtpTokenProvider). Second, no per-attempt delay: every login 2FA failure in the same service pays Task.Delay(2000) plus a notification email (BaseRequestValidator.cs:504,536), and a failure counter was even scaffolded (user.FailedLoginCount) but is never read for lockout anywhere — dead code; the Send OTP validator returns an error immediately instead. Third, no rate limiting: CustomIpRateLimitMiddleware is registered only when !globalSettings.SelfHosted (src/Identity/Startup.cs:190-195), so a stock self-hosted install has no limiter at all. On top of this, the 5-minute validity window is renewable at will: any token request without otp regenerates the code, re-emails it, and resets the TTL, so the sweep faces no deadline even at modest rates.
We confirmed the full chain live against a stock, unmodified ghcr.io/bitwarden/lite:2026.9.0 instance: a sweep reached the correct code (601006) after 603,684 guesses in 234 seconds — ≈2,580 guesses/s sustained from a single machine, zero HTTP 429 responses, unthrottled latency, within a single OTP window. The send_access token retrieved the Send (HTTP 200) and it decrypted correctly with the sendKey from the link. At that rate the entire 10⁶ keyspace takes ≈6.5 minutes. The scope is one Send: the token is an anonymous grant bound to exactly one send_id and carries no user or vault access (Bitwarden is zero-knowledge; no key material transits this flow). Content disclosure is conditional on holding the link, since only the sendKey fragment decrypts it — in the leaked-link threat model the disclosure is total.
Disclosure outcome: the finding was reported to Bitwarden through the designated security channel on 24th of September, 2026. Bitwarden's response required the reporter to submit the report through Bitwarden's HackerOne program. Bitwarden's HackerOne response policy lists "rate limiting or brute-force issues" among its out-of-scope bug classes, so a submission would be closed as out of scope and the reporter chose not to file a report destined for that outcome. As of 2026-11-06 none of the missing controls has changed upstream, no patch or vendor advisory exists, and the realistic outlook is that this issue will not be addressed; this advisory is published for the self-hosted operators who own the residual risk.
Impact
- Confidentiality: High on self-hosted deployments. An anonymous attacker holding a Send's link and one allow-listed recipient email address can exhaustively guess the OTP gate and fully recover the Send's content (text and file attachments). End-to-end decryption was demonstrated live.
- Integrity: None. The
send_accessgrant is anonymous and scoped to exactly one Send; it is rejected by every user-authenticated API surface. No write paths, no account takeover, no vault exposure; Bitwarden's zero-knowledge key material never transits this flow. - Availability: None. The attack does not degrade service or lock out any user. The only side effect is OTP notification emails to the recipient each time the attacker renews the window — a nuisance, not a denial of service.
Remediation
Vendor-side (recommended upstream, but not expected to ship — see Technical Analysis): a per-(sendId, email) failure counter with lockout after N failures; a constant failure delay mirroring the login-2FA Task.Delay(2000); registration of the rate limiter on self-hosted deployments (remove the !SelfHosted gate in src/Identity/Startup.cs); caps on OTP regeneration per (Send, email, IP); telemetry and alerting on send_access failure bursts per send_id.
Operator mitigations to apply now (no server patching required):
- Reverse-proxy rate limit on the token endpoint, e.g. nginx
limit_req_zone $binary_remote_addr zone=bw_token:10m rate=10r/m;withlimit_req zone=bw_token burst=20 nodelay;onlocation = /identity/connect/token. At 10 req/min an attacker gets ≤50 guesses per 5-minute window (≈0.005% success), i.e. cloud-equivalent posture. Do not log request bodies (they contain master-password material). - fail2ban on the proxy access log: match
POST /identity/connect/token, ban after ~150 matches in 300 s for 1 hour. This also throttles login brute force. - Optional third layer: any WAF/CDN per-IP rate rule (Cloudflare free-tier custom rate rule, Traefik
rateLimit, Caddyrate_limit, AWS WAF rate-based rule) on the same path. - Usage hygiene: treat the link fragment as the real secret and never distribute an email-gated Send's link over a channel you would not trust with the plaintext; prefer password-protected Sends with high-entropy passwords; set short expiration dates and tight max access counts; delete Sends when done.
- Detection signature: bursts of
POST /identity/connect/tokenwithgrant_type=send_accessandsend_access_error_type=email_and_otp_requiredresponses from one source (or distributed sources against onesend_id); each such response with anotppresent is a failed guess.
Vendor advisory: none. Bitwarden directed the report to its HackerOne program, where "rate limiting or brute-force issues" are out of scope; no vendor advisory will be issued.
Timeline
- Request for PGP or S/MIME key or another secure channel
- Asks for submission at HackerOne
- Stating that the vulnerability is out-of-scope at HackerOnes bug bounty program
- Follow-up requesting a secure channel again and warning that we will publish the vulnerability according to our RVDP otherwise
- Published
- Advisory published
References
- Bitwarden HackerOne response policy — "rate limiting or brute-force issues" listed as out of scope
- bitwarden/server — SendAccess request validators
Credits
Reported by David Haintz, Haikhavan.