diff --git a/actions.go b/actions.go
index 61f0abfbf40f3a3d5e68424e57b2dd3ff207b546..f805d7792bc0d3866d95f3822262d73c729ebd92 100644
--- a/actions.go
+++ b/actions.go
@@ -173,12 +173,23 @@ func (s *AccountService) RecoverPassword(ctx context.Context, tx TX, req *Passwo
 	if err != nil {
 		return err
 	}
-	// TODO: authenticate with the secret recovery password.
+
+	// Authenticate the secret recovery password.
+	if !pwhash.ComparePassword(tx.GetUserRecoveryEncryptedPassword(ctx, user), req.RecoveryPassword) {
+		return ErrUnauthorized
+	}
+
 	ctx = context.WithValue(ctx, authUserCtxKey, req.Username)
 
 	return s.withRequest(ctx, tx, req, user, func(ctx context.Context) error {
+		s.audit.Log(ctx, ResourceID{}, "password reset via account recovery")
+
 		// Change the user password (the recovery password does not change).
-		return s.changeUserPasswordAndUpdateEncryptionKeys(ctx, tx, user, req.RecoveryPassword, req.Password)
+		if err := s.changeUserPasswordAndUpdateEncryptionKeys(ctx, tx, user, req.RecoveryPassword, req.Password); err != nil {
+			return err
+		}
+		// Disable 2FA.
+		return s.disable2FA(ctx, tx, user)
 	})
 }
 
diff --git a/actions_test.go b/actions_test.go
index c0423b31e5dcc0b2e9a14dd0799c1591da80b102..f9cf9245ae4cb85f43897edef67a0e132a11b678 100644
--- a/actions_test.go
+++ b/actions_test.go
@@ -5,6 +5,7 @@ import (
 	"errors"
 	"testing"
 
+	"git.autistici.org/ai3/go-common/pwhash"
 	sso "git.autistici.org/id/go-sso"
 )
 
@@ -12,6 +13,7 @@ type fakeBackend struct {
 	users                map[string]*User
 	resources            map[string]map[string]*Resource
 	passwords            map[string]string
+	recoveryPasswords    map[string]string
 	appSpecificPasswords map[string][]*AppSpecificPasswordInfo
 	encryptionKeys       map[string][]*UserEncryptionKey
 }
@@ -62,9 +64,19 @@ func (b *fakeBackend) SetUserPassword(_ context.Context, user *User, password st
 }
 
 func (b *fakeBackend) SetPasswordRecoveryHint(_ context.Context, user *User, hint, response string) error {
+	b.users[user.Name].PasswordRecoveryHint = hint
+	b.recoveryPasswords[user.Name] = response
 	return nil
 }
 
+func (b *fakeBackend) GetUserEncryptedPassword(_ context.Context, user *User) string {
+	return b.passwords[user.Name]
+}
+
+func (b *fakeBackend) GetUserRecoveryEncryptedPassword(_ context.Context, user *User) string {
+	return b.recoveryPasswords[user.Name]
+}
+
 func (b *fakeBackend) SetResourcePassword(_ context.Context, r *Resource, password string) error {
 	b.passwords[r.ID.String()] = password
 	return nil
@@ -138,9 +150,13 @@ func (v *fakeValidator) Validate(tkt, nonce, service string, _ []string) (*sso.T
 	}, nil
 }
 
-func (b *fakeBackend) addUser(user *User) {
+func (b *fakeBackend) addUser(user *User, pw, rpw string) {
 	b.users[user.Name] = user
 	b.resources[user.Name] = make(map[string]*Resource)
+	b.passwords[user.Name] = pwhash.Encrypt(pw)
+	if rpw != "" {
+		b.recoveryPasswords[user.Name] = pwhash.Encrypt(rpw)
+	}
 	for _, r := range user.Resources {
 		b.resources[user.Name][r.ID.String()] = r
 	}
@@ -154,6 +170,7 @@ func createFakeBackend() *fakeBackend {
 			"": make(map[string]*Resource),
 		},
 		passwords:            make(map[string]string),
+		recoveryPasswords:    make(map[string]string),
 		appSpecificPasswords: make(map[string][]*AppSpecificPasswordInfo),
 		encryptionKeys:       make(map[string][]*UserEncryptionKey),
 	}
@@ -177,7 +194,7 @@ func createFakeBackend() *fakeBackend {
 				},
 			},
 		},
-	})
+	}, "password", "recoverypassword")
 	return fb
 }
 
@@ -267,6 +284,9 @@ func TestService_ChangePassword(t *testing.T) {
 		newPassword string
 		expectedOk  bool
 	}{
+		// First, fail cur_password authentication.
+		{"BADPASS", "new_password", false},
+
 		// Ordering is important as it is meant to emulate
 		// setting the password, failing to reset it, then
 		// succeeding.
@@ -524,3 +544,27 @@ func TestService_ResetResourcePassword(t *testing.T) {
 		t.Fatal("oops, it appears that the password was stored in cleartext on the backend")
 	}
 }
+
+func TestService_Recovery(t *testing.T) {
+	svc, tx := testService("")
+
+	// Bad recovery response.
+	err := svc.RecoverPassword(context.Background(), tx, &PasswordRecoveryRequest{
+		Username:         "testuser",
+		RecoveryPassword: "BADPASS",
+		Password:         "new_password",
+	})
+	if err == nil {
+		t.Fatal("oops, recovered account with bad password")
+	}
+
+	// Successful account recovery.
+	err = svc.RecoverPassword(context.Background(), tx, &PasswordRecoveryRequest{
+		Username:         "testuser",
+		RecoveryPassword: "recoverypassword",
+		Password:         "new_password",
+	})
+	if err != nil {
+		t.Fatalf("RecoverPassword failed: %v", err)
+	}
+}
diff --git a/backend/model.go b/backend/model.go
index aa7590b19eb3694d7f30e9bfef7d8c0d27ac5d6e..9a9fa95b8d13a5391523fb27e0bb95733e87ef38 100644
--- a/backend/model.go
+++ b/backend/model.go
@@ -258,6 +258,24 @@ func (tx *backendTX) SetUserPassword(ctx context.Context, user *accountserver.Us
 	return nil
 }
 
+func (tx *backendTX) GetUserEncryptedPassword(ctx context.Context, user *accountserver.User) string {
+	values := tx.readAttributeValues(ctx, tx.getUserDN(user), passwordLDAPAttr)
+	if len(values) > 0 {
+		// Remove legacy LDAP encryption prefix.
+		return strings.TrimPrefix(values[0], "{crypt}")
+	}
+	return ""
+}
+
+func (tx *backendTX) GetUserRecoveryEncryptedPassword(ctx context.Context, user *accountserver.User) string {
+	values := tx.readAttributeValues(ctx, tx.getUserDN(user), recoveryResponseLDAPAttr)
+	if len(values) > 0 {
+		// Remove legacy LDAP encryption prefix.
+		return strings.TrimPrefix(values[0], "{crypt}")
+	}
+	return ""
+}
+
 func (tx *backendTX) SetPasswordRecoveryHint(ctx context.Context, user *accountserver.User, hint, response string) error {
 	dn := tx.getUserDN(user)
 	tx.setAttr(dn, recoveryHintLDAPAttr, hint)
diff --git a/errors.go b/errors.go
index 3f7788eef4821b30d7d24ea7c0e577451463fce1..50a7c2299112c3f332fb056cce648846c7ddad0f 100644
--- a/errors.go
+++ b/errors.go
@@ -4,7 +4,7 @@ import "errors"
 
 var (
 	// ErrUnauthorized means that the request failed due to lack of authorization.
-	ErrUnauthorized = errors.New("unauthorized")
+	ErrUnauthorized = newAuthError(errors.New("unauthorized"))
 
 	// ErrUserNotFound is returned when a user object is not found.
 	ErrUserNotFound = errors.New("user not found")
diff --git a/integrationtest/integration_test.go b/integrationtest/integration_test.go
index 039b7d3890bd32f20a779b19d95f467cb0ec7952..0b00418f6a41de88ad258d285006c157cfa8e4cd 100644
--- a/integrationtest/integration_test.go
+++ b/integrationtest/integration_test.go
@@ -198,11 +198,7 @@ func TestIntegration_ChangeUserPassword(t *testing.T) {
 				Username: "uno@investici.org",
 				SSO:      c.ssoTicket("uno@investici.org"),
 			},
-			// Since the user in the test fixture does
-			// *not* have encryption key, and
-			// authenticateWithPassword is currently a
-			// no-op, this test will pass.
-			CurPassword: "ha ha, really not the current password",
+			CurPassword: "password",
 		},
 		Password: "new_password",
 	}, nil)
diff --git a/integrationtest/testdata/test1.ldif b/integrationtest/testdata/test1.ldif
index 9bf6880683b247b165f7b48ccd62866470c24af8..b2a5140a188774340156146effda8eedb7c1236f 100644
--- a/integrationtest/testdata/test1.ldif
+++ b/integrationtest/testdata/test1.ldif
@@ -18,9 +18,8 @@ givenName: Private
 shadowLastChange: 12345
 shadowWarning: 7
 preferredLanguage: it
-userPassword:: e2NyeXB0fSQ2JG1wVzdTZHZROG52OFJabE8kcFNqbm1hLi9CZDNIWU1hL29sMGZ
- FRmZrS3pWRjAxUkkzMnpISEoxNnc2V2xaajFuSFVzMmd4ZXZIVDdoemdELnJ5Lk1BZWM3REptZVZ5
- WEtkeDV0QTE=
+userPassword:: e2NyeXB0fSQ2JG1wVzdTZHZROG52OFJabE8kSTRmQldkVUpFeVVsb0dpdVpnYm8yNTlVVFpJMi9y
+ Vk5QODdZU04zVE0ud2F5MmR2UndYNmE0NHVVV2drWC9ac25HOGF3R0RYWFRmMDVNVXhNbGlnSDA=
 
 dn: mail=uno@investici.org,uid=uno@investici.org,ou=People,dc=example,dc=com
 status: active
diff --git a/service.go b/service.go
index 3c82d693b3164f8ecb3a9bda09edfa7e6dfd8170..4f2c06d04db91a9ee07adcaa1318b4ed5f440aae 100644
--- a/service.go
+++ b/service.go
@@ -6,6 +6,7 @@ import (
 	"log"
 	"reflect"
 
+	"git.autistici.org/ai3/go-common/pwhash"
 	"git.autistici.org/id/go-sso"
 )
 
@@ -54,6 +55,13 @@ type TX interface {
 	DeleteApplicationSpecificPassword(context.Context, *User, string) error
 	SetUserTOTPSecret(context.Context, *User, string) error
 	DeleteUserTOTPSecret(context.Context, *User) error
+
+	// The Get*EncryptedPassword methods allow us to perform
+	// authentication within the accountserver, skipping one round
+	// trip to the auth-server, given that we already have the
+	// necessary information.
+	GetUserEncryptedPassword(context.Context, *User) string
+	GetUserRecoveryEncryptedPassword(context.Context, *User) string
 }
 
 // FindResourceRequest contains parameters for searching a resource by name.
@@ -177,7 +185,7 @@ func (s *authService) authorizeAdminGeneric(ctx context.Context, tx TX, ssoTicke
 	// Requests are allowed if the SSO ticket corresponds to an admin, or if
 	// it identifies the same user that we're querying.
 	if !s.isAdmin(tkt) {
-		return nil, newAuthError(ErrUnauthorized)
+		return nil, ErrUnauthorized
 	}
 
 	ctx = context.WithValue(ctx, authUserCtxKey, tkt.User)
@@ -194,7 +202,7 @@ func (s *authService) authorizeAdmin(ctx context.Context, tx TX, req RequestBase
 	// Requests are allowed if the SSO ticket corresponds to an admin, or if
 	// it identifies the same user that we're querying.
 	if !s.isAdmin(tkt) {
-		return nil, nil, newAuthError(ErrUnauthorized)
+		return nil, nil, ErrUnauthorized
 	}
 
 	ctx = context.WithValue(ctx, authUserCtxKey, tkt.User)
@@ -213,7 +221,7 @@ func (s *authService) authorizeUser(ctx context.Context, tx TX, req RequestBase)
 	// Requests are allowed if the SSO ticket corresponds to an admin, or if
 	// it identifies the same user that we're querying.
 	if !s.isAdmin(tkt) && tkt.User != req.Username {
-		return nil, nil, newAuthError(ErrUnauthorized)
+		return nil, nil, ErrUnauthorized
 	}
 
 	user, err := getUserOrDie(ctx, tx, req.Username)
@@ -225,8 +233,14 @@ func (s *authService) authorizeUser(ctx context.Context, tx TX, req RequestBase)
 // user password. Used for account-privileged operations related to
 // credential manipulation.
 func (s *authService) authorizeUserWithPassword(ctx context.Context, tx TX, req PrivilegedRequestBase) (context.Context, *User, error) {
-	// TODO: call out to the auth-server?
-	return s.authorizeUser(ctx, tx, req.RequestBase)
+	rctx, user, err := s.authorizeUser(ctx, tx, req.RequestBase)
+	if err != nil {
+		return nil, nil, err
+	}
+	if !pwhash.ComparePassword(tx.GetUserEncryptedPassword(ctx, user), req.CurPassword) {
+		return nil, nil, ErrUnauthorized
+	}
+	return rctx, user, nil
 }
 
 // Extended version of authorizeUser that checks access to a specific
@@ -249,7 +263,7 @@ func (s *authService) authorizeResource(ctx context.Context, tx TX, req Resource
 	}
 
 	if !s.isAdmin(tkt) && !canAccessResource(tkt.User, r) {
-		return nil, nil, newAuthError(ErrUnauthorized)
+		return nil, nil, ErrUnauthorized
 	}
 
 	ctx = context.WithValue(ctx, authUserCtxKey, tkt.User)