diff --git a/API.md b/API.md
index 43e513962d6feacd4c2b2ed9f523cf1c691df8da..ccd2d2511a586a22048bff0114c4b009162dd57a 100644
--- a/API.md
+++ b/API.md
@@ -314,6 +314,21 @@ Request parameters:
 * `sso` - SSO ticket
 * `asp_id` - ID of the app-specific password
 
+### `/api/user/update`
+
+Allows the caller to update a (very limited) selected set of fields on
+a user object. It is a catch-all function for very simple changes that
+don't justify their own specialized method.
+
+Non-authentication request attributes are all optional.
+
+Request parameters:
+
+* `username` - user to fetch
+* `sso` - SSO ticket
+* `lang` - set the preferred language for this user
+* `u2f_registrations` - set the list of U2F registrations
+
 ### `/api/user/create`
 
 Create a new user (admin-only). Will also create all the resources
diff --git a/actions.go b/actions.go
index 6e2ed414ccb640187e4da643647eac104f52d48a..bb2dc5e9e619d71bac4158fecf154ee26b2b8ad7 100644
--- a/actions.go
+++ b/actions.go
@@ -11,6 +11,7 @@ import (
 	"git.autistici.org/ai3/go-common/pwhash"
 	"github.com/pquerna/otp/totp"
 	"github.com/sethvargo/go-password/password"
+	"github.com/tstranex/u2f"
 )
 
 // RequestBase contains parameters shared by all request types.
@@ -839,6 +840,50 @@ func (s *AccountService) CreateUser(ctx context.Context, tx TX, req *CreateUserR
 	return &resp, err
 }
 
+// UpdateUserRequest is the request type for AccountService.UpdateUser().
+type UpdateUserRequest struct {
+	RequestBase
+
+	Lang             string              `json:"lang,omitempty"`
+	U2FRegistrations []*u2f.Registration `json:"u2f_registrations,omitempty"`
+}
+
+// Validate the request.
+func (r *UpdateUserRequest) Validate(_ context.Context, _ *AccountService) error {
+	if len(r.U2FRegistrations) > 20 {
+		return errors.New("too many U2F registrations")
+	}
+
+	// TODO: better validation of the language code!
+	if len(r.Lang) > 2 {
+		return errors.New("invalid language code")
+	}
+
+	return nil
+}
+
+// UpdateUser allows the caller to update a (very limited) selected
+// set of fields on a User object. It is a catch-all function for very
+// simple changes that don't justify their own specialized method.
+func (s *AccountService) UpdateUser(ctx context.Context, tx TX, req *UpdateUserRequest) (*User, error) {
+	var resp *User
+	err := s.handleUserRequest(ctx, tx, req, s.authUser(req.RequestBase), func(ctx context.Context, user *User) error {
+		if req.Lang != "" {
+			user.Lang = req.Lang
+		}
+
+		// TODO: check if this allows the caller to use an
+		// empty list to unset the field completely.
+		if req.U2FRegistrations != nil {
+			user.U2FRegistrations = req.U2FRegistrations
+		}
+
+		resp = user
+		return tx.UpdateUser(ctx, user)
+	})
+	return resp, err
+}
+
 func randomBase64(n int) string {
 	b := make([]byte, n*3/4)
 	_, err := rand.Read(b[:])
diff --git a/actions_test.go b/actions_test.go
index 81cda0784dec58e490311423547ae683ca5919e7..ed87188ad328f816d7ca33701ff8ef7ad1ddd6dc 100644
--- a/actions_test.go
+++ b/actions_test.go
@@ -28,6 +28,11 @@ func (b *fakeBackend) GetUser(_ context.Context, username string) (*User, error)
 	return b.users[username], nil
 }
 
+func (b *fakeBackend) UpdateUser(_ context.Context, user *User) error {
+	b.users[user.Name] = user
+	return nil
+}
+
 func (b *fakeBackend) CreateUser(_ context.Context, user *User) error {
 	b.users[user.Name] = user
 	return nil
diff --git a/backend/model.go b/backend/model.go
index e2db1b4ccc47f56d01d451ce03548b7e273d5d29..aa7590b19eb3694d7f30e9bfef7d8c0d27ac5d6e 100644
--- a/backend/model.go
+++ b/backend/model.go
@@ -5,6 +5,7 @@ import (
 	"strings"
 
 	ldaputil "git.autistici.org/ai3/go-common/ldap"
+	"github.com/tstranex/u2f"
 	"gopkg.in/ldap.v2"
 
 	"git.autistici.org/ai3/accountserver"
@@ -20,6 +21,7 @@ const (
 	storagePublicKeyLDAPAttr  = "storagePublicKey"
 	storagePrivateKeyLDAPAttr = "storageEncryptedSecretKey"
 	passwordLDAPAttr          = "userPassword"
+	u2fRegistrationsLDAPAttr  = "u2fRegistration"
 )
 
 // backend is the interface to an LDAP-backed user database.
@@ -103,6 +105,7 @@ func newUser(entry *ldap.Entry) (*accountserver.User, error) {
 		Has2FA: (entry.GetAttributeValue(totpSecretLDAPAttr) != ""),
 		//HasEncryptionKeys: (len(entry.GetAttributeValues("storageEncryptionKey")) > 0),
 		//PasswordRecoveryHint: entry.GetAttributeValue("recoverQuestion"),
+		U2FRegistrations: decodeU2FRegistrations(entry.GetAttributeValues(u2fRegistrationsLDAPAttr)),
 	}
 	if user.Lang == "" {
 		user.Lang = "en"
@@ -125,10 +128,42 @@ func userToLDAP(user *accountserver.User) (attrs []ldap.PartialAttribute) {
 		{Type: "shadowWarning", Vals: []string{"7"}},
 		{Type: "shadowMax", Vals: []string{"99999"}},
 		{Type: preferredLanguageLDAPAttr, Vals: s2l(user.Lang)},
+		{Type: u2fRegistrationsLDAPAttr, Vals: encodeU2FRegistrations(user.U2FRegistrations)},
 	}...)
 	return
 }
 
+func decodeU2FRegistration(enc string) (*u2f.Registration, error) {
+	var reg u2f.Registration
+	if err := reg.UnmarshalBinary([]byte(enc)); err != nil {
+		return nil, err
+	}
+	return &reg, nil
+}
+
+func encodeU2FRegistration(r *u2f.Registration) string {
+	b, _ := r.MarshalBinary()
+	return string(b)
+}
+
+func decodeU2FRegistrations(encRegs []string) []*u2f.Registration {
+	var out []*u2f.Registration
+	for _, enc := range encRegs {
+		if r, err := decodeU2FRegistration(enc); err == nil {
+			out = append(out, r)
+		}
+	}
+	return out
+}
+
+func encodeU2FRegistrations(regs []*u2f.Registration) []string {
+	var out []string
+	for _, r := range regs {
+		out = append(out, encodeU2FRegistration(r))
+	}
+	return out
+}
+
 func (tx *backendTX) getUserDN(user *accountserver.User) string {
 	return joinDN("uid="+user.Name, "ou=People", tx.backend.baseDN)
 }
@@ -152,6 +187,15 @@ func (tx *backendTX) CreateUser(ctx context.Context, user *accountserver.User) e
 	return nil
 }
 
+// UpdateUser updates values for the user only (not the resources).
+func (tx *backendTX) UpdateUser(ctx context.Context, user *accountserver.User) error {
+	dn := tx.getUserDN(user)
+	for _, attr := range userToLDAP(user) {
+		tx.setAttr(dn, attr.Type, attr.Vals...)
+	}
+	return nil
+}
+
 // GetUser returns a user.
 func (tx *backendTX) GetUser(ctx context.Context, username string) (*accountserver.User, error) {
 	// First of all, find the main user object, and just that one.
diff --git a/server/server.go b/server/server.go
index f2948f376f0e96a46fe803632230263ccd0c085e..ce8efa4c07cab73776cc697e86f0c0a4f5c45cc3 100644
--- a/server/server.go
+++ b/server/server.go
@@ -44,6 +44,13 @@ func (s *AccountServer) handleCreateUser(tx as.TX, w http.ResponseWriter, r *htt
 	})
 }
 
+func (s *AccountServer) handleUpdateUser(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) {
+	var req as.UpdateUserRequest
+	return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) {
+		return s.service.UpdateUser(ctx, tx, &req)
+	})
+}
+
 func (s *AccountServer) handleChangeUserPassword(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) {
 	var req as.ChangeUserPasswordRequest
 	return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) {
@@ -171,6 +178,7 @@ func (s *AccountServer) Handler() http.Handler {
 	h := http.NewServeMux()
 	h.HandleFunc("/api/user/get", s.withTx(s.handleGetUser))
 	h.HandleFunc("/api/user/create", s.withTx(s.handleCreateUser))
+	h.HandleFunc("/api/user/update", s.withTx(s.handleUpdateUser))
 	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))
diff --git a/service.go b/service.go
index bfb8c10fa3b486a93185bb004b4f7f37ee797a4b..8b46fcb1d24b11fc3be02b0e76f4cd2c5574ff55 100644
--- a/service.go
+++ b/service.go
@@ -43,6 +43,7 @@ type TX interface {
 	HasAnyResource(context.Context, []FindResourceRequest) (bool, error)
 
 	GetUser(context.Context, string) (*User, error)
+	UpdateUser(context.Context, *User) error
 	CreateUser(context.Context, *User) error
 	SetUserPassword(context.Context, *User, string) error
 	SetPasswordRecoveryHint(context.Context, *User, string, string) error
diff --git a/types.go b/types.go
index 447f32e23da5710ca682d1957e7834c99f390cae..68a3b526bc829893ab9f5b55c421f281a594f053 100644
--- a/types.go
+++ b/types.go
@@ -7,6 +7,8 @@ import (
 	"path/filepath"
 	"strings"
 	"time"
+
+	"github.com/tstranex/u2f"
 )
 
 // Some considerations about the model design:
@@ -31,6 +33,8 @@ type User struct {
 
 	AppSpecificPasswords []*AppSpecificPasswordInfo `json:"app_specific_passwords,omitempty"`
 
+	U2FRegistrations []*u2f.Registration `json:"u2f_registrations"`
+
 	Resources []*Resource `json:"resources,omitempty"`
 }