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
-
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
-
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"}'
-
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"
-
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
-
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.
Summary
Refresh tokens are not invalidated when the user's
security_stampis 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_requestinsrc/auth.rs(line ~626) checks that thesstampclaim embedded in the JWT matches the user's currentsecurity_stampin 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_tokensinsrc/auth.rs, line ~1205) works as follows:The
RefreshJwtClaimsstruct does not contain asecurity_stampfield:Since the refresh flow only looks up the Device by
device_tokenand 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 theSecurityStampclaim into the grant when issuing a refresh token. On every refresh,ProfileService.IsActiveAsyncvalidates this:When the stamps don't match,
IsActiveis set tofalse, 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(viareset_security_stamp()orset_password(..., true, ...)) but do not delete Device records or rotate therefresh_tokenof each device:post_passwordinaccounts.rspost_kdfinaccounts.rspost_rotatekeyinaccounts.rspost_emailinaccounts.rsput_reset_passwordinorganizations.rspassword_emergency_accessinemergency_access.rsNote: Three operations (
post_sstamp,deauth_user,disable_user) correctly callDevice::delete_all_by_userin addition to stamp rotation, which effectively invalidates refresh tokens by removing the Device records. These are not affected.PoC
Prerequisites
v1.35.4)Steps
Obtain a refresh token: Log in as the target user. The login response includes a
refresh_token. Save it.Change the user's password (simulating the user securing their account):
Verify that the old access token is now invalid:
Use the old refresh token to obtain a new valid access token:
Confirm continued access with the new token:
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:
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
sstamptoRefreshJwtClaimsand validate it inrefresh_tokens: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
sstampfield 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_tokenon security stamp reset:When
security_stampis reset, also regenerate therefresh_tokenfield on all Device records for that user ifDevice::delete_all_by_user()is not called. Sincerefresh_tokens()locates the device viaDevice::find_by_refresh_token, the old refresh token would no longer match any device record and would be effectively invalidated.