diff --git a/actions.go b/actions.go
index a3f8ab829e6047f6e94d1994969cdcb27a3b1f59..e2e98a80f523f46476bfadf8e8e5f19314708696 100644
--- a/actions.go
+++ b/actions.go
@@ -7,7 +7,6 @@ import (
 	"errors"
 
 	"git.autistici.org/ai3/go-common/pwhash"
-	"git.autistici.org/id/keystore/userenckey"
 	"github.com/pquerna/otp/totp"
 )
 
@@ -154,105 +153,176 @@ func (s *AccountService) ChangeUserPassword(ctx context.Context, tx TX, req *Cha
 	}
 
 	return s.withRequest(ctx, req, func(ctx context.Context) error {
-		// If the user does not yet have an encryption key, generate one now.
-		if !user.HasEncryptionKeys {
-			err = s.initializeUserEncryptionKeys(ctx, tx, user, req.Password)
-		} else {
-			err = s.updateUserEncryptionKeys(ctx, tx, user, req.CurPassword, req.Password, UserEncryptionKeyMainID)
+		return s.changeUserPasswordAndUpdateEncryptionKeys(ctx, tx, user, req.CurPassword, req.Password)
+	})
+}
+
+// PasswordRecoveryRequest is the request type for AccountService.RecoverPassword().
+// It is not authenticated with SSO.
+type PasswordRecoveryRequest struct {
+	Username         string `json:"username"`
+	RecoveryPassword string `json:"recovery_password"`
+	Password         string `json:"password"`
+}
+
+// Validate the request.
+func (r *PasswordRecoveryRequest) Validate(ctx context.Context, s *AccountService) error {
+	return s.passwordValidator(ctx, r.Password)
+}
+
+// RecoverPassword lets users reset their password by providing
+// secondary credentials, which we authenticate ourselves.
+//
+// TODO: call out to auth-server for secondary authentication?
+func (s *AccountService) RecoverPassword(ctx context.Context, tx TX, req *PasswordRecoveryRequest) error {
+	user, err := s.getUser(ctx, tx, req.Username)
+	if err != nil {
+		return err
+	}
+	// TODO: authenticate with the secret recovery password.
+	ctx = context.WithValue(ctx, authUserCtxKey, req.Username)
+
+	return s.withRequest(ctx, req, func(ctx context.Context) error {
+		// Change the user password (the recovery password does not change).
+		return s.changeUserPasswordAndUpdateEncryptionKeys(ctx, tx, user, req.RecoveryPassword, req.Password)
+	})
+}
+
+// ResetPasswordRequest is the request type for AccountService.ResetPassword().
+type ResetPasswordRequest struct {
+	RequestBase
+	Password string `json:"password"`
+}
+
+// Validate the request.
+func (r *ResetPasswordRequest) Validate(ctx context.Context, s *AccountService) error {
+	return s.passwordValidator(ctx, r.Password)
+}
+
+// ResetPassword is an admin operation to forcefully reset the
+// password for an account. The user will lose access to all stored
+// email (because the encryption keys will be reset) and to 2FA.
+func (s *AccountService) ResetPassword(ctx context.Context, tx TX, req *ResetPasswordRequest) error {
+	ctx, user, err := s.authorizeAdmin(ctx, tx, req.RequestBase)
+	if err != nil {
+		return err
+	}
+
+	return s.withRequest(ctx, req, func(ctx context.Context) error {
+		// Disable 2FA.
+		if err := tx.DeleteUserTOTPSecret(ctx, user); err != nil {
+			return err
+		}
+		// Reset encryption keys and set the new password.
+		return s.changeUserPasswordAndResetEncryptionKeys(ctx, tx, user, req.Password)
+	})
+}
+
+// SetPasswordRecoveryHintRequest is the request type for
+// AccountService.SetPasswordRecoveryHint().
+type SetPasswordRecoveryHintRequest struct {
+	PrivilegedRequestBase
+	Hint     string `json:"recovery_hint"`
+	Response string `json:"recovery_response"`
+}
+
+// Validate the request.
+func (r *SetPasswordRecoveryHintRequest) Validate(ctx context.Context, s *AccountService) error {
+	return s.passwordValidator(ctx, r.Response)
+}
+
+// SetPasswordRecoveryHint lets users set the password recovery hint
+// and expected response (secondary password).
+func (s *AccountService) SetPasswordRecoveryHint(ctx context.Context, tx TX, req *SetPasswordRecoveryHintRequest) error {
+	ctx, user, err := s.authorizeUserWithPassword(ctx, tx, req.PrivilegedRequestBase)
+	if err != nil {
+		return err
+	}
+
+	return s.withRequest(ctx, req, func(ctx context.Context) error {
+		// If the encryption keys are not set up yet, use the
+		// CurPassword to initialize them.
+		keys, decrypted, err := s.readOrInitializeEncryptionKeys(ctx, tx, user, req.CurPassword, req.CurPassword)
+		if err != nil {
+			return err
 		}
+
+		keys, err = updateEncryptionKey(keys, decrypted, UserEncryptionKeyRecoveryID, req.Response)
 		if err != nil {
 			return err
 		}
 
-		// Set the encrypted password attribute on the user (will set it on emails too).
-		encPass := pwhash.Encrypt(req.Password)
-		if err := tx.SetUserPassword(ctx, user, encPass); err != nil {
+		if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil {
 			return newBackendError(err)
 		}
-		return nil
+
+		encResponse := pwhash.Encrypt(req.Response)
+		return tx.SetPasswordRecoveryHint(ctx, user, req.Hint, encResponse)
 	})
 }
 
-// Initialize the user encryption key list, by creating a new "main" key
-// encrypted with the given password (which must be the primary password for the
-// user).
-func (s *AccountService) initializeUserEncryptionKeys(ctx context.Context, tx TX, user *User, curPassword string) error {
-	// Generate a new key pair.
-	pub, priv, err := userenckey.GenerateKey()
+// Change the user password and update encryption keys, provided we
+// have a password that we can use to decrypt them first.
+func (s *AccountService) changeUserPasswordAndUpdateEncryptionKeys(ctx context.Context, tx TX, user *User, oldPassword, newPassword string) error {
+	// If the user does not yet have an encryption key, generate one now.
+	var err error
+
+	keys, decrypted, err := s.readOrInitializeEncryptionKeys(ctx, tx, user, oldPassword, newPassword)
 	if err != nil {
 		return err
 	}
 
-	// Encrypt the private key with the password.
-	enc, err := userenckey.Encrypt(priv, []byte(curPassword))
+	keys, err = updateEncryptionKey(keys, decrypted, UserEncryptionKeyMainID, newPassword)
 	if err != nil {
 		return err
 	}
-	keys := []*UserEncryptionKey{
-		&UserEncryptionKey{
-			ID:  UserEncryptionKeyMainID,
-			Key: enc,
-		},
-	}
 
-	// Update the backend database.
 	if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil {
 		return newBackendError(err)
 	}
-	if err := tx.SetUserEncryptionPublicKey(ctx, user, pub); err != nil {
+
+	// Set the encrypted password attribute on the user (will set it on emails too).
+	encPass := pwhash.Encrypt(newPassword)
+	if err := tx.SetUserPassword(ctx, user, encPass); err != nil {
 		return newBackendError(err)
 	}
-
 	return nil
 }
 
-// Re-encrypt the specified user encryption key with newPassword. For this
-// operation to succeed, we must be able to decrypt one of the keys (not
-// necessarily the same one) with curPassword.
-func (s *AccountService) updateUserEncryptionKeys(ctx context.Context, tx TX, user *User, curPassword, newPassword, keyID string) error {
-	keys, err := tx.GetUserEncryptionKeys(ctx, user)
+// Change the user password and reset all encryption keys. Existing email
+// won't be readable anymore. Existing 2FA credentials will be deleted.
+func (s *AccountService) changeUserPasswordAndResetEncryptionKeys(ctx context.Context, tx TX, user *User, newPassword string) error {
+	// Calling initialize will wipe the current keys and replace
+	// them with a new one.
+	keys, _, err := s.initializeEncryptionKeys(ctx, tx, user, newPassword)
 	if err != nil {
-		return newBackendError(err)
-	}
-	keys, err = reEncryptUserKeys(keys, curPassword, newPassword, keyID)
-	if err != nil {
-		return newRequestError(err)
+		return err
 	}
+
 	if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil {
 		return newBackendError(err)
 	}
-	return nil
-}
 
-// Decode the user encyrption key using the given password, then generate a new
-// list of encryption keys by replacing the specified encryption key with one
-// encrypted with the given password (or adding it if it does not exist).
-func reEncryptUserKeys(keys []*UserEncryptionKey, curPassword, newPassword, keyID string) ([]*UserEncryptionKey, error) {
-	// userenckey.Decrypt wants a slice of []byte.
-	var rawKeys [][]byte
-	for _, k := range keys {
-		rawKeys = append(rawKeys, k.Key)
-	}
-	decrypted, err := userenckey.Decrypt(rawKeys, []byte(curPassword))
-	if err != nil {
-		return nil, err
+	// Wipe all 2FA credentials that had corresponding encryption keys as well.
+	if err := s.wipeApplicationSpecificPasswords(ctx, tx, user); err != nil {
+		return err
 	}
-	encrypted, err := userenckey.Encrypt(decrypted, []byte(newPassword))
-	if err != nil {
-		return nil, err
+
+	// Set the encrypted password attribute on the user (will set it on emails too).
+	encPass := pwhash.Encrypt(newPassword)
+	if err := tx.SetUserPassword(ctx, user, encPass); err != nil {
+		return newBackendError(err)
 	}
+	return nil
+}
 
-	var keysOut []*UserEncryptionKey
-	for _, key := range keys {
-		if key.ID != keyID {
-			keysOut = append(keysOut, key)
+func (s *AccountService) wipeApplicationSpecificPasswords(ctx context.Context, tx TX, user *User) error {
+	for _, asp := range user.AppSpecificPasswords {
+		if err := tx.DeleteApplicationSpecificPassword(ctx, user, asp.ID); err != nil {
+			return err
 		}
 	}
-	keysOut = append(keysOut, &UserEncryptionKey{
-		ID:  keyID,
-		Key: encrypted,
-	})
-	return keysOut, nil
+	return nil
 }
 
 // CreateApplicationSpecificPasswordRequest is the request type for
@@ -310,10 +380,18 @@ func (s *AccountService) CreateApplicationSpecificPassword(ctx context.Context,
 		// this password. The user encryption key IDs for ASPs all
 		// have an 'asp_' prefix, followed by the ASP ID.
 		if user.HasEncryptionKeys {
+			keys, decrypted, err := s.readOrInitializeEncryptionKeys(ctx, tx, user, req.CurPassword, req.CurPassword)
+			if err != nil {
+				return err
+			}
 			keyID := "asp_" + asp.ID
-			if err := s.updateUserEncryptionKeys(ctx, tx, user, req.CurPassword, password, keyID); err != nil {
+			keys, err = updateEncryptionKey(keys, decrypted, keyID, password)
+			if err != nil {
 				return err
 			}
+			if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil {
+				return newBackendError(err)
+			}
 		}
 
 		resp.Password = password
diff --git a/actions_test.go b/actions_test.go
index 3c8de5e372e3bf026fd50c685cba814edfc379d2..e3a353263b6e13f8f3c895a3c2ee98a6f5408a30 100644
--- a/actions_test.go
+++ b/actions_test.go
@@ -40,6 +40,10 @@ func (b *fakeBackend) SetUserPassword(_ context.Context, user *User, password st
 	return nil
 }
 
+func (b *fakeBackend) SetPasswordRecoveryHint(_ context.Context, user *User, hint, response string) error {
+	return nil
+}
+
 func (b *fakeBackend) SetResourcePassword(_ context.Context, r *Resource, password string) error {
 	return nil
 }
@@ -252,19 +256,25 @@ func TestService_EncryptionKeys(t *testing.T) {
 
 	user, _ := svc.getUser(ctx, tx, "testuser")
 
-	if err := svc.initializeUserEncryptionKeys(ctx, tx, user, "password"); err != nil {
+	// Set the keys to something.
+	keys, _, err := svc.initializeEncryptionKeys(ctx, tx, user, "password")
+	if err != nil {
 		t.Fatal("init", err)
 	}
-	if err := svc.updateUserEncryptionKeys(ctx, tx, user, "BADPASS", "new_password", UserEncryptionKeyMainID); err == nil {
-		t.Fatal("update with bad password did not fail")
-	}
-	if err := svc.updateUserEncryptionKeys(ctx, tx, user, "password", "new_password", UserEncryptionKeyMainID); err != nil {
-		t.Fatal("update", err)
+	if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil {
+		t.Fatal("SetUserEncryptionKeys", err)
 	}
-
 	if n := len(fb.encryptionKeys["testuser"]); n != 1 {
 		t.Fatalf("found %d encryption keys, expected 1", n)
 	}
+
+	// Try to read (decrypt) them again using bad / good passwords.
+	if _, _, err := svc.readOrInitializeEncryptionKeys(ctx, tx, user, "BADPASS", "new_password"); err == nil {
+		t.Fatal("read with bad password did not fail")
+	}
+	if _, _, err := svc.readOrInitializeEncryptionKeys(ctx, tx, user, "password", "new_password"); err != nil {
+		t.Fatal("readOrInitialize", err)
+	}
 }
 
 // Try adding aliases to the email resource.
diff --git a/backend/model.go b/backend/model.go
index 17d1ecbbee67a1fb2c9ffe62fda7f699a34ec78f..bf071309a87f588a07e31ebad9f7b12653fe46a2 100644
--- a/backend/model.go
+++ b/backend/model.go
@@ -14,7 +14,8 @@ const (
 	// Names of some well-known LDAP attributes.
 	totpSecretLDAPAttr        = "totpSecret"
 	preferredLanguageLDAPAttr = "preferredLanguage"
-	recoverQuestionLDAPAttr   = "recoverQuestion"
+	recoveryHintLDAPAttr      = "recoverQuestion"
+	recoveryResponseLDAPAttr  = "recoverAnswer"
 	aspLDAPAttr               = "appSpecificPassword"
 	storagePublicKeyLDAPAttr  = "storagePublicKey"
 	storagePrivateKeyLDAPAttr = "storageEncryptedSecretKey"
@@ -144,7 +145,7 @@ func (tx *backendTX) GetUser(ctx context.Context, username string) (*accountserv
 			// object, a shortcoming of the legacy A/I database model. Set
 			// them on the main User object.
 			if isObjectClass(entry, "virtualMailUser") {
-				user.PasswordRecoveryHint = entry.GetAttributeValue(recoverQuestionLDAPAttr)
+				user.PasswordRecoveryHint = entry.GetAttributeValue(recoveryHintLDAPAttr)
 				user.AppSpecificPasswords = getASPInfo(decodeAppSpecificPasswords(entry.GetAttributeValues(aspLDAPAttr)))
 				user.HasEncryptionKeys = (entry.GetAttributeValue(storagePublicKeyLDAPAttr) != "")
 			}
@@ -171,6 +172,13 @@ func (tx *backendTX) SetUserPassword(ctx context.Context, user *accountserver.Us
 	return nil
 }
 
+func (tx *backendTX) SetPasswordRecoveryHint(ctx context.Context, user *accountserver.User, hint, response string) error {
+	dn := tx.getUserDN(user)
+	tx.setAttr(dn, recoveryHintLDAPAttr, hint)
+	tx.setAttr(dn, recoveryResponseLDAPAttr, response)
+	return nil
+}
+
 func (tx *backendTX) GetUserEncryptionKeys(ctx context.Context, user *accountserver.User) ([]*accountserver.UserEncryptionKey, error) {
 	r := user.GetSingleResourceByType(accountserver.ResourceTypeEmail)
 	dn, _ := tx.backend.resources.GetDN(r.ID)
diff --git a/encryption.go b/encryption.go
new file mode 100644
index 0000000000000000000000000000000000000000..ed6549499b7671f039c69e99323957e6ca1e5ab1
--- /dev/null
+++ b/encryption.go
@@ -0,0 +1,71 @@
+package accountserver
+
+import (
+	"context"
+
+	"git.autistici.org/id/keystore/userenckey"
+)
+
+func keysToBytes(keys []*UserEncryptionKey) [][]byte {
+	var rawKeys [][]byte
+	for _, k := range keys {
+		rawKeys = append(rawKeys, k.Key)
+	}
+	return rawKeys
+}
+
+func (s *AccountService) initializeEncryptionKeys(ctx context.Context, tx TX, user *User, password string) (keys []*UserEncryptionKey, decrypted []byte, err error) {
+	// Create new keys
+	pub, priv, err := userenckey.GenerateKey()
+	if err != nil {
+		return nil, nil, err
+	}
+	decrypted = priv
+	enc, err := userenckey.Encrypt(priv, []byte(password))
+	if err != nil {
+		return nil, nil, err
+	}
+	keys = append(keys, &UserEncryptionKey{
+		ID:  UserEncryptionKeyMainID,
+		Key: enc,
+	})
+
+	// Save the new public key.
+	if err = tx.SetUserEncryptionPublicKey(ctx, user, pub); err != nil {
+		err = newBackendError(err)
+	}
+	return
+}
+
+func (s *AccountService) readOrInitializeEncryptionKeys(ctx context.Context, tx TX, user *User, oldPassword, newPassword string) (keys []*UserEncryptionKey, decrypted []byte, err error) {
+	if user.HasEncryptionKeys {
+		// Fetch the encryption keys from the database.
+		keys, err = tx.GetUserEncryptionKeys(ctx, user)
+		if err != nil {
+			return
+		}
+		decrypted, err = userenckey.Decrypt(keysToBytes(keys), []byte(oldPassword))
+		return
+	}
+	return s.initializeEncryptionKeys(ctx, tx, user, newPassword)
+}
+
+func updateEncryptionKey(keys []*UserEncryptionKey, decrypted []byte, keyID, password string) ([]*UserEncryptionKey, error) {
+	encrypted, err := userenckey.Encrypt(decrypted, []byte(password))
+	if err != nil {
+		return nil, err
+	}
+
+	// Replace the key with id 'keyID' in the in-memory list.
+	var keysOut []*UserEncryptionKey
+	for _, key := range keys {
+		if key.ID != keyID {
+			keysOut = append(keysOut, key)
+		}
+	}
+	keysOut = append(keysOut, &UserEncryptionKey{
+		ID:  keyID,
+		Key: encrypted,
+	})
+	return keysOut, nil
+}
diff --git a/server/server.go b/server/server.go
index c620db6a6526bd45d18bf8305feb2670e4483863..f4243d4647d5cb25338fde96333ed88a7cdb9359 100644
--- a/server/server.go
+++ b/server/server.go
@@ -44,6 +44,20 @@ func (s *AccountServer) handleChangeUserPassword(tx as.TX, w http.ResponseWriter
 	})
 }
 
+func (s *AccountServer) handleSetPasswordRecoveryHint(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) {
+	var req as.SetPasswordRecoveryHintRequest
+	return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) {
+		return &emptyResponse, s.service.SetPasswordRecoveryHint(ctx, tx, &req)
+	})
+}
+
+func (s *AccountServer) handleRecoverPassword(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) {
+	var req as.PasswordRecoveryRequest
+	return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) {
+		return &emptyResponse, s.service.RecoverPassword(ctx, tx, &req)
+	})
+}
+
 func (s *AccountServer) handleCreateApplicationSpecificPassword(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) {
 	var req as.CreateApplicationSpecificPasswordRequest
 	return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) {
@@ -122,6 +136,7 @@ func (s *AccountServer) Handler() http.Handler {
 	h := http.NewServeMux()
 	h.HandleFunc("/api/user/get", s.withTx(s.handleGetUser))
 	h.HandleFunc("/api/user/change_password", s.withTx(s.handleChangeUserPassword))
+	h.HandleFunc("/api/user/set_password_recovery_hint", s.withTx(s.handleSetPasswordRecoveryHint))
 	h.HandleFunc("/api/user/enable_otp", s.withTx(s.handleEnableOTP))
 	h.HandleFunc("/api/user/disable_otp", s.withTx(s.handleDisableOTP))
 	h.HandleFunc("/api/app_specific_password/create", s.withTx(s.handleCreateApplicationSpecificPassword))
@@ -129,6 +144,7 @@ func (s *AccountServer) Handler() http.Handler {
 	h.HandleFunc("/api/resource/enable", s.withTx(s.handleEnableResource))
 	h.HandleFunc("/api/resource/disable", s.withTx(s.handleDisableResource))
 	h.HandleFunc("/api/resource/move", s.withTx(s.handleMoveResource))
+	h.HandleFunc("/api/recover_password", s.withTx(s.handleRecoverPassword))
 	return h
 }
 
diff --git a/service.go b/service.go
index 90410d33715a7fb12e9f937d0b9fac1e405896a8..8f5d65806557ef943e7bee5a354553cfdf17b2b6 100644
--- a/service.go
+++ b/service.go
@@ -42,6 +42,7 @@ type TX interface {
 
 	GetUser(context.Context, string) (*User, error)
 	SetUserPassword(context.Context, *User, string) error
+	SetPasswordRecoveryHint(context.Context, *User, string, string) error
 	GetUserEncryptionKeys(context.Context, *User) ([]*UserEncryptionKey, error)
 	SetUserEncryptionKeys(context.Context, *User, []*UserEncryptionKey) error
 	SetUserEncryptionPublicKey(context.Context, *User, []byte) error