Skip to content

Refresh tokens not invalidated on security stamp rotation

Moderate
dani-garcia published GHSA-6j4w-g4jh-xjfx Apr 25, 2026

Package

cargo Vaultwarden (Rust)

Affected versions

<= 1.35.4

Patched versions

1.35.5

Description

Summary

Refresh tokens are not invalidated when the user's security_stamp is rotated by some security-sensitive operations (password change, KDF change, key rotation, email change, org admin password reset, emergency access takeover). This allows an attacker holding a previously obtained refresh token to maintain session access even after the user has taken action to secure their account.

Details

How access tokens are validated

When a client makes an API request with an access token, Headers::from_request in src/auth.rs (line ~626) checks that the sstamp claim embedded in the JWT matches the user's current security_stamp in the database. If they don't match, the request is rejected (unless a temporary stamp exception applies). This correctly invalidates access tokens after stamp rotation.

How refresh tokens bypass the sstamp check

The refresh token flow (refresh_tokens in src/auth.rs, line ~1205) works as follows:

pub async fn refresh_tokens(
    ip: &ClientIp,
    refresh_token: &str,
    client_id: Option<String>,
    conn: &DbConn,
) -> ApiResult<(Device, AuthTokens)> {
    let refresh_claims = decode_refresh(refresh_token)?;
    let mut device = Device::find_by_refresh_token(&refresh_claims.device_token, conn).await
        .ok_or("Invalid refresh token")?;
    device.save(true, conn).await?;
    let user = User::find_by_uuid(&device.user_uuid, conn).await
        .ok_or("Impossible to find user")?;
    // No security stamp validation here
    let auth_tokens = AuthTokens::new(&device, &user, refresh_claims.sub, client_id);
    Ok((device, auth_tokens))
}

The RefreshJwtClaims struct does not contain a security_stamp field:

pub struct RefreshJwtClaims {
    pub nbf: i64,
    pub exp: i64,
    pub iss: String,
    pub sub: AuthMethod,
    pub device_token: String,
    pub token: Option<TokenWrapper>,
    // No sstamp / security_stamp field
}

Since the refresh flow only looks up the Device by device_token and never checks the security stamp, any refresh token issued before a stamp rotation remains valid as long as the Device record exists in the database.

Comparison with upstream Bitwarden

The upstream Bitwarden server (bitwarden/server) persists the SecurityStamp claim into the grant when issuing a refresh token. On every refresh, ProfileService.IsActiveAsync validates this:

// From bitwarden/server src/Identity/IdentityServer/ProfileService.cs
public async Task IsActiveAsync(IsActiveContext context)
{
    var securityTokenClaim = context.Subject?.Claims
        .FirstOrDefault(c => c.Type == Claims.SecurityStamp);
    var user = await _userService.GetUserByPrincipalAsync(context.Subject);

    if (user != null && securityTokenClaim != null)
    {
        context.IsActive = string.Equals(
            user.SecurityStamp,
            securityTokenClaim.Value,
            StringComparison.InvariantCultureIgnoreCase);
        return;
    }
    else
    {
        context.IsActive = true;
    }
}

When the stamps don't match, IsActive is set to false, which causes IdentityServer to reject the refresh request. This means that in upstream Bitwarden, any security stamp rotation automatically invalidates all outstanding refresh tokens.

Affected operations

The following operations reset the security_stamp (via reset_security_stamp() or set_password(..., true, ...)) but do not delete Device records or rotate the refresh_token of each device:

Operation Code location Deletes Devices? Sends Logout?
Password change post_password in accounts.rs No Yes
KDF change post_kdf in accounts.rs No Yes
Key rotation post_rotatekey in accounts.rs No Yes
Email change post_email in accounts.rs No Yes
Org admin password reset put_reset_password in organizations.rs No Yes
Emergency access takeover password_emergency_access in emergency_access.rs No No

Note: Three operations (post_sstamp, deauth_user, disable_user) correctly call Device::delete_all_by_user in addition to stamp rotation, which effectively invalidates refresh tokens by removing the Device records. These are not affected.

PoC

Prerequisites

  • A Vaultwarden instance (tested on v1.35.4)
  • A user account with at least one active device/session
  • The refresh token from that device (obtainable from the login response or client storage)

Steps

  1. Obtain a refresh token: Log in as the target user. The login response includes a refresh_token. Save it.

    # Login and capture tokens
    curl -s -X POST "$VW_URL/identity/connect/token" \
      -H "Content-Type: application/x-www-form-urlencoded" \
      -d "grant_type=password&username=$EMAIL&password=$MASTER_HASH&scope=api+offline_access&client_id=web&deviceIdentifier=$DEVICE_ID&deviceType=7&deviceName=Test" \
      | jq -r '.refresh_token' > refresh_token.txt
  2. Change the user's password (simulating the user securing their account):

    curl -s -X POST "$VW_URL/api/accounts/password" \
      -H "Authorization: Bearer $ACCESS_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"masterPasswordHash":"old_hash","newMasterPasswordHash":"new_hash","masterPasswordHint":"","key":"new_key"}'
  3. Verify that the old access token is now invalid:

    curl -s "$VW_URL/api/accounts/profile" \
      -H "Authorization: Bearer $ACCESS_TOKEN"
    # Expected: 401 Unauthorized or "Invalid security stamp"
  4. Use the old refresh token to obtain a new valid access token:

    curl -s -X POST "$VW_URL/identity/connect/token" \
      -H "Content-Type: application/x-www-form-urlencoded" \
      -d "grant_type=refresh_token&refresh_token=$(cat refresh_token.txt)&client_id=web" \
      | jq '.access_token'
    # Expected (current behavior): Returns a new valid access token
    # Expected (correct behavior): Should return an error
  5. Confirm continued access with the new token:

    curl -s "$VW_URL/api/accounts/profile" \
      -H "Authorization: Bearer $NEW_ACCESS_TOKEN"
    # Returns user profile — session was not revoked

Impact

An attacker who has obtained a user's refresh token (e.g., through device theft, malware, or session hijacking) can maintain persistent access to the user's vault even after the user has:

  • Changed their master password
  • Changed their KDF settings
  • Rotated their encryption keys
  • Changed their email address
  • Had their password reset by an organization admin

The attacker can refresh their session and continue to read and modify vault entries, unless the user explicitly uses the "Deauthorize Sessions" feature (which does delete devices) or has an admin deauth their account.

The emergency access takeover scenario is particularly concerning: the takeover does not send any logout notification, so even well-behaved clients of the original account owner might silently refresh and maintain their sessions.

Suggested Fix

Option A — Add sstamp to RefreshJwtClaims and validate it in refresh_tokens:

pub struct RefreshJwtClaims {
    // ... existing fields ...
    pub sstamp: String, // security stamp at time of issuance
}

// In refresh_tokens():
if user.security_stamp != refresh_claims.sstamp {
    err!("Invalid token")
}

This approach is more aligned with the upstream approach, but it structurally changes the refresh token JWT. All refresh tokens issued before the update would lack the sstamp field and fail deserialization (or require a fallback path). In practice, this means all existing users would be force-logged-out upon upgrading Vaultwarden.

Option B — Rotate device refresh_token on security stamp reset:

When security_stamp is reset, also regenerate the refresh_token field on all Device records for that user if Device::delete_all_by_user() is not called. Since refresh_tokens() locates the device via Device::find_by_refresh_token, the old refresh token would no longer match any device record and would be effectively invalidated.

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 v3 base metrics

Attack vector
Network
Attack complexity
High
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N

CVE ID

CVE-2026-43911

Weaknesses

Insufficient Session Expiration

According to WASC, Insufficient Session Expiration is when a web site permits an attacker to reuse old session credentials or session IDs for authorization. Learn more on MITRE.

Credits