bitwarden/server · error · BadRequestException

User verification failed.

Error message

User verification failed.

What it means

BadRequestException with key 'UserVerificationToken' is thrown in PUT /authenticator (PutAuthenticator) when the user-verification token fails one of: TryUnprotect (tampered/corrupt), decryptedToken.Valid (expired signature), or decryptedToken.TokenIsValid(user, model.Key) (wrong user or key). This is a data-protection token bound to the requesting user and their key, minted by the get-authenticator endpoint.

Source

Thrown at src/Api/Auth/Controllers/TwoFactorController.cs:143

        var tokenable = new TwoFactorAuthenticatorUserVerificationTokenable(user, data.Key);
        var userVerificationToken = _twoFactorAuthenticatorDataProtector.Protect(tokenable);
        return new TwoFactorAuthenticatorResponseModel(data, userVerificationToken);
    }

    [HttpPut("authenticator")]
    public async Task<TwoFactorAuthenticatorUpdateResponseModel> PutAuthenticator(
        [FromBody] TwoFactorAuthenticatorUpdateRequestModel model)
    {
        var user = model.ToUser(await _userService.GetUserByPrincipalAsync(User));

        var tokenIsValid =
            _twoFactorAuthenticatorDataProtector.TryUnprotect(model.UserVerificationToken, out var decryptedToken)
            && decryptedToken.Valid
            && decryptedToken.TokenIsValid(user, model.Key);

        if (!tokenIsValid)
        {
            throw new BadRequestException("UserVerificationToken", "User verification failed.");
        }

        if (!await _userManager.VerifyTwoFactorTokenAsync(user,
                CoreHelpers.CustomProviderName(TwoFactorProviderType.Authenticator), model.Token))
        {
            throw new BadRequestException("Token", "Invalid token.");
        }

        await _userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.Authenticator);
        return new TwoFactorAuthenticatorUpdateResponseModel(user);
    }

    [HttpPost("authenticator")]
    [Obsolete("This endpoint is deprecated. Use PUT /authenticator instead.")]
    public async Task<TwoFactorAuthenticatorUpdateResponseModel> PostAuthenticator(
        [FromBody] TwoFactorAuthenticatorUpdateRequestModel model)
    {
        return await PutAuthenticator(model);

View on GitHub (pinned to e93b962371)

Solutions

  1. Re-call POST /two-factor/get-authenticator (with secret verification) to mint a fresh UserVerificationToken, then submit it immediately.
  2. Ensure the same authenticated user and master key are used for both minting and the PUT.
  3. Do not cache or reuse the token across logins; treat it as single-use and short-lived.
  4. Confirm the token string is not truncated/altered in transit (encoding).

Example fix

// before: reusing an old/cached token
api.put('/users/two-factor/authenticator', { userVerificationToken: oldToken, ... })
// after: mint fresh each flow
const { userVerificationToken } = await api.post('/users/two-factor/get-authenticator', { masterPasswordHash });
api.put('/users/two-factor/authenticator', { userVerificationToken, token, key });
Defensive patterns

Strategy: validation

Validate before calling

if (!model.userVerificationToken) { const r = await api.post('/users/two-factor/get-authenticator', { masterPasswordHash }); model.userVerificationToken = r.userVerificationToken; }

Type guard

function hasFreshVerificationToken(m, mintedAt): m is AuthenticatorUpdateModel & { userVerificationToken: string } {
  return typeof m.userVerificationToken === 'string' && Date.now() - mintedAt < 5 * 60 * 1000;
}

Try / catch

try { await api.put('/users/two-factor/authenticator', model); }
catch (e) {
  if (e.response?.status === 400 && e.response.data?.error?.errors?.UserVerificationToken) {
    model.userVerificationToken = (await api.post('/users/two-factor/get-authenticator', { masterPasswordHash })).userVerificationToken;
    return api.put('/users/two-factor/authenticator', model);
  }
  throw e;
}

Prevention

When it happens

Trigger: PUT /api/users/two-factor/authenticator (TwoFactorController line 143) submitted with a UserVerificationToken that is missing, expired, decrypted-but-invalid, or bound to a different user/key than the current principal.

Common situations: The client reused a token from a different session/user, the token expired before submission, the user changed their master key between minting and using the token, or the get-authenticator step (which issues the token) was skipped.

Related errors


AI-assisted analysis of bitwarden/server@e93b962371 (2026-08-13). Data as JSON: /api/errors/23dcc21395b07c05. Report an issue: GitHub.