diff --git a/dataprovider/admin.go b/dataprovider/admin.go
index b116e59c..56fbaf26 100644
--- a/dataprovider/admin.go
+++ b/dataprovider/admin.go
@@ -86,36 +86,36 @@ func (a *Admin) checkPassword() error {
 
 func (a *Admin) validate() error {
 	if a.Username == "" {
-		return &ValidationError{err: "username is mandatory"}
+		return utils.NewValidationError("username is mandatory")
 	}
 	if a.Password == "" {
-		return &ValidationError{err: "please set a password"}
+		return utils.NewValidationError("please set a password")
 	}
 	if !config.SkipNaturalKeysValidation && !usernameRegex.MatchString(a.Username) {
-		return &ValidationError{err: fmt.Sprintf("username %#v is not valid, the following characters are allowed: a-zA-Z0-9-_.~", a.Username)}
+		return utils.NewValidationError(fmt.Sprintf("username %#v is not valid, the following characters are allowed: a-zA-Z0-9-_.~", a.Username))
 	}
 	if err := a.checkPassword(); err != nil {
 		return err
 	}
 	a.Permissions = utils.RemoveDuplicates(a.Permissions)
 	if len(a.Permissions) == 0 {
-		return &ValidationError{err: "please grant some permissions to this admin"}
+		return utils.NewValidationError("please grant some permissions to this admin")
 	}
 	if utils.IsStringInSlice(PermAdminAny, a.Permissions) {
 		a.Permissions = []string{PermAdminAny}
 	}
 	for _, perm := range a.Permissions {
 		if !utils.IsStringInSlice(perm, validAdminPerms) {
-			return &ValidationError{err: fmt.Sprintf("invalid permission: %#v", perm)}
+			return utils.NewValidationError(fmt.Sprintf("invalid permission: %#v", perm))
 		}
 	}
 	if a.Email != "" && !emailRegex.MatchString(a.Email) {
-		return &ValidationError{err: fmt.Sprintf("email %#v is not valid", a.Email)}
+		return utils.NewValidationError(fmt.Sprintf("email %#v is not valid", a.Email))
 	}
 	for _, IPMask := range a.Filters.AllowList {
 		_, _, err := net.ParseCIDR(IPMask)
 		if err != nil {
-			return &ValidationError{err: fmt.Sprintf("could not parse allow list entry %#v : %v", IPMask, err)}
+			return utils.NewValidationError(fmt.Sprintf("could not parse allow list entry %#v : %v", IPMask, err))
 		}
 	}
 
diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go
index ab67af69..f7ba7917 100644
--- a/dataprovider/dataprovider.go
+++ b/dataprovider/dataprovider.go
@@ -366,23 +366,6 @@ type checkPasswordResponse struct {
 	ToVerify string `json:"to_verify"`
 }
 
-// ValidationError raised if input data is not valid
-type ValidationError struct {
-	err string
-}
-
-// Validation error details
-func (e *ValidationError) Error() string {
-	return fmt.Sprintf("Validation error: %s", e.err)
-}
-
-// NewValidationError returns a validation errors
-func NewValidationError(error string) *ValidationError {
-	return &ValidationError{
-		err: error,
-	}
-}
-
 // MethodDisabledError raised if a method is disabled in config file.
 // For example, if user management is disabled, this error is raised
 // every time a user operation is done using the REST API
@@ -453,11 +436,6 @@ type Provider interface {
 	revertDatabase(targetVersion int) error
 }
 
-type fsValidatorHelper interface {
-	GetGCSCredentialsFilePath() string
-	GetEncrytionAdditionalData() string
-}
-
 // SetTempPath sets the path for temporary files
 func SetTempPath(fsPath string) {
 	tempPath = fsPath
@@ -1164,14 +1142,14 @@ func isMappedDirOverlapped(dir1, dir2 string, fullCheck bool) bool {
 
 func validateFolderQuotaLimits(folder vfs.VirtualFolder) error {
 	if folder.QuotaSize < -1 {
-		return &ValidationError{err: fmt.Sprintf("invalid quota_size: %v folder path %#v", folder.QuotaSize, folder.MappedPath)}
+		return utils.NewValidationError(fmt.Sprintf("invalid quota_size: %v folder path %#v", folder.QuotaSize, folder.MappedPath))
 	}
 	if folder.QuotaFiles < -1 {
-		return &ValidationError{err: fmt.Sprintf("invalid quota_file: %v folder path %#v", folder.QuotaFiles, folder.MappedPath)}
+		return utils.NewValidationError(fmt.Sprintf("invalid quota_file: %v folder path %#v", folder.QuotaFiles, folder.MappedPath))
 	}
 	if (folder.QuotaSize == -1 && folder.QuotaFiles != -1) || (folder.QuotaFiles == -1 && folder.QuotaSize != -1) {
-		return &ValidationError{err: fmt.Sprintf("virtual folder quota_size and quota_files must be both -1 or >= 0, quota_size: %v quota_files: %v",
-			folder.QuotaFiles, folder.QuotaSize)}
+		return utils.NewValidationError(fmt.Sprintf("virtual folder quota_size and quota_files must be both -1 or >= 0, quota_size: %v quota_files: %v",
+			folder.QuotaFiles, folder.QuotaSize))
 	}
 	return nil
 }
@@ -1207,7 +1185,7 @@ func validateUserVirtualFolders(user *User) error {
 	for _, v := range user.VirtualFolders {
 		cleanedVPath := filepath.ToSlash(path.Clean(v.VirtualPath))
 		if !path.IsAbs(cleanedVPath) || cleanedVPath == "/" {
-			return &ValidationError{err: fmt.Sprintf("invalid virtual folder %#v", v.VirtualPath)}
+			return utils.NewValidationError(fmt.Sprintf("invalid virtual folder %#v", v.VirtualPath))
 		}
 		if err := validateFolderQuotaLimits(v); err != nil {
 			return err
@@ -1219,21 +1197,21 @@ func validateUserVirtualFolders(user *User) error {
 		cleanedMPath := folder.MappedPath
 		if folder.IsLocalOrLocalCrypted() {
 			if isMappedDirOverlapped(cleanedMPath, user.GetHomeDir(), true) {
-				return &ValidationError{err: fmt.Sprintf("invalid mapped folder %#v cannot be inside or contain the user home dir %#v",
-					folder.MappedPath, user.GetHomeDir())}
+				return utils.NewValidationError(fmt.Sprintf("invalid mapped folder %#v cannot be inside or contain the user home dir %#v",
+					folder.MappedPath, user.GetHomeDir()))
 			}
 			for mPath := range mappedPaths {
 				if folder.IsLocalOrLocalCrypted() && isMappedDirOverlapped(mPath, cleanedMPath, false) {
-					return &ValidationError{err: fmt.Sprintf("invalid mapped folder %#v overlaps with mapped folder %#v",
-						v.MappedPath, mPath)}
+					return utils.NewValidationError(fmt.Sprintf("invalid mapped folder %#v overlaps with mapped folder %#v",
+						v.MappedPath, mPath))
 				}
 			}
 			mappedPaths[cleanedMPath] = true
 		}
 		for vPath := range virtualPaths {
 			if isVirtualDirOverlapped(vPath, cleanedVPath, false) {
-				return &ValidationError{err: fmt.Sprintf("invalid virtual folder %#v overlaps with virtual folder %#v",
-					v.VirtualPath, vPath)}
+				return utils.NewValidationError(fmt.Sprintf("invalid virtual folder %#v overlaps with virtual folder %#v",
+					v.VirtualPath, vPath))
 			}
 		}
 		virtualPaths[cleanedVPath] = true
@@ -1250,22 +1228,22 @@ func validateUserVirtualFolders(user *User) error {
 
 func validatePermissions(user *User) error {
 	if len(user.Permissions) == 0 {
-		return &ValidationError{err: "please grant some permissions to this user"}
+		return utils.NewValidationError("please grant some permissions to this user")
 	}
 	permissions := make(map[string][]string)
 	if _, ok := user.Permissions["/"]; !ok {
-		return &ValidationError{err: "permissions for the root dir \"/\" must be set"}
+		return utils.NewValidationError("permissions for the root dir \"/\" must be set")
 	}
 	for dir, perms := range user.Permissions {
 		if len(perms) == 0 && dir == "/" {
-			return &ValidationError{err: fmt.Sprintf("no permissions granted for the directory: %#v", dir)}
+			return utils.NewValidationError(fmt.Sprintf("no permissions granted for the directory: %#v", dir))
 		}
 		if len(perms) > len(ValidPerms) {
-			return &ValidationError{err: "invalid permissions"}
+			return utils.NewValidationError("invalid permissions")
 		}
 		for _, p := range perms {
 			if !utils.IsStringInSlice(p, ValidPerms) {
-				return &ValidationError{err: fmt.Sprintf("invalid permission: %#v", p)}
+				return utils.NewValidationError(fmt.Sprintf("invalid permission: %#v", p))
 			}
 		}
 		cleanedDir := filepath.ToSlash(path.Clean(dir))
@@ -1273,10 +1251,10 @@ func validatePermissions(user *User) error {
 			cleanedDir = strings.TrimSuffix(cleanedDir, "/")
 		}
 		if !path.IsAbs(cleanedDir) {
-			return &ValidationError{err: fmt.Sprintf("cannot set permissions for non absolute path: %#v", dir)}
+			return utils.NewValidationError(fmt.Sprintf("cannot set permissions for non absolute path: %#v", dir))
 		}
 		if dir != cleanedDir && cleanedDir == "/" {
-			return &ValidationError{err: fmt.Sprintf("cannot set permissions for invalid subdirectory: %#v is an alias for \"/\"", dir)}
+			return utils.NewValidationError(fmt.Sprintf("cannot set permissions for invalid subdirectory: %#v is an alias for \"/\"", dir))
 		}
 		if utils.IsStringInSlice(PermAny, perms) {
 			permissions[cleanedDir] = []string{PermAny}
@@ -1299,7 +1277,7 @@ func validatePublicKeys(user *User) error {
 		}
 		_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k))
 		if err != nil {
-			return &ValidationError{err: fmt.Sprintf("could not parse key nr. %d: %s", i+1, err)}
+			return utils.NewValidationError(fmt.Sprintf("could not parse key nr. %d: %s", i+1, err))
 		}
 		validatedKeys = append(validatedKeys, k)
 	}
@@ -1317,13 +1295,13 @@ func validateFiltersPatternExtensions(user *User) error {
 	for _, f := range user.Filters.FilePatterns {
 		cleanedPath := filepath.ToSlash(path.Clean(f.Path))
 		if !path.IsAbs(cleanedPath) {
-			return &ValidationError{err: fmt.Sprintf("invalid path %#v for file patterns filter", f.Path)}
+			return utils.NewValidationError(fmt.Sprintf("invalid path %#v for file patterns filter", f.Path))
 		}
 		if utils.IsStringInSlice(cleanedPath, filteredPaths) {
-			return &ValidationError{err: fmt.Sprintf("duplicate file patterns filter for path %#v", f.Path)}
+			return utils.NewValidationError(fmt.Sprintf("duplicate file patterns filter for path %#v", f.Path))
 		}
 		if len(f.AllowedPatterns) == 0 && len(f.DeniedPatterns) == 0 {
-			return &ValidationError{err: fmt.Sprintf("empty file patterns filter for path %#v", f.Path)}
+			return utils.NewValidationError(fmt.Sprintf("empty file patterns filter for path %#v", f.Path))
 		}
 		f.Path = cleanedPath
 		allowed := make([]string, 0, len(f.AllowedPatterns))
@@ -1331,14 +1309,14 @@ func validateFiltersPatternExtensions(user *User) error {
 		for _, pattern := range f.AllowedPatterns {
 			_, err := path.Match(pattern, "abc")
 			if err != nil {
-				return &ValidationError{err: fmt.Sprintf("invalid file pattern filter %#v", pattern)}
+				return utils.NewValidationError(fmt.Sprintf("invalid file pattern filter %#v", pattern))
 			}
 			allowed = append(allowed, strings.ToLower(pattern))
 		}
 		for _, pattern := range f.DeniedPatterns {
 			_, err := path.Match(pattern, "abc")
 			if err != nil {
-				return &ValidationError{err: fmt.Sprintf("invalid file pattern filter %#v", pattern)}
+				return utils.NewValidationError(fmt.Sprintf("invalid file pattern filter %#v", pattern))
 			}
 			denied = append(denied, strings.ToLower(pattern))
 		}
@@ -1371,45 +1349,45 @@ func validateFilters(user *User) error {
 	for _, IPMask := range user.Filters.DeniedIP {
 		_, _, err := net.ParseCIDR(IPMask)
 		if err != nil {
-			return &ValidationError{err: fmt.Sprintf("could not parse denied IP/Mask %#v : %v", IPMask, err)}
+			return utils.NewValidationError(fmt.Sprintf("could not parse denied IP/Mask %#v : %v", IPMask, err))
 		}
 	}
 	for _, IPMask := range user.Filters.AllowedIP {
 		_, _, err := net.ParseCIDR(IPMask)
 		if err != nil {
-			return &ValidationError{err: fmt.Sprintf("could not parse allowed IP/Mask %#v : %v", IPMask, err)}
+			return utils.NewValidationError(fmt.Sprintf("could not parse allowed IP/Mask %#v : %v", IPMask, err))
 		}
 	}
 	if len(user.Filters.DeniedLoginMethods) >= len(ValidLoginMethods) {
-		return &ValidationError{err: "invalid denied_login_methods"}
+		return utils.NewValidationError("invalid denied_login_methods")
 	}
 	for _, loginMethod := range user.Filters.DeniedLoginMethods {
 		if !utils.IsStringInSlice(loginMethod, ValidLoginMethods) {
-			return &ValidationError{err: fmt.Sprintf("invalid login method: %#v", loginMethod)}
+			return utils.NewValidationError(fmt.Sprintf("invalid login method: %#v", loginMethod))
 		}
 	}
 	if len(user.Filters.DeniedProtocols) >= len(ValidProtocols) {
-		return &ValidationError{err: "invalid denied_protocols"}
+		return utils.NewValidationError("invalid denied_protocols")
 	}
 	for _, p := range user.Filters.DeniedProtocols {
 		if !utils.IsStringInSlice(p, ValidProtocols) {
-			return &ValidationError{err: fmt.Sprintf("invalid protocol: %#v", p)}
+			return utils.NewValidationError(fmt.Sprintf("invalid protocol: %#v", p))
 		}
 	}
 	if user.Filters.TLSUsername != "" {
 		if !utils.IsStringInSlice(string(user.Filters.TLSUsername), validTLSUsernames) {
-			return &ValidationError{err: fmt.Sprintf("invalid TLS username: %#v", user.Filters.TLSUsername)}
+			return utils.NewValidationError(fmt.Sprintf("invalid TLS username: %#v", user.Filters.TLSUsername))
 		}
 	}
 	for _, opts := range user.Filters.WebClient {
 		if !utils.IsStringInSlice(opts, WebClientOptions) {
-			return &ValidationError{err: fmt.Sprintf("invalid web client options %#v", opts)}
+			return utils.NewValidationError(fmt.Sprintf("invalid web client options %#v", opts))
 		}
 	}
 	return validateFiltersPatternExtensions(user)
 }
 
-func saveGCSCredentials(fsConfig *vfs.Filesystem, helper fsValidatorHelper) error {
+func saveGCSCredentials(fsConfig *vfs.Filesystem, helper vfs.ValidatorHelper) error {
 	if fsConfig.Provider != vfs.GCSFilesystemProvider {
 		return nil
 	}
@@ -1418,7 +1396,7 @@ func saveGCSCredentials(fsConfig *vfs.Filesystem, helper fsValidatorHelper) erro
 	}
 	if config.PreferDatabaseCredentials {
 		if fsConfig.GCSConfig.Credentials.IsPlain() {
-			fsConfig.GCSConfig.Credentials.SetAdditionalData(helper.GetEncrytionAdditionalData())
+			fsConfig.GCSConfig.Credentials.SetAdditionalData(helper.GetEncryptionAdditionalData())
 			err := fsConfig.GCSConfig.Credentials.Encrypt()
 			if err != nil {
 				return err
@@ -1427,113 +1405,45 @@ func saveGCSCredentials(fsConfig *vfs.Filesystem, helper fsValidatorHelper) erro
 		return nil
 	}
 	if fsConfig.GCSConfig.Credentials.IsPlain() {
-		fsConfig.GCSConfig.Credentials.SetAdditionalData(helper.GetEncrytionAdditionalData())
+		fsConfig.GCSConfig.Credentials.SetAdditionalData(helper.GetEncryptionAdditionalData())
 		err := fsConfig.GCSConfig.Credentials.Encrypt()
 		if err != nil {
-			return &ValidationError{err: fmt.Sprintf("could not encrypt GCS credentials: %v", err)}
+			return utils.NewValidationError(fmt.Sprintf("could not encrypt GCS credentials: %v", err))
 		}
 	}
 	creds, err := json.Marshal(fsConfig.GCSConfig.Credentials)
 	if err != nil {
-		return &ValidationError{err: fmt.Sprintf("could not marshal GCS credentials: %v", err)}
+		return utils.NewValidationError(fmt.Sprintf("could not marshal GCS credentials: %v", err))
 	}
 	credentialsFilePath := helper.GetGCSCredentialsFilePath()
 	err = os.MkdirAll(filepath.Dir(credentialsFilePath), 0700)
 	if err != nil {
-		return &ValidationError{err: fmt.Sprintf("could not create GCS credentials dir: %v", err)}
+		return utils.NewValidationError(fmt.Sprintf("could not create GCS credentials dir: %v", err))
 	}
 	err = os.WriteFile(credentialsFilePath, creds, 0600)
 	if err != nil {
-		return &ValidationError{err: fmt.Sprintf("could not save GCS credentials: %v", err)}
+		return utils.NewValidationError(fmt.Sprintf("could not save GCS credentials: %v", err))
 	}
 	fsConfig.GCSConfig.Credentials = kms.NewEmptySecret()
 	return nil
 }
 
-func validateFilesystemConfig(fsConfig *vfs.Filesystem, helper fsValidatorHelper) error {
-	if fsConfig.Provider == vfs.S3FilesystemProvider {
-		if err := fsConfig.S3Config.Validate(); err != nil {
-			return &ValidationError{err: fmt.Sprintf("could not validate s3config: %v", err)}
-		}
-		if err := fsConfig.S3Config.EncryptCredentials(helper.GetEncrytionAdditionalData()); err != nil {
-			return &ValidationError{err: fmt.Sprintf("could not encrypt s3 access secret: %v", err)}
-		}
-		fsConfig.GCSConfig = vfs.GCSFsConfig{}
-		fsConfig.AzBlobConfig = vfs.AzBlobFsConfig{}
-		fsConfig.CryptConfig = vfs.CryptFsConfig{}
-		fsConfig.SFTPConfig = vfs.SFTPFsConfig{}
-		return nil
-	} else if fsConfig.Provider == vfs.GCSFilesystemProvider {
-		if err := fsConfig.GCSConfig.Validate(helper.GetGCSCredentialsFilePath()); err != nil {
-			return &ValidationError{err: fmt.Sprintf("could not validate GCS config: %v", err)}
-		}
-		fsConfig.S3Config = vfs.S3FsConfig{}
-		fsConfig.AzBlobConfig = vfs.AzBlobFsConfig{}
-		fsConfig.CryptConfig = vfs.CryptFsConfig{}
-		fsConfig.SFTPConfig = vfs.SFTPFsConfig{}
-		return nil
-	} else if fsConfig.Provider == vfs.AzureBlobFilesystemProvider {
-		if err := fsConfig.AzBlobConfig.Validate(); err != nil {
-			return &ValidationError{err: fmt.Sprintf("could not validate Azure Blob config: %v", err)}
-		}
-		if err := fsConfig.AzBlobConfig.EncryptCredentials(helper.GetEncrytionAdditionalData()); err != nil {
-			return &ValidationError{err: fmt.Sprintf("could not encrypt Azure blob account key: %v", err)}
-		}
-		fsConfig.S3Config = vfs.S3FsConfig{}
-		fsConfig.GCSConfig = vfs.GCSFsConfig{}
-		fsConfig.CryptConfig = vfs.CryptFsConfig{}
-		fsConfig.SFTPConfig = vfs.SFTPFsConfig{}
-		return nil
-	} else if fsConfig.Provider == vfs.CryptedFilesystemProvider {
-		if err := fsConfig.CryptConfig.Validate(); err != nil {
-			return &ValidationError{err: fmt.Sprintf("could not validate Crypt fs config: %v", err)}
-		}
-		if err := fsConfig.CryptConfig.EncryptCredentials(helper.GetEncrytionAdditionalData()); err != nil {
-			return &ValidationError{err: fmt.Sprintf("could not encrypt Crypt fs passphrase: %v", err)}
-		}
-		fsConfig.S3Config = vfs.S3FsConfig{}
-		fsConfig.GCSConfig = vfs.GCSFsConfig{}
-		fsConfig.AzBlobConfig = vfs.AzBlobFsConfig{}
-		fsConfig.SFTPConfig = vfs.SFTPFsConfig{}
-		return nil
-	} else if fsConfig.Provider == vfs.SFTPFilesystemProvider {
-		if err := fsConfig.SFTPConfig.Validate(); err != nil {
-			return &ValidationError{err: fmt.Sprintf("could not validate SFTP fs config: %v", err)}
-		}
-		if err := fsConfig.SFTPConfig.EncryptCredentials(helper.GetEncrytionAdditionalData()); err != nil {
-			return &ValidationError{err: fmt.Sprintf("could not encrypt SFTP fs credentials: %v", err)}
-		}
-		fsConfig.S3Config = vfs.S3FsConfig{}
-		fsConfig.GCSConfig = vfs.GCSFsConfig{}
-		fsConfig.AzBlobConfig = vfs.AzBlobFsConfig{}
-		fsConfig.CryptConfig = vfs.CryptFsConfig{}
-		return nil
-	}
-	fsConfig.Provider = vfs.LocalFilesystemProvider
-	fsConfig.S3Config = vfs.S3FsConfig{}
-	fsConfig.GCSConfig = vfs.GCSFsConfig{}
-	fsConfig.AzBlobConfig = vfs.AzBlobFsConfig{}
-	fsConfig.CryptConfig = vfs.CryptFsConfig{}
-	fsConfig.SFTPConfig = vfs.SFTPFsConfig{}
-	return nil
-}
-
 func validateBaseParams(user *User) error {
 	if user.Username == "" {
-		return &ValidationError{err: "username is mandatory"}
+		return utils.NewValidationError("username is mandatory")
 	}
 	if !config.SkipNaturalKeysValidation && !usernameRegex.MatchString(user.Username) {
-		return &ValidationError{err: fmt.Sprintf("username %#v is not valid, the following characters are allowed: a-zA-Z0-9-_.~",
-			user.Username)}
+		return utils.NewValidationError(fmt.Sprintf("username %#v is not valid, the following characters are allowed: a-zA-Z0-9-_.~",
+			user.Username))
 	}
 	if user.HomeDir == "" {
-		return &ValidationError{err: "home_dir is mandatory"}
+		return utils.NewValidationError("home_dir is mandatory")
 	}
 	if user.Password == "" && len(user.PublicKeys) == 0 {
-		return &ValidationError{err: "please set a password or at least a public_key"}
+		return utils.NewValidationError("please set a password or at least a public_key")
 	}
 	if !filepath.IsAbs(user.HomeDir) {
-		return &ValidationError{err: fmt.Sprintf("home_dir must be an absolute path, actual value: %v", user.HomeDir)}
+		return utils.NewValidationError(fmt.Sprintf("home_dir must be an absolute path, actual value: %v", user.HomeDir))
 	}
 	return nil
 }
@@ -1561,24 +1471,24 @@ func createUserPasswordHash(user *User) error {
 // FIXME: this should be defined as Folder struct method
 func ValidateFolder(folder *vfs.BaseVirtualFolder) error {
 	if folder.Name == "" {
-		return &ValidationError{err: "folder name is mandatory"}
+		return utils.NewValidationError("folder name is mandatory")
 	}
 	if !config.SkipNaturalKeysValidation && !usernameRegex.MatchString(folder.Name) {
-		return &ValidationError{err: fmt.Sprintf("folder name %#v is not valid, the following characters are allowed: a-zA-Z0-9-_.~",
-			folder.Name)}
+		return utils.NewValidationError(fmt.Sprintf("folder name %#v is not valid, the following characters are allowed: a-zA-Z0-9-_.~",
+			folder.Name))
 	}
 	if folder.FsConfig.Provider == vfs.LocalFilesystemProvider || folder.FsConfig.Provider == vfs.CryptedFilesystemProvider ||
 		folder.MappedPath != "" {
 		cleanedMPath := filepath.Clean(folder.MappedPath)
 		if !filepath.IsAbs(cleanedMPath) {
-			return &ValidationError{err: fmt.Sprintf("invalid folder mapped path %#v", folder.MappedPath)}
+			return utils.NewValidationError(fmt.Sprintf("invalid folder mapped path %#v", folder.MappedPath))
 		}
 		folder.MappedPath = cleanedMPath
 	}
 	if folder.HasRedactedSecret() {
 		return errors.New("cannot save a folder with a redacted secret")
 	}
-	if err := validateFilesystemConfig(&folder.FsConfig, folder); err != nil {
+	if err := folder.FsConfig.Validate(folder); err != nil {
 		return err
 	}
 	return saveGCSCredentials(&folder.FsConfig, folder)
@@ -1598,14 +1508,14 @@ func ValidateUser(user *User) error {
 	if user.hasRedactedSecret() {
 		return errors.New("cannot save a user with a redacted secret")
 	}
-	if err := validateFilesystemConfig(&user.FsConfig, user); err != nil {
+	if err := user.FsConfig.Validate(user); err != nil {
 		return err
 	}
 	if err := validateUserVirtualFolders(user); err != nil {
 		return err
 	}
 	if user.Status < 0 || user.Status > 1 {
-		return &ValidationError{err: fmt.Sprintf("invalid user status: %v", user.Status)}
+		return utils.NewValidationError(fmt.Sprintf("invalid user status: %v", user.Status))
 	}
 	if err := createUserPasswordHash(user); err != nil {
 		return err
diff --git a/dataprovider/user.go b/dataprovider/user.go
index a1789a01..eb85b15c 100644
--- a/dataprovider/user.go
+++ b/dataprovider/user.go
@@ -342,20 +342,7 @@ func (u *User) isFsEqual(other *User) bool {
 // hideConfidentialData hides user confidential data
 func (u *User) hideConfidentialData() {
 	u.Password = ""
-	switch u.FsConfig.Provider {
-	case vfs.S3FilesystemProvider:
-		u.FsConfig.S3Config.AccessSecret.Hide()
-	case vfs.GCSFilesystemProvider:
-		u.FsConfig.GCSConfig.Credentials.Hide()
-	case vfs.AzureBlobFilesystemProvider:
-		u.FsConfig.AzBlobConfig.AccountKey.Hide()
-		u.FsConfig.AzBlobConfig.SASURL.Hide()
-	case vfs.CryptedFilesystemProvider:
-		u.FsConfig.CryptConfig.Passphrase.Hide()
-	case vfs.SFTPFilesystemProvider:
-		u.FsConfig.SFTPConfig.Password.Hide()
-		u.FsConfig.SFTPConfig.PrivateKey.Hide()
-	}
+	u.FsConfig.HideConfidentialData()
 }
 
 // GetSubDirPermissions returns permissions for sub directories
@@ -387,33 +374,8 @@ func (u *User) PrepareForRendering() {
 }
 
 func (u *User) hasRedactedSecret() bool {
-	switch u.FsConfig.Provider {
-	case vfs.S3FilesystemProvider:
-		if u.FsConfig.S3Config.AccessSecret.IsRedacted() {
-			return true
-		}
-	case vfs.GCSFilesystemProvider:
-		if u.FsConfig.GCSConfig.Credentials.IsRedacted() {
-			return true
-		}
-	case vfs.AzureBlobFilesystemProvider:
-		if u.FsConfig.AzBlobConfig.AccountKey.IsRedacted() {
-			return true
-		}
-		if u.FsConfig.AzBlobConfig.SASURL.IsRedacted() {
-			return true
-		}
-	case vfs.CryptedFilesystemProvider:
-		if u.FsConfig.CryptConfig.Passphrase.IsRedacted() {
-			return true
-		}
-	case vfs.SFTPFilesystemProvider:
-		if u.FsConfig.SFTPConfig.Password.IsRedacted() {
-			return true
-		}
-		if u.FsConfig.SFTPConfig.PrivateKey.IsRedacted() {
-			return true
-		}
+	if u.FsConfig.HasRedactedSecret() {
+		return true
 	}
 
 	for idx := range u.VirtualFolders {
@@ -1189,8 +1151,8 @@ func (u *User) getNotificationFieldsAsSlice(action string) []string {
 	}
 }
 
-// GetEncrytionAdditionalData returns the additional data to use for AEAD
-func (u *User) GetEncrytionAdditionalData() string {
+// GetEncryptionAdditionalData returns the additional data to use for AEAD
+func (u *User) GetEncryptionAdditionalData() string {
 	return u.Username
 }
 
diff --git a/httpd/api_admin.go b/httpd/api_admin.go
index a64c9742..7cab4d01 100644
--- a/httpd/api_admin.go
+++ b/httpd/api_admin.go
@@ -9,6 +9,7 @@ import (
 	"github.com/go-chi/render"
 
 	"github.com/drakkan/sftpgo/dataprovider"
+	"github.com/drakkan/sftpgo/utils"
 )
 
 func getAdmins(w http.ResponseWriter, r *http.Request) {
@@ -140,13 +141,13 @@ func changeAdminPassword(w http.ResponseWriter, r *http.Request) {
 
 func doChangeAdminPassword(r *http.Request, currentPassword, newPassword, confirmNewPassword string) error {
 	if currentPassword == "" || newPassword == "" || confirmNewPassword == "" {
-		return dataprovider.NewValidationError("please provide the current password and the new one two times")
+		return utils.NewValidationError("please provide the current password and the new one two times")
 	}
 	if newPassword != confirmNewPassword {
-		return dataprovider.NewValidationError("the two password fields do not match")
+		return utils.NewValidationError("the two password fields do not match")
 	}
 	if currentPassword == newPassword {
-		return dataprovider.NewValidationError("the new password must be different from the current one")
+		return utils.NewValidationError("the new password must be different from the current one")
 	}
 	claims, err := getTokenClaims(r)
 	if err != nil {
@@ -158,7 +159,7 @@ func doChangeAdminPassword(r *http.Request, currentPassword, newPassword, confir
 	}
 	match, err := admin.CheckPassword(currentPassword)
 	if !match || err != nil {
-		return dataprovider.NewValidationError("current password does not match")
+		return utils.NewValidationError("current password does not match")
 	}
 
 	admin.Password = newPassword
diff --git a/httpd/api_http_user.go b/httpd/api_http_user.go
index 6a9a0782..790cc234 100644
--- a/httpd/api_http_user.go
+++ b/httpd/api_http_user.go
@@ -215,13 +215,13 @@ func changeUserPassword(w http.ResponseWriter, r *http.Request) {
 
 func doChangeUserPassword(r *http.Request, currentPassword, newPassword, confirmNewPassword string) error {
 	if currentPassword == "" || newPassword == "" || confirmNewPassword == "" {
-		return dataprovider.NewValidationError("please provide the current password and the new one two times")
+		return utils.NewValidationError("please provide the current password and the new one two times")
 	}
 	if newPassword != confirmNewPassword {
-		return dataprovider.NewValidationError("the two password fields do not match")
+		return utils.NewValidationError("the two password fields do not match")
 	}
 	if currentPassword == newPassword {
-		return dataprovider.NewValidationError("the new password must be different from the current one")
+		return utils.NewValidationError("the new password must be different from the current one")
 	}
 	claims, err := getTokenClaims(r)
 	if err != nil || claims.Username == "" {
@@ -230,7 +230,7 @@ func doChangeUserPassword(r *http.Request, currentPassword, newPassword, confirm
 	user, err := dataprovider.CheckUserAndPass(claims.Username, currentPassword, utils.GetIPFromRemoteAddress(r.RemoteAddr),
 		common.ProtocolHTTP)
 	if err != nil {
-		return dataprovider.NewValidationError("current password does not match")
+		return utils.NewValidationError("current password does not match")
 	}
 	user.Password = newPassword
 
diff --git a/httpd/api_maintenance.go b/httpd/api_maintenance.go
index a0cd1ce8..1dd4414f 100644
--- a/httpd/api_maintenance.go
+++ b/httpd/api_maintenance.go
@@ -16,6 +16,7 @@ import (
 	"github.com/drakkan/sftpgo/common"
 	"github.com/drakkan/sftpgo/dataprovider"
 	"github.com/drakkan/sftpgo/logger"
+	"github.com/drakkan/sftpgo/utils"
 	"github.com/drakkan/sftpgo/vfs"
 )
 
@@ -104,7 +105,7 @@ func loadDataFromRequest(w http.ResponseWriter, r *http.Request) {
 	content, err := io.ReadAll(r.Body)
 	if err != nil || len(content) == 0 {
 		if len(content) == 0 {
-			err = dataprovider.NewValidationError("request body is required")
+			err = utils.NewValidationError("request body is required")
 		}
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		return
@@ -150,7 +151,7 @@ func loadData(w http.ResponseWriter, r *http.Request) {
 func restoreBackup(content []byte, inputFile string, scanQuota, mode int) error {
 	dump, err := dataprovider.ParseDumpData(content)
 	if err != nil {
-		return dataprovider.NewValidationError(fmt.Sprintf("Unable to parse backup content: %v", err))
+		return utils.NewValidationError(fmt.Sprintf("Unable to parse backup content: %v", err))
 	}
 
 	if err = RestoreFolders(dump.Folders, inputFile, mode, scanQuota); err != nil {
diff --git a/httpd/api_utils.go b/httpd/api_utils.go
index 73b69f22..50b7f52c 100644
--- a/httpd/api_utils.go
+++ b/httpd/api_utils.go
@@ -42,7 +42,7 @@ func sendAPIResponse(w http.ResponseWriter, r *http.Request, err error, message
 }
 
 func getRespStatus(err error) int {
-	if _, ok := err.(*dataprovider.ValidationError); ok {
+	if _, ok := err.(*utils.ValidationError); ok {
 		return http.StatusBadRequest
 	}
 	if _, ok := err.(*dataprovider.MethodDisabledError); ok {
diff --git a/utils/errors.go b/utils/errors.go
new file mode 100644
index 00000000..8b73bfc4
--- /dev/null
+++ b/utils/errors.go
@@ -0,0 +1,20 @@
+package utils
+
+import "fmt"
+
+// ValidationError raised if input data is not valid
+type ValidationError struct {
+	err string
+}
+
+// Validation error details
+func (e *ValidationError) Error() string {
+	return fmt.Sprintf("Validation error: %s", e.err)
+}
+
+// NewValidationError returns a validation errors
+func NewValidationError(error string) *ValidationError {
+	return &ValidationError{
+		err: error,
+	}
+}
diff --git a/vfs/filesystem.go b/vfs/filesystem.go
index 0978cb5c..020338ba 100644
--- a/vfs/filesystem.go
+++ b/vfs/filesystem.go
@@ -1,6 +1,11 @@
 package vfs
 
-import "github.com/drakkan/sftpgo/kms"
+import (
+	"fmt"
+
+	"github.com/drakkan/sftpgo/kms"
+	"github.com/drakkan/sftpgo/utils"
+)
 
 // FilesystemProvider defines the supported storage filesystems
 type FilesystemProvider int
@@ -15,6 +20,13 @@ const (
 	SFTPFilesystemProvider                                // SFTP
 )
 
+// ValidatorHelper implements methods we need for Filesystem.ValidateConfig.
+// It is implemented by vfs.Folder and dataprovider.User
+type ValidatorHelper interface {
+	GetGCSCredentialsFilePath() string
+	GetEncryptionAdditionalData() string
+}
+
 // Filesystem defines cloud storage filesystem details
 type Filesystem struct {
 	RedactedSecret string             `json:"-"`
@@ -99,6 +111,128 @@ func (f *Filesystem) IsEqual(other *Filesystem) bool {
 	}
 }
 
+// Validate verifies the FsConfig matching the configured provider and sets all other
+// Filesystem.*Config to their zero value if successful
+func (f *Filesystem) Validate(helper ValidatorHelper) error {
+	switch f.Provider {
+	case S3FilesystemProvider:
+		if err := f.S3Config.Validate(); err != nil {
+			return utils.NewValidationError(fmt.Sprintf("could not validate s3config: %v", err))
+		}
+		if err := f.S3Config.EncryptCredentials(helper.GetEncryptionAdditionalData()); err != nil {
+			return utils.NewValidationError(fmt.Sprintf("could not encrypt s3 access secret: %v", err))
+		}
+		f.GCSConfig = GCSFsConfig{}
+		f.AzBlobConfig = AzBlobFsConfig{}
+		f.CryptConfig = CryptFsConfig{}
+		f.SFTPConfig = SFTPFsConfig{}
+		return nil
+	case GCSFilesystemProvider:
+		if err := f.GCSConfig.Validate(helper.GetGCSCredentialsFilePath()); err != nil {
+			return utils.NewValidationError(fmt.Sprintf("could not validate GCS config: %v", err))
+		}
+		f.S3Config = S3FsConfig{}
+		f.AzBlobConfig = AzBlobFsConfig{}
+		f.CryptConfig = CryptFsConfig{}
+		f.SFTPConfig = SFTPFsConfig{}
+		return nil
+	case AzureBlobFilesystemProvider:
+		if err := f.AzBlobConfig.Validate(); err != nil {
+			return utils.NewValidationError(fmt.Sprintf("could not validate Azure Blob config: %v", err))
+		}
+		if err := f.AzBlobConfig.EncryptCredentials(helper.GetEncryptionAdditionalData()); err != nil {
+			return utils.NewValidationError(fmt.Sprintf("could not encrypt Azure blob account key: %v", err))
+		}
+		f.S3Config = S3FsConfig{}
+		f.GCSConfig = GCSFsConfig{}
+		f.CryptConfig = CryptFsConfig{}
+		f.SFTPConfig = SFTPFsConfig{}
+		return nil
+	case CryptedFilesystemProvider:
+		if err := f.CryptConfig.Validate(); err != nil {
+			return utils.NewValidationError(fmt.Sprintf("could not validate Crypt fs config: %v", err))
+		}
+		if err := f.CryptConfig.EncryptCredentials(helper.GetEncryptionAdditionalData()); err != nil {
+			return utils.NewValidationError(fmt.Sprintf("could not encrypt Crypt fs passphrase: %v", err))
+		}
+		f.S3Config = S3FsConfig{}
+		f.GCSConfig = GCSFsConfig{}
+		f.AzBlobConfig = AzBlobFsConfig{}
+		f.SFTPConfig = SFTPFsConfig{}
+		return nil
+	case SFTPFilesystemProvider:
+		if err := f.SFTPConfig.Validate(); err != nil {
+			return utils.NewValidationError(fmt.Sprintf("could not validate SFTP fs config: %v", err))
+		}
+		if err := f.SFTPConfig.EncryptCredentials(helper.GetEncryptionAdditionalData()); err != nil {
+			return utils.NewValidationError(fmt.Sprintf("could not encrypt SFTP fs credentials: %v", err))
+		}
+		f.S3Config = S3FsConfig{}
+		f.GCSConfig = GCSFsConfig{}
+		f.AzBlobConfig = AzBlobFsConfig{}
+		f.CryptConfig = CryptFsConfig{}
+		return nil
+	default:
+		f.Provider = LocalFilesystemProvider
+		f.S3Config = S3FsConfig{}
+		f.GCSConfig = GCSFsConfig{}
+		f.AzBlobConfig = AzBlobFsConfig{}
+		f.CryptConfig = CryptFsConfig{}
+		f.SFTPConfig = SFTPFsConfig{}
+		return nil
+	}
+}
+
+// HasRedactedSecret returns true if configured the filesystem configuration has a redacted secret
+func (f *Filesystem) HasRedactedSecret() bool {
+	// TODO move vfs specific code into each *FsConfig struct
+	switch f.Provider {
+	case S3FilesystemProvider:
+		if f.S3Config.AccessSecret.IsRedacted() {
+			return true
+		}
+	case GCSFilesystemProvider:
+		if f.GCSConfig.Credentials.IsRedacted() {
+			return true
+		}
+	case AzureBlobFilesystemProvider:
+		if f.AzBlobConfig.AccountKey.IsRedacted() {
+			return true
+		}
+	case CryptedFilesystemProvider:
+		if f.CryptConfig.Passphrase.IsRedacted() {
+			return true
+		}
+	case SFTPFilesystemProvider:
+		if f.SFTPConfig.Password.IsRedacted() {
+			return true
+		}
+		if f.SFTPConfig.PrivateKey.IsRedacted() {
+			return true
+		}
+	}
+
+	return false
+}
+
+// HideConfidentialData hides filesystem confidential data
+func (f *Filesystem) HideConfidentialData() {
+	switch f.Provider {
+	case S3FilesystemProvider:
+		f.S3Config.AccessSecret.Hide()
+	case GCSFilesystemProvider:
+		f.GCSConfig.Credentials.Hide()
+	case AzureBlobFilesystemProvider:
+		f.AzBlobConfig.AccountKey.Hide()
+		f.AzBlobConfig.SASURL.Hide()
+	case CryptedFilesystemProvider:
+		f.CryptConfig.Passphrase.Hide()
+	case SFTPFilesystemProvider:
+		f.SFTPConfig.Password.Hide()
+		f.SFTPConfig.PrivateKey.Hide()
+	}
+}
+
 // GetACopy returns a copy
 func (f *Filesystem) GetACopy() Filesystem {
 	f.SetEmptySecretsIfNil()
diff --git a/vfs/folder.go b/vfs/folder.go
index 73090afb..29e7aa33 100644
--- a/vfs/folder.go
+++ b/vfs/folder.go
@@ -28,8 +28,8 @@ type BaseVirtualFolder struct {
 	FsConfig Filesystem `json:"filesystem"`
 }
 
-// GetEncrytionAdditionalData returns the additional data to use for AEAD
-func (v *BaseVirtualFolder) GetEncrytionAdditionalData() string {
+// GetEncryptionAdditionalData returns the additional data to use for AEAD
+func (v *BaseVirtualFolder) GetEncryptionAdditionalData() string {
 	return fmt.Sprintf("folder_%v", v.Name)
 }