Merge changes from topic 'http-password'

* changes:
  Migrate external IDs to NoteDb (part 1)
  gerrit-server: use hashed passwords for HTTP.
  AccountByEmailCacheImpl: Consider emails from all external IDs on load


* submodules:
* Update plugins/cookbook-plugin from branch 'master'
  - Merge "Remove for HTTP digest auth from examples."
  - Remove for HTTP digest auth from examples.
    
    Change-Id: I495ee8140cbe2ae12510a4d4cbc2c8360b135b33
    
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index a0e00a5..f82566b 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -141,6 +141,14 @@
 directory using either an anonymous request, or the configured
 <<ldap.username,ldap.username>> identity. Gerrit can also use kerberos if
 <<ldap.authentication,ldap.authentication>> is set to `GSSAPI`.
++
+If link:#auth.gitBasicAuthPolicy[`auth.gitBasicAuthPolicy`] is set to `HTTP`,
+the randomly generated HTTP password is used for authentication. On the other hand,
+if link:#auth.gitBasicAuthPolicy[`auth.gitBasicAuthPolicy`] is set to `HTTP_LDAP`,
+the password in the request is first checked against the HTTP password and, if
+it does not match, it is then validated against the LDAP password.
+Service users that only exist in the Gerrit database are authenticated by their
+HTTP passwords.
 
 * `LDAP_BIND`
 +
@@ -164,6 +172,12 @@
 Site owners have to register their application before getting started. Note
 that provider specific plugins must be used with this authentication scheme.
 +
+Git clients may send OAuth 2 access tokens instead of passwords in the Basic
+authentication header. Note that provider specific plugins must be installed to
+facilitate this authentication scheme. If multiple OAuth 2 provider plugins are
+installed one of them must be selected as default with the
+`auth.gitOAuthProvider` option.
++
 * `DEVELOPMENT_BECOME_ANY_ACCOUNT`
 +
 *DO NOT USE*.  Only for use in a development environment.
@@ -279,7 +293,7 @@
 [[auth.httpHeader]]auth.httpHeader::
 +
 HTTP header to trust the username from, or unset to select HTTP basic
-or digest authentication.  Only used if `auth.type` is set to `HTTP`.
+authentication.  Only used if `auth.type` is set to `HTTP`.
 
 [[auth.httpDisplaynameHeader]]auth.httpDisplaynameHeader::
 +
@@ -445,45 +459,16 @@
 the container.
 +
 This parameter only affects git over http traffic. If set to false
-then Gerrit will do the authentication (using DIGEST authentication).
+then Gerrit will do the authentication (using Basic authentication).
 +
 By default this is set to false.
 
-[[auth.gitBasicAuth]]auth.gitBasicAuth::
-+
-If true then Git over HTTP and HTTP/S traffic is authenticated using
-standard BasicAuth. Depending on the configured `auth.type`, credentials
-are validated against the randomly generated HTTP password, against LDAP
-(`auth.type = LDAP`) or against an OAuth 2 provider (`auth.type = OAUTH`).
-+
-This parameter affects git over HTTP traffic and access to the REST
-API. If set to false then Gerrit will authenticate through DIGEST
-authentication and the randomly generated HTTP password in the Gerrit
-database.
-+
-When `auth.type` is `LDAP`, users should authenticate using their LDAP passwords.
-However, if link:#auth.gitBasicAuthPolicy[`auth.gitBasicAuthPolicy`] is set to `HTTP`,
-the randomly generated HTTP password is used exclusively. In the other hand,
-if link:#auth.gitBasicAuthPolicy[`auth.gitBasicAuthPolicy`] is set to `HTTP_LDAP`,
-the password in the request is first checked against the HTTP password and, if
-it does not match, it is then validated against the LDAP password.
-Service users that only exist in the Gerrit database are authenticated by their
-HTTP passwords.
-+
-When `auth.type` is `OAUTH`, Git clients may send OAuth 2 access tokens
-instead of passwords in the Basic authentication header. Note that provider
-specific plugins must be installed to facilitate this authentication scheme.
-If multiple OAuth 2 provider plugins are installed one of them must be
-selected as default with the `auth.gitOAuthProvider` option.
-+
-By default this is set to false.
 
 [[auth.gitBasicAuthPolicy]]auth.gitBasicAuthPolicy::
 +
-When `auth.type` is `LDAP` and BasicAuth (i.e., link:#auth.gitBasicAuth[`auth.gitBasicAuth`]
-is set to true), it allows using either the generated HTTP password, the LDAP
-password or both to authenticate Git over HTTP and REST API requests. The
-supported values are:
+When `auth.type` is `LDAP`, it allows using either the generated HTTP password,
+the LDAP password, or both, to authenticate Git over HTTP and REST API
+requests. The supported values are:
 +
 *`HTTP`
 +
diff --git a/Documentation/config-sso.txt b/Documentation/config-sso.txt
index 5f35d73..04949bf 100644
--- a/Documentation/config-sso.txt
+++ b/Documentation/config-sso.txt
@@ -88,7 +88,7 @@
 Login using the other identity can only be performed after the linking is
 successful.
 
-== HTTP Basic/Digest Authentication
+== HTTP Basic Authentication
 
 When using HTTP authentication, Gerrit assumes that the servlet
 container or the frontend web server has performed all user
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index bb9c5c7..9a289e1 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -1443,7 +1443,7 @@
 ----
   curl -X POST -H "Content-Type: application/json" \
     -d '{message: "François", french: true}' \
-    --digest --user joe:secret \
+    --user joe:secret \
     http://host:port/a/changes/1/revisions/1/cookbook~say-hello
   "Bonjour François from change 1, patch set 1!"
 ----
@@ -2451,18 +2451,18 @@
 shows the error dialog. This means currently plugins cannot do any
 error handling and e.g. ignore expected errors.
 
-In the following example the REST endpoint would return '404 Not Found'
-if there is no HTTP password and the Gerrit core UI would display an
-error dialog for this. However having no HTTP password is not an error
-and the plugin may like to handle this case.
+In the following example the REST endpoint would return '404 Not
+Found' if the user has no username and the Gerrit core UI would
+display an error dialog for this. However having no username is
+not an error and the plugin may like to handle this case.
 
 [source,java]
 ----
-new RestApi("accounts").id("self").view("password.http")
+new RestApi("accounts").id("self").view("username")
     .get(new AsyncCallback<NativeString>() {
 
   @Override
-  public void onSuccess(NativeString httpPassword) {
+  public void onSuccess(NativeString username) {
     // TODO
   }
 
diff --git a/Documentation/dev-rest-api.txt b/Documentation/dev-rest-api.txt
index 308d4bd..fec9c97 100644
--- a/Documentation/dev-rest-api.txt
+++ b/Documentation/dev-rest-api.txt
@@ -47,7 +47,7 @@
 Example to set a Gerrit project's link:rest-api-projects.html#set-project-description[description]:
 
 ----
- curl -X PUT --digest --user john:2LlAB3K9B0PF --data-binary @project-desc.txt --header "Content-Type: application/json; charset=UTF-8" http://localhost:8080/a/projects/myproject/description
+ curl -X PUT --user john:2LlAB3K9B0PF --data-binary @project-desc.txt --header "Content-Type: application/json; charset=UTF-8" http://localhost:8080/a/projects/myproject/description
 ----
 
 === Authentication
@@ -56,7 +56,7 @@
 the command line:
 
 ----
- curl --digest --user username:password http://localhost:8080/a/path/to/api/
+ curl --user username:password http://localhost:8080/a/path/to/api/
 ----
 
 This makes it easy to switch users for testing of permissions.
@@ -65,7 +65,7 @@
 file (on Windows, `_netrc`):
 
 ----
- curl --digest -n http://localhost:8080/a/path/to/api/
+ curl -n http://localhost:8080/a/path/to/api/
 ----
 
 In both cases, the password should be the user's link:user-upload.html#http[HTTP password].
@@ -75,7 +75,7 @@
 To verify the headers returned from a REST API call, use `curl` in verbose mode:
 
 ----
-  curl -v -n --digest -X DELETE http://localhost:8080/a/path/to/api/
+  curl -v -n -X DELETE http://localhost:8080/a/path/to/api/
 ----
 
 The headers on both the request and the response will be printed.
diff --git a/Documentation/pgm-LocalUsernamesToLowerCase.txt b/Documentation/pgm-LocalUsernamesToLowerCase.txt
index 1136ced..f295225 100644
--- a/Documentation/pgm-LocalUsernamesToLowerCase.txt
+++ b/Documentation/pgm-LocalUsernamesToLowerCase.txt
@@ -9,7 +9,6 @@
 --
 _java_ -jar gerrit.war _LocalUsernamesToLowerCase
   -d <SITE_PATH>
-  [--threads]
 --
 
 == DESCRIPTION
@@ -40,10 +39,6 @@
 	Location of the gerrit.config file, and all other per-site
 	configuration data, supporting libraries and log files.
 
---threads::
-	Number of threads to perform the scan work with.  Defaults to
-	twice the number of CPUs available.
-
 == CONTEXT
 This command can only be run on a server which has direct
 connectivity to the metadata database.
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index f1b4abf..d4fa912 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -458,31 +458,6 @@
 
 If the account was already inactive the response is "`409 Conflict`".
 
-[[get-http-password]]
-=== Get HTTP Password
---
-'GET /accounts/link:#account-id[\{account-id\}]/password.http'
---
-
-Retrieves the HTTP password of an account.
-
-.Request
-----
-  GET /accounts/john.doe@example.com/password.http HTTP/1.0
-----
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Content-Disposition: attachment
-  Content-Type: application/json; charset=UTF-8
-
-  )]}'
-  "Qmxlc21ydCB1YmVyIGFsbGVzIGluIGRlciBXZWx0IQ"
-----
-
-If the account does not have an HTTP password the response is "`404 Not Found`".
-
 [[set-http-password]]
 === Set/Generate HTTP Password
 --
@@ -1028,12 +1003,12 @@
   }
 ----
 
-Administrator that has authenticated with digest authentication:
+Administrator that has authenticated with basic authentication:
 
 .Request
 ----
   GET /a/accounts/self/capabilities HTTP/1.0
-  Authorization: Digest username="admin", realm="Gerrit Code Review", nonce="...
+  Authorization: Basic ABCDECF..
 ----
 
 .Response
@@ -1075,7 +1050,7 @@
 .Request
 ----
   GET /a/accounts/self/capabilities?q=createAccount&q=createGroup HTTP/1.0
-  Authorization: Digest username="admin", realm="Gerrit Code Review", nonce="...
+  Authorization: Basic ABCDEF...
 ----
 
 .Response
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index cd4f745..fd35353 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -470,9 +470,9 @@
 E.g. this could be used to flush all caches:
 +
 ----
-  for c in $(curl --digest --user jdoe:TNAuLkXsIV7w http://gerrit/a/config/server/caches/?format=TEXT_LIST | base64 -D)
+  for c in $(curl --user jdoe:TNAuLkXsIV7w http://gerrit/a/config/server/caches/?format=TEXT_LIST | base64 -D)
   do
-    curl --digest --user jdoe:TNAuLkXsIV7w -X POST http://gerrit/a/config/server/caches/$c/flush
+    curl --user jdoe:TNAuLkXsIV7w -X POST http://gerrit/a/config/server/caches/$c/flush
   done
 ----
 
@@ -1270,11 +1270,6 @@
 The link:config-gerrit.html#auth.httpPasswordUrl[URL to obtain an HTTP
 password]. Only set if link:config-gerrit.html#auth.type[authentication
 type] is `CUSTOM_EXTENSION`.
-|`is_git_basic_auth`          |optional, not set if `false`|
-Whether link:config-gerrit.html#auth.gitBasicAuth[basic authentication
-is used for Git over HTTP/HTTPS]. Only set if
-link:config-gerrit.html#auth.type[authentication type] is is `LDAP` or
-`LDAP_BIND`.
 |`git_basic_auth_policy`      |optional|
 The link:config-gerrit.html#auth.gitBasicAuthPolicy[policy] to authenticate
 Git over HTTP and REST API requests when
diff --git a/Documentation/rest-api-plugins.txt b/Documentation/rest-api-plugins.txt
index dfe9f0e..ce0fdb7 100644
--- a/Documentation/rest-api-plugins.txt
+++ b/Documentation/rest-api-plugins.txt
@@ -87,7 +87,7 @@
 following curl command can be used:
 
 ----
-  curl --digest --user admin:TNNuLkWsIV8w -X PUT --data-binary @delete-project-2.8.jar 'http://gerrit:8080/a/plugins/delete-project'
+  curl --user admin:TNNuLkWsIV8w -X PUT --data-binary @delete-project-2.8.jar 'http://gerrit:8080/a/plugins/delete-project'
 ----
 
 As response a link:#plugin-info[PluginInfo] entity is returned that
diff --git a/Documentation/rest-api.txt b/Documentation/rest-api.txt
index 7f7e62e..7928512 100644
--- a/Documentation/rest-api.txt
+++ b/Documentation/rest-api.txt
@@ -36,10 +36,8 @@
 `/a/`. For example to authenticate to `/projects/`, request the URL
 `/a/projects/`.
 
-By default Gerrit uses HTTP digest authentication with the HTTP password
-from the user's account settings page. HTTP basic authentication is used
-if link:config-gerrit.html#auth.gitBasicAuth[`auth.gitBasicAuth`] is set
-to true in the Gerrit configuration.
+Gerrit uses HTTP basic authentication with the HTTP password from the
+user's account settings page.
 
 [[preconditions]]
 === Preconditions
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index 6d0c47a..9efbb21 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -18,10 +18,9 @@
 On Gerrit installations that do not support SSH authentication, the
 user must authenticate via HTTP/HTTPS.
 
-When link:config-gerrit.html#auth.gitBasicAuth[gitBasicAuth] is enabled,
-the user is authenticated using standard BasicAuth. Depending on the value of
-link:#auth.gitBasicAuthPolicy[auth.gitBasicAuthPolicy], credentials are
-validated using:
+The user is authenticated using standard BasicAuth. Depending on the
+value of link:#auth.gitBasicAuthPolicy[auth.gitBasicAuthPolicy],
+credentials are validated using:
 
 * The randomly generated HTTP password on the `HTTP Password` tab
   in the user settings page if `gitBasicAuthPolicy` is `HTTP`.
@@ -29,9 +28,10 @@
 * Both, the HTTP and the LDAP passwords (in this order) if `gitBasicAuthPolicy`
   is `HTTP_LDAP`.
 
-When gitBasicAuthPolicy is not `LDAP`, the user's HTTP credentials can be
-accessed within Gerrit by going to `Settings`, and then accessing the `HTTP
-Password` tab.
+When gitBasicAuthPolicy is not `LDAP`, the user's HTTP credentials can
+be regenerated by going to `Settings`, and then accessing the `HTTP
+Password` tab. Revocation can effectively be done by regenerating the
+password and then forgetting it.
 
 For Gerrit installations where an link:config-gerrit.html#auth.httpPasswordUrl[HTTP password URL]
 is configured, the password can be obtained by clicking on `Obtain Password`
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java
index de0c430..e136bb3 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -20,12 +20,13 @@
 
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountByEmailCache;
 import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.ExternalIdsUpdate;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.index.account.AccountIndexer;
@@ -39,8 +40,10 @@
 import com.jcraft.jsch.KeyPair;
 import java.io.ByteArrayOutputStream;
 import java.io.UnsupportedEncodingException;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 
 @Singleton
@@ -54,6 +57,7 @@
   private final AccountCache accountCache;
   private final AccountByEmailCache byEmailCache;
   private final AccountIndexer indexer;
+  private final ExternalIdsUpdate.Server externalIdsUpdate;
 
   @Inject
   AccountCreator(
@@ -63,7 +67,8 @@
       SshKeyCache sshKeyCache,
       AccountCache accountCache,
       AccountByEmailCache byEmailCache,
-      AccountIndexer indexer) {
+      AccountIndexer indexer,
+      ExternalIdsUpdate.Server externalIdsUpdate) {
     accounts = new HashMap<>();
     reviewDbProvider = schema;
     this.authorizedKeys = authorizedKeys;
@@ -72,6 +77,7 @@
     this.accountCache = accountCache;
     this.byEmailCache = byEmailCache;
     this.indexer = indexer;
+    this.externalIdsUpdate = externalIdsUpdate;
   }
 
   public synchronized TestAccount create(
@@ -83,18 +89,14 @@
     try (ReviewDb db = reviewDbProvider.open()) {
       Account.Id id = new Account.Id(db.nextAccountId());
 
-      AccountExternalId extUser =
-          new AccountExternalId(
-              id, new AccountExternalId.Key(AccountExternalId.SCHEME_USERNAME, username));
+      List<ExternalId> extIds = new ArrayList<>(2);
       String httpPass = "http-pass";
-      extUser.setPassword(httpPass);
-      db.accountExternalIds().insert(Collections.singleton(extUser));
+      extIds.add(ExternalId.createUsername(username, id, httpPass));
 
       if (email != null) {
-        AccountExternalId extMailto = new AccountExternalId(id, getEmailKey(email));
-        extMailto.setEmailAddress(email);
-        db.accountExternalIds().insert(Collections.singleton(extMailto));
+        extIds.add(ExternalId.createEmail(id, email));
       }
+      externalIdsUpdate.create().insert(db, extIds);
 
       Account a = new Account(id, TimeUtil.nowTs());
       a.setFullName(fullName);
@@ -157,10 +159,6 @@
     return checkNotNull(accounts.get(username), "No TestAccount created for %s", username);
   }
 
-  private AccountExternalId.Key getEmailKey(String email) {
-    return new AccountExternalId.Key(AccountExternalId.SCHEME_MAILTO, email);
-  }
-
   public static KeyPair genSshKey() throws JSchException {
     JSch jsch = new JSch();
     return KeyPair.genKeyPair(jsch, KeyPair.RSA);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 82aa576..0adf1a0 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -35,6 +35,7 @@
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AccountCreator;
@@ -61,8 +62,10 @@
 import com.google.gerrit.gpg.server.GpgKeys;
 import com.google.gerrit.gpg.testutil.TestKey;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.AccountByEmailCache;
+import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.ExternalIdsUpdate;
 import com.google.gerrit.server.account.WatchConfig;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.config.AllUsersName;
@@ -77,10 +80,10 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.EnumSet;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
 import org.bouncycastle.bcpg.ArmoredOutputStream;
@@ -111,10 +114,17 @@
 
   @Inject private AllUsersName allUsers;
 
-  private List<AccountExternalId> savedExternalIds;
+  @Inject private AccountByEmailCache byEmailCache;
+
+  @Inject private ExternalIdsUpdate.User externalIdsUpdateFactory;
+
+  private ExternalIdsUpdate externalIdsUpdate;
+  private List<ExternalId> savedExternalIds;
 
   @Before
   public void saveExternalIds() throws Exception {
+    externalIdsUpdate = externalIdsUpdateFactory.create();
+
     savedExternalIds = new ArrayList<>();
     savedExternalIds.addAll(getExternalIds(admin));
     savedExternalIds.addAll(getExternalIds(user));
@@ -126,9 +136,9 @@
       // savedExternalIds is null when we don't run SSH tests and the assume in
       // @Before in AbstractDaemonTest prevents this class' @Before method from
       // being executed.
-      db.accountExternalIds().delete(getExternalIds(admin));
-      db.accountExternalIds().delete(getExternalIds(user));
-      db.accountExternalIds().insert(savedExternalIds);
+      externalIdsUpdate.delete(db, getExternalIds(admin));
+      externalIdsUpdate.delete(db, getExternalIds(user));
+      externalIdsUpdate.insert(db, savedExternalIds);
     }
     accountCache.evict(admin.getId());
     accountCache.evict(user.getId());
@@ -146,7 +156,7 @@
     }
   }
 
-  private Collection<AccountExternalId> getExternalIds(TestAccount account) throws Exception {
+  private Collection<ExternalId> getExternalIds(TestAccount account) throws Exception {
     return accountCache.get(account.getId()).getExternalIds();
   }
 
@@ -440,11 +450,11 @@
     String email = "foo.bar@example.com";
     String extId1 = "foo:bar";
     String extId2 = "foo:baz";
-    db.accountExternalIds()
-        .insert(
-            ImmutableList.of(
-                createExternalIdWithEmail(extId1, email),
-                createExternalIdWithEmail(extId2, email)));
+    List<ExternalId> extIds =
+        ImmutableList.of(
+            ExternalId.createWithEmail(ExternalId.Key.parse(extId1), admin.id, email),
+            ExternalId.createWithEmail(ExternalId.Key.parse(extId2), admin.id, email));
+    externalIdsUpdateFactory.create().insert(db, extIds);
     accountCache.evict(admin.id);
     assertThat(
             gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
@@ -487,6 +497,29 @@
   }
 
   @Test
+  public void lookUpFromCacheByEmail() throws Exception {
+    // exact match with scheme "mailto:"
+    assertEmail(byEmailCache.get(admin.email), admin);
+
+    // exact match with other scheme
+    String email = "foo.bar@example.com";
+    externalIdsUpdateFactory
+        .create()
+        .insert(db, ExternalId.createWithEmail(ExternalId.Key.parse("foo:bar"), admin.id, email));
+    accountCache.evict(admin.id);
+    assertEmail(byEmailCache.get(email), admin);
+
+    // wrong case doesn't match
+    assertThat(byEmailCache.get(admin.email.toUpperCase(Locale.US))).isEmpty();
+
+    // prefix doesn't match
+    assertThat(byEmailCache.get(admin.email.substring(0, admin.email.indexOf('@')))).isEmpty();
+
+    // non-existing doesn't match
+    assertThat(byEmailCache.get("non-existing@example.com")).isEmpty();
+  }
+
+  @Test
   public void putStatus() throws Exception {
     List<String> statuses = ImmutableList.of("OOO", "Busy");
     AccountInfo info;
@@ -680,10 +713,7 @@
   public void addOtherUsersGpgKey_Conflict() throws Exception {
     // Both users have a matching external ID for this key.
     addExternalIdEmail(admin, "test5@example.com");
-    AccountExternalId extId =
-        new AccountExternalId(user.getId(), new AccountExternalId.Key("foo:myId"));
-
-    db.accountExternalIds().insert(Collections.singleton(extId));
+    externalIdsUpdate.insert(db, ExternalId.create("foo", "myId", user.getId()));
     accountCache.evict(user.getId());
 
     TestKey key = validKeyWithSecondUserId();
@@ -883,7 +913,7 @@
     Iterable<String> expectedFps =
         expected.transform(k -> BaseEncoding.base16().encode(k.getPublicKey().getFingerprint()));
     Iterable<String> actualFps =
-        GpgKeys.getGpgExtIds(db, currAccountId).transform(AccountExternalId::getSchemeRest);
+        GpgKeys.getGpgExtIds(db, currAccountId).transform(e -> e.key().id());
     assertThat(actualFps).named("external IDs in database").containsExactlyElementsIn(expectedFps);
 
     // Check raw stored keys.
@@ -908,11 +938,9 @@
 
   private void addExternalIdEmail(TestAccount account, String email) throws Exception {
     checkNotNull(email);
-    AccountExternalId extId =
-        new AccountExternalId(account.getId(), new AccountExternalId.Key(name("test"), email));
-    extId.setEmailAddress(email);
-    db.accountExternalIds().insert(Collections.singleton(extId));
-    // Clear saved AccountState and AccountExternalIds.
+    externalIdsUpdate.insert(
+        db, ExternalId.createWithEmail(name("test"), email, account.getId(), email));
+    // Clear saved AccountState and ExternalIds.
     accountCache.evict(account.getId());
     setApiUser(account);
   }
@@ -932,9 +960,8 @@
     return gApi.accounts().self().getEmails().stream().map(e -> e.email).collect(toSet());
   }
 
-  private AccountExternalId createExternalIdWithEmail(String id, String email) {
-    AccountExternalId extId = new AccountExternalId(admin.id, new AccountExternalId.Key(id));
-    extId.setEmailAddress(email);
-    return extId;
+  private void assertEmail(Set<Account.Id> accounts, TestAccount expectedAccount) {
+    assertThat(accounts).hasSize(1);
+    assertThat(Iterables.getOnlyElement(accounts)).isEqualTo(expectedAccount.getId());
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index 4272bae..06b8f68 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -15,30 +15,64 @@
 package com.google.gerrit.acceptance.rest.account;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.fetch;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static org.junit.Assert.fail;
 
+import com.github.rholder.retry.BlockStrategy;
+import com.github.rholder.retry.Retryer;
+import com.github.rholder.retry.RetryerBuilder;
+import com.github.rholder.retry.StopStrategies;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.common.AccountExternalIdInfo;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.ExternalIds;
+import com.google.gerrit.server.account.ExternalIdsUpdate;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.LockFailureException;
 import com.google.gson.reflect.TypeToken;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.jgit.api.errors.TransportException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
 import org.junit.Test;
 
 @Sandboxed
 public class ExternalIdIT extends AbstractDaemonTest {
+  @Inject private AllUsersName allUsers;
+
+  @Inject private ExternalIdsUpdate.Server extIdsUpdate;
+
+  @Inject private ExternalIds externalIds;
+
   @Test
   public void getExternalIDs() throws Exception {
-    Collection<AccountExternalId> expectedIds = accountCache.get(user.getId()).getExternalIds();
+    Collection<ExternalId> expectedIds = accountCache.get(user.getId()).getExternalIds();
 
     List<AccountExternalIdInfo> expectedIdInfos = new ArrayList<>();
-    for (AccountExternalId id : expectedIds) {
-      id.setCanDelete(!id.getExternalId().equals("username:" + user.username));
-      id.setTrusted(true);
-      expectedIdInfos.add(toInfo(id));
+    for (ExternalId id : expectedIds) {
+      AccountExternalIdInfo info = new AccountExternalIdInfo();
+      info.identity = id.key().get();
+      info.emailAddress = id.email();
+      info.canDelete = !id.isScheme(SCHEME_USERNAME) ? true : null;
+      info.trusted = true;
+      expectedIdInfos.add(info);
     }
 
     RestResponse response = userRestSession.get("/accounts/self/external.ids");
@@ -102,12 +136,119 @@
         .isEqualTo(String.format("External id %s does not exist", externalIdStr));
   }
 
-  private static AccountExternalIdInfo toInfo(AccountExternalId id) {
-    AccountExternalIdInfo info = new AccountExternalIdInfo();
-    info.identity = id.getExternalId();
-    info.emailAddress = id.getEmailAddress();
-    info.trusted = id.isTrusted() ? true : null;
-    info.canDelete = id.canDelete() ? true : null;
-    return info;
+  @Test
+  public void fetchExternalIdsBranch() throws Exception {
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, user);
+
+    // refs/meta/external-ids is only visible to users with the 'Access Database' capability
+    try {
+      fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+      fail("expected TransportException");
+    } catch (TransportException e) {
+      assertThat(e.getMessage())
+          .isEqualTo(
+              "Remote does not have " + RefNames.REFS_EXTERNAL_IDS + " available for fetch.");
+    }
+
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    // re-clone to get new request context, otherwise the old global capabilities are still cached
+    // in the IdentifiedUser object
+    allUsersRepo = cloneProject(allUsers, user);
+    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+  }
+
+  @Test
+  public void pushToExternalIdsBranch() throws Exception {
+    grant(Permission.READ, allUsers, RefNames.REFS_EXTERNAL_IDS);
+    grant(Permission.PUSH, allUsers, RefNames.REFS_EXTERNAL_IDS);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":externalIds");
+    allUsersRepo.reset("externalIds");
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
+    push.to(RefNames.REFS_EXTERNAL_IDS)
+        .assertErrorStatus("not allowed to update " + RefNames.REFS_EXTERNAL_IDS);
+  }
+
+  @Test
+  public void retryOnLockFailure() throws Exception {
+    Retryer<Void> retryer =
+        ExternalIdsUpdate.retryerBuilder()
+            .withBlockStrategy(
+                new BlockStrategy() {
+                  @Override
+                  public void block(long sleepTime) {
+                    // Don't sleep in tests.
+                  }
+                })
+            .build();
+
+    ExternalId.Key fooId = ExternalId.Key.create("foo", "foo");
+    ExternalId.Key barId = ExternalId.Key.create("bar", "bar");
+
+    final AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
+    ExternalIdsUpdate update =
+        new ExternalIdsUpdate(
+            repoManager,
+            allUsers,
+            serverIdent.get(),
+            serverIdent.get(),
+            () -> {
+              if (!doneBgUpdate.getAndSet(true)) {
+                try {
+                  extIdsUpdate.create().insert(db, ExternalId.create(barId, admin.id));
+                } catch (IOException | ConfigInvalidException | OrmException e) {
+                  // Ignore, the successful insertion of the external ID is asserted later
+                }
+              }
+            },
+            retryer);
+    assertThat(doneBgUpdate.get()).isFalse();
+    update.insert(db, ExternalId.create(fooId, admin.id));
+    assertThat(doneBgUpdate.get()).isTrue();
+
+    assertThat(externalIds.get(fooId)).isNotNull();
+    assertThat(externalIds.get(barId)).isNotNull();
+  }
+
+  @Test
+  public void failAfterRetryerGivesUp() throws Exception {
+    ExternalId.Key[] extIdsKeys = {
+      ExternalId.Key.create("foo", "foo"),
+      ExternalId.Key.create("bar", "bar"),
+      ExternalId.Key.create("baz", "baz")
+    };
+    final AtomicInteger bgCounter = new AtomicInteger(0);
+    ExternalIdsUpdate update =
+        new ExternalIdsUpdate(
+            repoManager,
+            allUsers,
+            serverIdent.get(),
+            serverIdent.get(),
+            () -> {
+              try {
+                extIdsUpdate
+                    .create()
+                    .insert(db, ExternalId.create(extIdsKeys[bgCounter.getAndAdd(1)], admin.id));
+              } catch (IOException | ConfigInvalidException | OrmException e) {
+                // Ignore, the successful insertion of the external ID is asserted later
+              }
+            },
+            RetryerBuilder.<Void>newBuilder()
+                .retryIfException(e -> e instanceof LockFailureException)
+                .withStopStrategy(StopStrategies.stopAfterAttempt(extIdsKeys.length))
+                .build());
+    assertThat(bgCounter.get()).isEqualTo(0);
+    try {
+      update.insert(db, ExternalId.create(ExternalId.Key.create("abc", "abc"), admin.id));
+      fail("expected LockFailureException");
+    } catch (LockFailureException e) {
+      // Ignore, expected
+    }
+    assertThat(bgCounter.get()).isEqualTo(extIdsKeys.length);
+    for (ExternalId.Key extIdKey : extIdsKeys) {
+      assertThat(externalIds.get(extIdKey)).isNotNull();
+    }
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index a4e4437..f51bbf5 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -88,7 +88,6 @@
     assertThat(i.auth.registerText).isNull();
     assertThat(i.auth.editFullNameUrl).isNull();
     assertThat(i.auth.httpPasswordUrl).isNull();
-    assertThat(i.auth.isGitBasicAuth).isNull();
 
     // change
     assertThat(i.change.allowDrafts).isNull();
@@ -163,7 +162,6 @@
     assertThat(i.auth.registerText).isNull();
     assertThat(i.auth.editFullNameUrl).isNull();
     assertThat(i.auth.httpPasswordUrl).isNull();
-    assertThat(i.auth.isGitBasicAuth).isNull();
 
     // change
     assertThat(i.change.allowDrafts).isTrue();
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AuthInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AuthInfo.java
index 9780dd7..79c2250 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AuthInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AuthInfo.java
@@ -31,6 +31,5 @@
   public String registerText;
   public String editFullNameUrl;
   public String httpPasswordUrl;
-  public Boolean isGitBasicAuth;
   public GitBasicAuthPolicy gitBasicAuthPolicy;
 }
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
index b74139a..8e503ee 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
@@ -15,16 +15,16 @@
 package com.google.gerrit.gpg;
 
 import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_GPGKEY;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
@@ -155,8 +155,7 @@
   }
 
   private CheckResult checkIdsForArbitraryUser(PGPPublicKey key) throws PGPException, OrmException {
-    List<AccountState> accountStates =
-        accountQueryProvider.get().byExternalId(toExtIdKey(key).get());
+    List<AccountState> accountStates = accountQueryProvider.get().byExternalId(toExtIdKey(key));
     if (accountStates.isEmpty()) {
       return CheckResult.bad("Key is not associated with any users");
     }
@@ -202,11 +201,11 @@
   private Set<String> getAllowedUserIds(IdentifiedUser user) {
     Set<String> result = new HashSet<>();
     result.addAll(user.getEmailAddresses());
-    for (AccountExternalId extId : user.state().getExternalIds()) {
+    for (ExternalId extId : user.state().getExternalIds()) {
       if (extId.isScheme(SCHEME_GPGKEY)) {
         continue; // Omit GPG keys.
       }
-      result.add(extId.getExternalId());
+      result.add(extId.key().get());
     }
     return result;
   }
@@ -248,8 +247,7 @@
     return sb.toString();
   }
 
-  static AccountExternalId.Key toExtIdKey(PGPPublicKey key) {
-    return new AccountExternalId.Key(
-        SCHEME_GPGKEY, BaseEncoding.base16().encode(key.getFingerprint()));
+  static ExternalId.Key toExtIdKey(PGPPublicKey key) {
+    return ExternalId.Key.create(SCHEME_GPGKEY, BaseEncoding.base16().encode(key.getFingerprint()));
   }
 }
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
index 5c1fad5..ba79a6f 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
@@ -33,6 +33,7 @@
 import java.util.List;
 import java.util.Map;
 import org.bouncycastle.openpgp.PGPException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.transport.PushCertificate;
 import org.eclipse.jgit.transport.PushCertificateParser;
 
@@ -78,7 +79,7 @@
     in.delete = delete;
     try {
       return postGpgKeys.apply(account, in);
-    } catch (PGPException | OrmException | IOException e) {
+    } catch (PGPException | OrmException | IOException | ConfigInvalidException e) {
       throw new GpgException(e);
     }
   }
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
index e99f900..9aa18fe 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
@@ -25,6 +25,7 @@
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import org.bouncycastle.openpgp.PGPException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 public class GpgKeyApiImpl implements GpgKeyApi {
   public interface Factory {
@@ -55,7 +56,7 @@
   public void delete() throws RestApiException {
     try {
       delete.apply(rsrc, new DeleteGpgKey.Input());
-    } catch (PGPException | OrmException | IOException e) {
+    } catch (PGPException | OrmException | IOException | ConfigInvalidException e) {
       throw new RestApiException("Cannot delete GPG key", e);
     }
   }
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
index 1b797eb..50bf57b 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.gpg.server;
 
 import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_GPGKEY;
 
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -22,17 +23,18 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.gpg.PublicKeyStore;
 import com.google.gerrit.gpg.server.DeleteGpgKey.Input;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.ExternalIdsUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
-import java.util.Collections;
 import org.bouncycastle.openpgp.PGPException;
 import org.bouncycastle.openpgp.PGPPublicKey;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
@@ -44,27 +46,34 @@
   private final Provider<ReviewDb> db;
   private final Provider<PublicKeyStore> storeProvider;
   private final AccountCache accountCache;
+  private final ExternalIdsUpdate.User externalIdsUpdateFactory;
 
   @Inject
   DeleteGpgKey(
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
       Provider<ReviewDb> db,
       Provider<PublicKeyStore> storeProvider,
-      AccountCache accountCache) {
+      AccountCache accountCache,
+      ExternalIdsUpdate.User externalIdsUpdateFactory) {
     this.serverIdent = serverIdent;
     this.db = db;
     this.storeProvider = storeProvider;
     this.accountCache = accountCache;
+    this.externalIdsUpdateFactory = externalIdsUpdateFactory;
   }
 
   @Override
   public Response<?> apply(GpgKey rsrc, Input input)
-      throws ResourceConflictException, PGPException, OrmException, IOException {
+      throws ResourceConflictException, PGPException, OrmException, IOException,
+          ConfigInvalidException {
     PGPPublicKey key = rsrc.getKeyRing().getPublicKey();
-    AccountExternalId.Key extIdKey =
-        new AccountExternalId.Key(
-            AccountExternalId.SCHEME_GPGKEY, BaseEncoding.base16().encode(key.getFingerprint()));
-    db.get().accountExternalIds().deleteKeys(Collections.singleton(extIdKey));
+    externalIdsUpdateFactory
+        .create()
+        .delete(
+            db.get(),
+            rsrc.getUser().getAccountId(),
+            ExternalId.Key.create(
+                SCHEME_GPGKEY, BaseEncoding.base16().encode(key.getFingerprint())));
     accountCache.evict(rsrc.getUser().getAccountId());
 
     try (PublicKeyStore store = storeProvider.get()) {
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java
index 864709c..819ad96 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.gpg.server;
 
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_GPGKEY;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -37,10 +37,10 @@
 import com.google.gerrit.gpg.PublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyStore;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -114,7 +114,7 @@
     throw new ResourceNotFoundException(id);
   }
 
-  static byte[] parseFingerprint(String str, Iterable<AccountExternalId> existingExtIds)
+  static byte[] parseFingerprint(String str, Iterable<ExternalId> existingExtIds)
       throws ResourceNotFoundException {
     str = CharMatcher.whitespace().removeFrom(str).toUpperCase();
     if ((str.length() != 8 && str.length() != 40)
@@ -122,8 +122,8 @@
       throw new ResourceNotFoundException(str);
     }
     byte[] fp = null;
-    for (AccountExternalId extId : existingExtIds) {
-      String fpStr = extId.getSchemeRest();
+    for (ExternalId extId : existingExtIds) {
+      String fpStr = extId.key().id();
       if (!fpStr.endsWith(str)) {
         continue;
       } else if (fp != null) {
@@ -152,8 +152,8 @@
       checkVisible(self, rsrc);
       Map<String, GpgKeyInfo> keys = new HashMap<>();
       try (PublicKeyStore store = storeProvider.get()) {
-        for (AccountExternalId extId : getGpgExtIds(rsrc)) {
-          String fpStr = extId.getSchemeRest();
+        for (ExternalId extId : getGpgExtIds(rsrc)) {
+          String fpStr = extId.key().id();
           byte[] fp = BaseEncoding.base16().decode(fpStr);
           boolean found = false;
           for (PGPPublicKeyRing keyRing : store.get(keyId(fp))) {
@@ -199,13 +199,14 @@
   }
 
   @VisibleForTesting
-  public static FluentIterable<AccountExternalId> getGpgExtIds(ReviewDb db, Account.Id accountId)
+  public static FluentIterable<ExternalId> getGpgExtIds(ReviewDb db, Account.Id accountId)
       throws OrmException {
-    return FluentIterable.from(db.accountExternalIds().byAccount(accountId))
+    return FluentIterable.from(
+            ExternalId.from(db.accountExternalIds().byAccount(accountId).toList()))
         .filter(in -> in.isScheme(SCHEME_GPGKEY));
   }
 
-  private Iterable<AccountExternalId> getGpgExtIds(AccountResource rsrc) throws OrmException {
+  private Iterable<ExternalId> getGpgExtIds(AccountResource rsrc) throws OrmException {
     return getGpgExtIds(db.get(), rsrc.getUser().getAccountId());
   }
 
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index 1c6fc3a..7b825b1 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -16,12 +16,13 @@
 
 import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
 import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_GPGKEY;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
@@ -39,7 +40,6 @@
 import com.google.gerrit.gpg.PublicKeyStore;
 import com.google.gerrit.gpg.server.PostGpgKeys.Input;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -47,6 +47,8 @@
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.ExternalIdsUpdate;
 import com.google.gerrit.server.mail.send.AddKeySender;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.OrmException;
@@ -66,6 +68,7 @@
 import org.bouncycastle.openpgp.PGPPublicKey;
 import org.bouncycastle.openpgp.PGPPublicKeyRing;
 import org.bouncycastle.openpgp.bc.BcPGPObjectFactory;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
@@ -88,6 +91,7 @@
   private final AddKeySender.Factory addKeyFactory;
   private final AccountCache accountCache;
   private final Provider<InternalAccountQuery> accountQueryProvider;
+  private final ExternalIdsUpdate.User externalIdsUpdateFactory;
 
   @Inject
   PostGpgKeys(
@@ -98,7 +102,8 @@
       GerritPublicKeyChecker.Factory checkerFactory,
       AddKeySender.Factory addKeyFactory,
       AccountCache accountCache,
-      Provider<InternalAccountQuery> accountQueryProvider) {
+      Provider<InternalAccountQuery> accountQueryProvider,
+      ExternalIdsUpdate.User externalIdsUpdateFactory) {
     this.serverIdent = serverIdent;
     this.db = db;
     this.self = self;
@@ -107,48 +112,48 @@
     this.addKeyFactory = addKeyFactory;
     this.accountCache = accountCache;
     this.accountQueryProvider = accountQueryProvider;
+    this.externalIdsUpdateFactory = externalIdsUpdateFactory;
   }
 
   @Override
   public Map<String, GpgKeyInfo> apply(AccountResource rsrc, Input input)
       throws ResourceNotFoundException, BadRequestException, ResourceConflictException,
-          PGPException, OrmException, IOException {
+          PGPException, OrmException, IOException, ConfigInvalidException {
     GpgKeys.checkVisible(self, rsrc);
 
-    List<AccountExternalId> existingExtIds =
+    Collection<ExternalId> existingExtIds =
         GpgKeys.getGpgExtIds(db.get(), rsrc.getUser().getAccountId()).toList();
-
     try (PublicKeyStore store = storeProvider.get()) {
       Set<Fingerprint> toRemove = readKeysToRemove(input, existingExtIds);
       List<PGPPublicKeyRing> newKeys = readKeysToAdd(input, toRemove);
-      List<AccountExternalId> newExtIds = new ArrayList<>(existingExtIds.size());
+      List<ExternalId> newExtIds = new ArrayList<>(existingExtIds.size());
 
       for (PGPPublicKeyRing keyRing : newKeys) {
         PGPPublicKey key = keyRing.getPublicKey();
-        AccountExternalId.Key extIdKey = toExtIdKey(key.getFingerprint());
-        Account account = getAccountByExternalId(extIdKey.get());
+        ExternalId.Key extIdKey = toExtIdKey(key.getFingerprint());
+        Account account = getAccountByExternalId(extIdKey);
         if (account != null) {
           if (!account.getId().equals(rsrc.getUser().getAccountId())) {
             throw new ResourceConflictException("GPG key already associated with another account");
           }
         } else {
-          newExtIds.add(new AccountExternalId(rsrc.getUser().getAccountId(), extIdKey));
+          newExtIds.add(ExternalId.create(extIdKey, rsrc.getUser().getAccountId()));
         }
       }
 
       storeKeys(rsrc, newKeys, toRemove);
-      if (!newExtIds.isEmpty()) {
-        db.get().accountExternalIds().insert(newExtIds);
-      }
-      db.get()
-          .accountExternalIds()
-          .deleteKeys(Iterables.transform(toRemove, fp -> toExtIdKey(fp.get())));
+
+      List<ExternalId.Key> extIdKeysToRemove =
+          toRemove.stream().map(fp -> toExtIdKey(fp.get())).collect(toList());
+      externalIdsUpdateFactory
+          .create()
+          .replace(db.get(), rsrc.getUser().getAccountId(), extIdKeysToRemove, newExtIds);
       accountCache.evict(rsrc.getUser().getAccountId());
       return toJson(newKeys, toRemove, store, rsrc.getUser());
     }
   }
 
-  private Set<Fingerprint> readKeysToRemove(Input input, List<AccountExternalId> existingExtIds) {
+  private Set<Fingerprint> readKeysToRemove(Input input, Collection<ExternalId> existingExtIds) {
     if (input.delete == null || input.delete.isEmpty()) {
       return ImmutableSet.of();
     }
@@ -243,13 +248,12 @@
     }
   }
 
-  private AccountExternalId.Key toExtIdKey(byte[] fp) {
-    return new AccountExternalId.Key(
-        AccountExternalId.SCHEME_GPGKEY, BaseEncoding.base16().encode(fp));
+  private ExternalId.Key toExtIdKey(byte[] fp) {
+    return ExternalId.Key.create(SCHEME_GPGKEY, BaseEncoding.base16().encode(fp));
   }
 
-  private Account getAccountByExternalId(String externalId) throws OrmException {
-    List<AccountState> accountStates = accountQueryProvider.get().byExternalId(externalId);
+  private Account getAccountByExternalId(ExternalId.Key extIdKey) throws OrmException {
+    List<AccountState> accountStates = accountQueryProvider.get().byExternalId(extIdKey);
 
     if (accountStates.isEmpty()) {
       return null;
@@ -257,7 +261,7 @@
 
     if (accountStates.size() > 1) {
       StringBuilder msg = new StringBuilder();
-      msg.append("GPG key ").append(externalId).append(" associated with multiple accounts: ");
+      msg.append("GPG key ").append(extIdKey.get()).append(" associated with multiple accounts: ");
       Joiner.on(", ")
           .appendTo(msg, Lists.transform(accountStates, AccountState.ACCOUNT_ID_FUNCTION));
       log.error(msg.toString());
diff --git a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
index 420dd50..862930f 100644
--- a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
+++ b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
@@ -23,7 +23,6 @@
 import static com.google.gerrit.gpg.testutil.TestTrustKeys.keyC;
 import static com.google.gerrit.gpg.testutil.TestTrustKeys.keyD;
 import static com.google.gerrit.gpg.testutil.TestTrustKeys.keyE;
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_MAILTO;
 import static org.eclipse.jgit.lib.RefUpdate.Result.FAST_FORWARD;
 import static org.eclipse.jgit.lib.RefUpdate.Result.FORCED;
 import static org.eclipse.jgit.lib.RefUpdate.Result.NEW;
@@ -34,13 +33,14 @@
 import com.google.gerrit.gpg.testutil.TestKey;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.ExternalIdsUpdate;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
@@ -55,7 +55,6 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collections;
 import java.util.List;
 import org.bouncycastle.openpgp.PGPPublicKey;
 import org.bouncycastle.openpgp.PGPPublicKeyRing;
@@ -86,6 +85,8 @@
 
   @Inject private ThreadLocalRequestContext requestContext;
 
+  @Inject private ExternalIdsUpdate.Server externalIdsUpdateFactory;
+
   private LifecycleManager lifecycle;
   private ReviewDb db;
   private Account.Id userId;
@@ -221,7 +222,8 @@
 
   @Test
   public void noExternalIds() throws Exception {
-    db.accountExternalIds().delete(db.accountExternalIds().byAccount(user.getAccountId()));
+    ExternalIdsUpdate externalIdsUpdate = externalIdsUpdateFactory.create();
+    externalIdsUpdate.deleteAll(db, user.getAccountId());
     reloadUser();
 
     TestKey key = validKeyWithSecondUserId();
@@ -234,11 +236,8 @@
     checker = checkerFactory.create().setStore(store).disableTrust();
     assertProblems(
         checker.check(key.getPublicKey()), Status.BAD, "Key is not associated with any users");
-
-    db.accountExternalIds()
-        .insert(
-            Collections.singleton(
-                new AccountExternalId(user.getAccountId(), toExtIdKey(key.getPublicKey()))));
+    externalIdsUpdate.insert(
+        db, ExternalId.create(toExtIdKey(key.getPublicKey()), user.getAccountId()));
     reloadUser();
     assertProblems(checker.check(key.getPublicKey()), Status.BAD, "No identities found for user");
   }
@@ -389,18 +388,15 @@
 
   private void add(PGPPublicKeyRing kr, IdentifiedUser user) throws Exception {
     Account.Id id = user.getAccountId();
-    List<AccountExternalId> newExtIds = new ArrayList<>(2);
-    newExtIds.add(new AccountExternalId(id, toExtIdKey(kr.getPublicKey())));
+    List<ExternalId> newExtIds = new ArrayList<>(2);
+    newExtIds.add(ExternalId.create(toExtIdKey(kr.getPublicKey()), id));
 
     @SuppressWarnings("unchecked")
     String userId = (String) Iterators.getOnlyElement(kr.getPublicKey().getUserIDs(), null);
     if (userId != null) {
       String email = PushCertificateIdent.parse(userId).getEmailAddress();
       assertThat(email).contains("@");
-      AccountExternalId mailto =
-          new AccountExternalId(id, new AccountExternalId.Key(SCHEME_MAILTO, email));
-      mailto.setEmailAddress(email);
-      newExtIds.add(mailto);
+      newExtIds.add(ExternalId.createEmail(id, email));
     }
 
     store.add(kr);
@@ -410,7 +406,7 @@
     cb.setCommitter(ident);
     assertThat(store.save(cb)).isAnyOf(NEW, FAST_FORWARD, FORCED);
 
-    db.accountExternalIds().insert(newExtIds);
+    externalIdsUpdateFactory.create().insert(db, newExtIds);
     accountCache.evict(user.getAccountId());
   }
 
@@ -434,12 +430,9 @@
   }
 
   private void addExternalId(String scheme, String id, String email) throws Exception {
-    AccountExternalId extId =
-        new AccountExternalId(user.getAccountId(), new AccountExternalId.Key(scheme, id));
-    if (email != null) {
-      extId.setEmailAddress(email);
-    }
-    db.accountExternalIds().insert(Collections.singleton(extId));
+    externalIdsUpdateFactory
+        .create()
+        .insert(db, ExternalId.createWithEmail(scheme, id, user.getAccountId(), email));
     reloadUser();
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index 9a8197a..9676cd3 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -22,12 +22,12 @@
 import com.google.gerrit.httpd.WebSessionManager.Key;
 import com.google.gerrit.httpd.WebSessionManager.Val;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AuthResult;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.Provider;
 import com.google.inject.servlet.RequestScoped;
@@ -132,7 +132,7 @@
   }
 
   @Override
-  public AccountExternalId.Key getLastLoginExternalId() {
+  public ExternalId.Key getLastLoginExternalId() {
     return val != null ? val.getExternalId() : null;
   }
 
@@ -149,9 +149,9 @@
   }
 
   @Override
-  public void login(final AuthResult res, final boolean rememberMe) {
-    final Account.Id id = res.getAccountId();
-    final AccountExternalId.Key identity = res.getExternalId();
+  public void login(AuthResult res, boolean rememberMe) {
+    Account.Id id = res.getAccountId();
+    ExternalId.Key identity = res.getExternalId();
 
     if (val != null) {
       manager.destroy(key);
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpModule.java
index 4f97783..3be9a12 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpModule.java
@@ -42,14 +42,10 @@
     Class<? extends Filter> authFilter;
     if (authConfig.isTrustContainerAuth()) {
       authFilter = ContainerAuthFilter.class;
-    } else if (authConfig.isGitBasicAuth()) {
-      if (authConfig.getAuthType() == OAUTH) {
-        authFilter = ProjectOAuthFilter.class;
-      } else {
-        authFilter = ProjectBasicAuthFilter.class;
-      }
+    } else if (authConfig.getAuthType() == OAUTH) {
+      authFilter = ProjectOAuthFilter.class;
     } else {
-      authFilter = ProjectDigestFilter.class;
+      authFilter = ProjectBasicAuthFilter.class;
     }
 
     if (isHttpEnabled()) {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
index 88f9b4c..57ec9c5 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
@@ -140,7 +140,7 @@
     GitBasicAuthPolicy gitBasicAuthPolicy = authConfig.getGitBasicAuthPolicy();
     if (gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP
         || gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP_LDAP) {
-      if (passwordMatchesTheUserGeneratedOne(who, username, password)) {
+      if (who.checkPassword(password, username)) {
         return succeedAuthentication(who);
       }
     }
@@ -157,7 +157,7 @@
       setUserIdentified(whoAuthResult.getAccountId());
       return true;
     } catch (NoSuchUserException e) {
-      if (password.equals(who.getPassword(who.getUserName()))) {
+      if (who.checkPassword(password, who.getUserName())) {
         return succeedAuthentication(who);
       }
       log.warn("Authentication failed for " + username, e);
@@ -193,12 +193,6 @@
     ws.setAccessPathOk(AccessPath.REST_API, true);
   }
 
-  private boolean passwordMatchesTheUserGeneratedOne(
-      AccountState who, String username, String password) {
-    String accountPassword = who.getPassword(username);
-    return accountPassword != null && password != null && accountPassword.equals(password);
-  }
-
   private String encoding(HttpServletRequest req) {
     return MoreObjects.firstNonNull(req.getCharacterEncoding(), UTF_8.name());
   }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectDigestFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectDigestFilter.java
deleted file mode 100644
index 7da8cda..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectDigestFilter.java
+++ /dev/null
@@ -1,337 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.concurrent.TimeUnit.HOURS;
-import static java.util.concurrent.TimeUnit.SECONDS;
-import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
-import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
-import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.server.AccessPath;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gwtjsonrpc.server.SignedToken;
-import com.google.gwtjsonrpc.server.XsrfException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Locale;
-import java.util.Map;
-import javax.servlet.Filter;
-import javax.servlet.FilterChain;
-import javax.servlet.FilterConfig;
-import javax.servlet.ServletContext;
-import javax.servlet.ServletException;
-import javax.servlet.ServletRequest;
-import javax.servlet.ServletResponse;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import javax.servlet.http.HttpServletResponseWrapper;
-import org.eclipse.jgit.lib.Config;
-
-/**
- * Authenticates the current user by HTTP digest authentication.
- *
- * <p>The current HTTP request is authenticated by looking up the username from the Authorization
- * header and checking the digest response against the stored password. This filter is intended only
- * to protect the {@link GitOverHttpServlet} and its handled URLs, which provide remote repository
- * access over HTTP.
- *
- * @see <a href="http://www.ietf.org/rfc/rfc2617.txt">RFC 2617</a>
- */
-@Singleton
-class ProjectDigestFilter implements Filter {
-  public static final String REALM_NAME = "Gerrit Code Review";
-  private static final String AUTHORIZATION = "Authorization";
-
-  private final Provider<String> urlProvider;
-  private final DynamicItem<WebSession> session;
-  private final AccountCache accountCache;
-  private final Config config;
-  private final SignedToken tokens;
-  private ServletContext context;
-
-  @Inject
-  ProjectDigestFilter(
-      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
-      DynamicItem<WebSession> session,
-      AccountCache accountCache,
-      @GerritServerConfig Config config)
-      throws XsrfException {
-    this.urlProvider = urlProvider;
-    this.session = session;
-    this.accountCache = accountCache;
-    this.config = config;
-    this.tokens = new SignedToken((int) SECONDS.convert(1, HOURS));
-  }
-
-  @Override
-  public void init(FilterConfig config) {
-    context = config.getServletContext();
-  }
-
-  @Override
-  public void destroy() {}
-
-  @Override
-  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
-      throws IOException, ServletException {
-    HttpServletRequest req = (HttpServletRequest) request;
-    Response rsp = new Response(req, (HttpServletResponse) response);
-
-    if (verify(req, rsp)) {
-      chain.doFilter(req, rsp);
-    }
-  }
-
-  private boolean verify(HttpServletRequest req, Response rsp) throws IOException {
-    final String hdr = req.getHeader(AUTHORIZATION);
-    if (hdr == null || !hdr.startsWith("Digest ")) {
-      // Allow an anonymous connection through, or it might be using a
-      // session cookie instead of digest authentication.
-      return true;
-    }
-
-    final Map<String, String> p = parseAuthorization(hdr);
-    final String user = p.get("username");
-    final String realm = p.get("realm");
-    final String nonce = p.get("nonce");
-    final String uri = p.get("uri");
-    final String response = p.get("response");
-    final String qop = p.get("qop");
-    final String nc = p.get("nc");
-    final String cnonce = p.get("cnonce");
-    final String method = req.getMethod();
-
-    if (user == null //
-        || realm == null //
-        || nonce == null //
-        || uri == null //
-        || response == null //
-        || !"auth".equals(qop) //
-        || !REALM_NAME.equals(realm)) {
-      context.log("Invalid header: " + AUTHORIZATION + ": " + hdr);
-      rsp.sendError(SC_FORBIDDEN);
-      return false;
-    }
-
-    String username = user;
-    if (config.getBoolean("auth", "userNameToLowerCase", false)) {
-      username = username.toLowerCase(Locale.US);
-    }
-
-    final AccountState who = accountCache.getByUsername(username);
-    if (who == null || !who.getAccount().isActive()) {
-      rsp.sendError(SC_UNAUTHORIZED);
-      return false;
-    }
-
-    final String passwd = who.getPassword(username);
-    if (passwd == null) {
-      rsp.sendError(SC_UNAUTHORIZED);
-      return false;
-    }
-
-    final String A1 = user + ":" + realm + ":" + passwd;
-    final String A2 = method + ":" + uri;
-    final String expect = KD(H(A1), nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + H(A2));
-
-    if (expect.equals(response)) {
-      try {
-        if (tokens.checkToken(nonce, "") != null) {
-          WebSession ws = session.get();
-          ws.setUserAccountId(who.getAccount().getId());
-          ws.setAccessPathOk(AccessPath.GIT, true);
-          ws.setAccessPathOk(AccessPath.REST_API, true);
-          return true;
-        }
-        rsp.stale = true;
-        rsp.sendError(SC_UNAUTHORIZED);
-        return false;
-      } catch (XsrfException e) {
-        context.log("Error validating nonce for digest authentication", e);
-        rsp.sendError(SC_INTERNAL_SERVER_ERROR);
-        return false;
-      }
-    }
-    rsp.sendError(SC_UNAUTHORIZED);
-    return false;
-  }
-
-  private static String H(String data) {
-    MessageDigest md = newMD5();
-    md.update(data.getBytes(UTF_8));
-    return LHEX(md.digest());
-  }
-
-  private static String KD(String secret, String data) {
-    MessageDigest md = newMD5();
-    md.update(secret.getBytes(UTF_8));
-    md.update((byte) ':');
-    md.update(data.getBytes(UTF_8));
-    return LHEX(md.digest());
-  }
-
-  private static MessageDigest newMD5() {
-    try {
-      return MessageDigest.getInstance("MD5");
-    } catch (NoSuchAlgorithmException e) {
-      throw new RuntimeException("No MD5 available", e);
-    }
-  }
-
-  private static final char[] LHEX = {
-    '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', //
-    'a', 'b', 'c', 'd', 'e', 'f',
-  };
-
-  private static String LHEX(byte[] bin) {
-    StringBuilder r = new StringBuilder(bin.length * 2);
-    for (byte b : bin) {
-      r.append(LHEX[(b >>> 4) & 0x0f]);
-      r.append(LHEX[b & 0x0f]);
-    }
-    return r.toString();
-  }
-
-  private Map<String, String> parseAuthorization(String auth) {
-    Map<String, String> p = new HashMap<>();
-    int next = "Digest ".length();
-    while (next < auth.length()) {
-      if (next < auth.length() && auth.charAt(next) == ',') {
-        next++;
-      }
-      while (next < auth.length() && Character.isWhitespace(auth.charAt(next))) {
-        next++;
-      }
-
-      int eq = auth.indexOf('=', next);
-      if (eq < 0 || eq + 1 == auth.length()) {
-        return Collections.emptyMap();
-      }
-
-      final String name = auth.substring(next, eq);
-      final String value;
-      if (auth.charAt(eq + 1) == '"') {
-        int dq = auth.indexOf('"', eq + 2);
-        if (dq < 0) {
-          return Collections.emptyMap();
-        }
-        value = auth.substring(eq + 2, dq);
-        next = dq + 1;
-
-      } else {
-        int space = auth.indexOf(' ', eq + 1);
-        int comma = auth.indexOf(',', eq + 1);
-        if (space < 0) {
-          space = auth.length();
-        }
-        if (comma < 0) {
-          comma = auth.length();
-        }
-
-        final int e = Math.min(space, comma);
-        value = auth.substring(eq + 1, e);
-        next = e + 1;
-      }
-      p.put(name, value);
-    }
-    return p;
-  }
-
-  private String newNonce() {
-    try {
-      return tokens.newToken("");
-    } catch (XsrfException e) {
-      throw new RuntimeException("Cannot generate new nonce", e);
-    }
-  }
-
-  class Response extends HttpServletResponseWrapper {
-    private static final String WWW_AUTHENTICATE = "WWW-Authenticate";
-    private final HttpServletRequest req;
-    Boolean stale;
-
-    Response(HttpServletRequest req, HttpServletResponse rsp) {
-      super(rsp);
-      this.req = req;
-    }
-
-    private void status(int sc) {
-      if (sc == SC_UNAUTHORIZED) {
-        StringBuilder v = new StringBuilder();
-        v.append("Digest");
-        v.append(" realm=\"").append(REALM_NAME).append("\"");
-
-        String url = urlProvider.get();
-        if (url == null) {
-          url = req.getContextPath();
-          if (url != null && !url.isEmpty() && !url.endsWith("/")) {
-            url += "/";
-          }
-        }
-        if (url != null && !url.isEmpty()) {
-          v.append(", domain=\"").append(url).append("\"");
-        }
-
-        v.append(", qop=\"auth\"");
-        if (stale != null) {
-          v.append(", stale=").append(stale);
-        }
-        v.append(", nonce=\"").append(newNonce()).append("\"");
-        setHeader(WWW_AUTHENTICATE, v.toString());
-
-      } else if (containsHeader(WWW_AUTHENTICATE)) {
-        setHeader(WWW_AUTHENTICATE, null);
-      }
-    }
-
-    @Override
-    public void sendError(int sc, String msg) throws IOException {
-      status(sc);
-      super.sendError(sc, msg);
-    }
-
-    @Override
-    public void sendError(int sc) throws IOException {
-      status(sc);
-      super.sendError(sc);
-    }
-
-    @Override
-    @Deprecated
-    public void setStatus(int sc, String sm) {
-      status(sc);
-      super.setStatus(sc, sm);
-    }
-
-    @Override
-    public void setStatus(int sc) {
-      status(sc);
-      super.setStatus(sc);
-    }
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java
index 948af29..f1600bc 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java
@@ -16,10 +16,10 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AuthResult;
+import com.google.gerrit.server.account.ExternalId;
 
 public interface WebSession {
   boolean isSignedIn();
@@ -29,7 +29,7 @@
 
   boolean isValidXGerritAuth(String keyIn);
 
-  AccountExternalId.Key getLastLoginExternalId();
+  ExternalId.Key getLastLoginExternalId();
 
   CurrentUser getUser();
 
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManager.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManager.java
index 28d12ee..59591cc 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManager.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManager.java
@@ -30,7 +30,7 @@
 
 import com.google.common.cache.Cache;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
@@ -98,18 +98,18 @@
     }
   }
 
-  Val createVal(final Key key, final Val val) {
-    final Account.Id who = val.getAccountId();
-    final boolean remember = val.isPersistentCookie();
-    final AccountExternalId.Key lastLogin = val.getExternalId();
+  Val createVal(Key key, Val val) {
+    Account.Id who = val.getAccountId();
+    boolean remember = val.isPersistentCookie();
+    ExternalId.Key lastLogin = val.getExternalId();
     return createVal(key, who, remember, lastLogin, val.sessionId, val.auth);
   }
 
   Val createVal(
-      final Key key,
-      final Account.Id who,
-      final boolean remember,
-      final AccountExternalId.Key lastLogin,
+      Key key,
+      Account.Id who,
+      boolean remember,
+      ExternalId.Key lastLogin,
       String sid,
       String auth) {
     // Refresh the cookie every hour or when it is half-expired.
@@ -191,19 +191,19 @@
     private transient Account.Id accountId;
     private transient long refreshCookieAt;
     private transient boolean persistentCookie;
-    private transient AccountExternalId.Key externalId;
+    private transient ExternalId.Key externalId;
     private transient long expiresAt;
     private transient String sessionId;
     private transient String auth;
 
     Val(
-        final Account.Id accountId,
-        final long refreshCookieAt,
-        final boolean persistentCookie,
-        final AccountExternalId.Key externalId,
-        final long expiresAt,
-        final String sessionId,
-        final String auth) {
+        Account.Id accountId,
+        long refreshCookieAt,
+        boolean persistentCookie,
+        ExternalId.Key externalId,
+        long expiresAt,
+        String sessionId,
+        String auth) {
       this.accountId = accountId;
       this.refreshCookieAt = refreshCookieAt;
       this.persistentCookie = persistentCookie;
@@ -221,7 +221,7 @@
       return accountId;
     }
 
-    AccountExternalId.Key getExternalId() {
+    ExternalId.Key getExternalId() {
       return externalId;
     }
 
@@ -253,7 +253,7 @@
 
       if (externalId != null) {
         writeVarInt32(out, 4);
-        writeString(out, externalId.get());
+        writeString(out, externalId.toString());
       }
 
       if (sessionId != null) {
@@ -289,7 +289,7 @@
             persistentCookie = readVarInt32(in) != 0;
             continue;
           case 4:
-            externalId = new AccountExternalId.Key(readString(in));
+            externalId = ExternalId.Key.parse(readString(in));
             continue;
           case 5:
             sessionId = readString(in);
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
index d6bc5dc..b7c6be3 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
@@ -14,7 +14,8 @@
 
 package com.google.gerrit.httpd.auth.become;
 
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_UUID;
 
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -23,13 +24,13 @@
 import com.google.gerrit.httpd.WebSession;
 import com.google.gerrit.httpd.template.SiteHeaderFooter;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.gwtorm.server.OrmException;
@@ -179,17 +180,16 @@
     return null;
   }
 
-  private AuthResult auth(final AccountExternalId account) {
+  private AuthResult auth(Account.Id account) {
     if (account != null) {
-      return new AuthResult(account.getAccountId(), null, false);
+      return new AuthResult(account, null, false);
     }
     return null;
   }
 
   private AuthResult byUserName(final String userName) {
     try {
-      AccountExternalId.Key extKey = new AccountExternalId.Key(SCHEME_USERNAME, userName);
-      List<AccountState> accountStates = accountQuery.byExternalId(extKey.get());
+      List<AccountState> accountStates = accountQuery.byExternalId(SCHEME_USERNAME, userName);
       if (accountStates.isEmpty()) {
         getServletContext().log("No accounts with username " + userName + " found");
         return null;
@@ -198,7 +198,7 @@
         getServletContext().log("Multiple accounts with username " + userName + " found");
         return null;
       }
-      return auth(new AccountExternalId(accountStates.get(0).getAccount().getId(), extKey));
+      return auth(accountStates.get(0).getAccount().getId());
     } catch (OrmException e) {
       getServletContext().log("cannot query account index", e);
       return null;
@@ -231,9 +231,9 @@
   }
 
   private AuthResult create() throws IOException {
-    String fakeId = AccountExternalId.SCHEME_UUID + UUID.randomUUID();
     try {
-      return accountManager.authenticate(new AuthRequest(fakeId));
+      return accountManager.authenticate(
+          new AuthRequest(ExternalId.Key.create(SCHEME_UUID, UUID.randomUUID().toString())));
     } catch (AccountException e) {
       getServletContext().log("cannot create new account", e);
       return null;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
index f921dbc..5a0ed71 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
@@ -17,7 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Strings.emptyToNull;
 import static com.google.common.net.HttpHeaders.AUTHORIZATION;
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
@@ -26,7 +26,7 @@
 import com.google.gerrit.httpd.RemoteUserUtil;
 import com.google.gerrit.httpd.WebSession;
 import com.google.gerrit.httpd.raw.HostPageServlet;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.gwtjsonrpc.server.RPCServletUtils;
@@ -127,8 +127,8 @@
   }
 
   private static boolean correctUser(String user, WebSession session) {
-    AccountExternalId.Key id = session.getLastLoginExternalId();
-    return id != null && id.equals(new AccountExternalId.Key(SCHEME_GERRIT, user));
+    ExternalId.Key id = session.getLastLoginExternalId();
+    return id != null && id.equals(ExternalId.Key.create(SCHEME_GERRIT, user));
   }
 
   String getRemoteUser(HttpServletRequest req) {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
index 7bc5e7c..40b543b 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.httpd.auth.container;
 
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_EXTERNAL;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_EXTERNAL;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.common.PageLinks;
@@ -23,11 +23,11 @@
 import com.google.gerrit.httpd.HtmlDomUtil;
 import com.google.gerrit.httpd.LoginUrlToken;
 import com.google.gerrit.httpd.WebSession;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.gwtorm.server.OrmException;
@@ -39,6 +39,7 @@
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.w3c.dom.Document;
@@ -127,7 +128,7 @@
       try {
         log.debug("Associating external identity \"{}\" to user \"{}\"", remoteExternalId, user);
         updateRemoteExternalId(arsp, remoteExternalId);
-      } catch (AccountException | OrmException e) {
+      } catch (AccountException | OrmException | ConfigInvalidException e) {
         log.error(
             "Unable to associate external identity \""
                 + remoteExternalId
@@ -156,12 +157,10 @@
   }
 
   private void updateRemoteExternalId(AuthResult arsp, String remoteAuthToken)
-      throws AccountException, OrmException, IOException {
-    AccountExternalId remoteAuthExtId =
-        new AccountExternalId(
-            arsp.getAccountId(), new AccountExternalId.Key(SCHEME_EXTERNAL, remoteAuthToken));
+      throws AccountException, OrmException, IOException, ConfigInvalidException {
     accountManager.updateLink(
-        arsp.getAccountId(), new AuthRequest(remoteAuthExtId.getExternalId()));
+        arsp.getAccountId(),
+        new AuthRequest(ExternalId.Key.create(SCHEME_EXTERNAL, remoteAuthToken)));
   }
 
   private void replace(Document doc, String name, String value) {
diff --git a/gerrit-oauth/BUILD b/gerrit-oauth/BUILD
index b459c70..0ef89c0 100644
--- a/gerrit-oauth/BUILD
+++ b/gerrit-oauth/BUILD
@@ -22,6 +22,7 @@
         "//lib/commons:codec",
         "//lib/guice",
         "//lib/guice:guice-servlet",
+        "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/log:api",
     ],
 )
diff --git a/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java b/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
index 7cfaf46..0391831 100644
--- a/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
+++ b/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.auth.oauth.OAuthTokenCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -44,6 +45,7 @@
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import org.apache.commons.codec.binary.Base64;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -124,7 +126,7 @@
 
   private void authenticateAndRedirect(
       HttpServletRequest req, HttpServletResponse rsp, OAuthToken token) throws IOException {
-    AuthRequest areq = new AuthRequest(user.getExternalId());
+    AuthRequest areq = new AuthRequest(ExternalId.Key.parse(user.getExternalId()));
     AuthResult arsp;
     try {
       String claimedIdentifier = user.getClaimedIdentity();
@@ -190,7 +192,7 @@
       log.info("OAuth2: linking claimed identity to {}", claimedId.get().toString());
       try {
         accountManager.link(claimedId.get(), req);
-      } catch (OrmException e) {
+      } catch (OrmException | ConfigInvalidException e) {
         log.error(
             "Cannot link: "
                 + user.getExternalId()
@@ -210,7 +212,7 @@
       throws AccountException, IOException {
     try {
       accountManager.link(identifiedUser.get().getAccountId(), areq);
-    } catch (OrmException e) {
+    } catch (OrmException | ConfigInvalidException e) {
       log.error(
           "Cannot link: "
               + user.getExternalId()
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
index 4a2ffec..e862bac 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthResult;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -43,6 +44,7 @@
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import org.apache.commons.codec.binary.Base64;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -116,7 +118,8 @@
   private void authenticateAndRedirect(HttpServletRequest req, HttpServletResponse rsp)
       throws IOException {
     com.google.gerrit.server.account.AuthRequest areq =
-        new com.google.gerrit.server.account.AuthRequest(user.getExternalId());
+        new com.google.gerrit.server.account.AuthRequest(
+            ExternalId.Key.parse(user.getExternalId()));
     AuthResult arsp = null;
     try {
       String claimedIdentifier = user.getClaimedIdentity();
@@ -167,7 +170,7 @@
           log.debug("Claimed account already exists: link to it.");
           try {
             accountManager.link(claimedId.get(), areq);
-          } catch (OrmException e) {
+          } catch (OrmException | ConfigInvalidException e) {
             log.error(
                 "Cannot link: "
                     + user.getExternalId()
@@ -186,7 +189,7 @@
         try {
           log.debug("Linking \"{}\" to \"{}\"", user.getExternalId(), accountId);
           accountManager.link(accountId, areq);
-        } catch (OrmException e) {
+        } catch (OrmException | ConfigInvalidException e) {
           log.error("Cannot link: " + user.getExternalId() + " to user identity: " + accountId);
           rsp.sendError(HttpServletResponse.SC_FORBIDDEN);
           return;
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
index dbc0d14..efe8c5f 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.server.UrlEncoded;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.auth.openid.OpenIdProviderPattern;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.ConfigUtil;
@@ -314,7 +315,7 @@
     }
 
     final com.google.gerrit.server.account.AuthRequest areq =
-        new com.google.gerrit.server.account.AuthRequest(openidIdentifier);
+        new com.google.gerrit.server.account.AuthRequest(ExternalId.Key.parse(openidIdentifier));
 
     if (sregRsp != null) {
       areq.setDisplayName(sregRsp.getAttributeValue("fullname"));
@@ -369,7 +370,7 @@
       // link between the two, so set one up if not present.
       //
       Optional<Account.Id> claimedId = accountManager.lookup(claimedIdentifier);
-      Optional<Account.Id> actualId = accountManager.lookup(areq.getExternalId());
+      Optional<Account.Id> actualId = accountManager.lookup(areq.getExternalIdKey().get());
 
       if (claimedId.isPresent() && actualId.isPresent()) {
         if (claimedId.get().equals(actualId.get())) {
@@ -388,7 +389,7 @@
                   + "  Delgate ID: "
                   + actualId.get()
                   + " is "
-                  + areq.getExternalId());
+                  + areq.getExternalIdKey());
           cancelWithError(req, rsp, "Contact site administrator");
           return;
         }
@@ -398,7 +399,8 @@
         // was missing due to a bug in Gerrit. Link the claimed.
         //
         final com.google.gerrit.server.account.AuthRequest linkReq =
-            new com.google.gerrit.server.account.AuthRequest(claimedIdentifier);
+            new com.google.gerrit.server.account.AuthRequest(
+                ExternalId.Key.parse(claimedIdentifier));
         linkReq.setDisplayName(areq.getDisplayName());
         linkReq.setEmailAddress(areq.getEmailAddress());
         accountManager.link(actualId.get(), linkReq);
@@ -434,7 +436,8 @@
           webSession.get().login(arsp, remember);
           if (arsp.isNew() && claimedIdentifier != null) {
             final com.google.gerrit.server.account.AuthRequest linkReq =
-                new com.google.gerrit.server.account.AuthRequest(claimedIdentifier);
+                new com.google.gerrit.server.account.AuthRequest(
+                    ExternalId.Key.parse(claimedIdentifier));
             linkReq.setDisplayName(areq.getDisplayName());
             linkReq.setEmailAddress(areq.getEmailAddress());
             accountManager.link(arsp.getAccountId(), linkReq);
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
index 72b8078..7457f40 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
@@ -14,115 +14,67 @@
 
 package com.google.gerrit.pgm;
 
+import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
 import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
 
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.pgm.util.SiteProgram;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.ExternalIdsBatchUpdate;
 import com.google.gerrit.server.schema.SchemaVersionCheck;
-import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
+import java.util.Collection;
 import java.util.Locale;
 import org.eclipse.jgit.lib.TextProgressMonitor;
-import org.kohsuke.args4j.Option;
 
 /** Converts the local username for all accounts to lower case */
 public class LocalUsernamesToLowerCase extends SiteProgram {
-  @Option(name = "--threads", usage = "Number of concurrent threads to run")
-  private int threads = 2;
-
   private final LifecycleManager manager = new LifecycleManager();
   private final TextProgressMonitor monitor = new TextProgressMonitor();
-  private List<AccountExternalId> todo;
-
-  private Injector dbInjector;
 
   @Inject private SchemaFactory<ReviewDb> database;
 
+  @Inject private ExternalIdsBatchUpdate externalIdsBatchUpdate;
+
   @Override
   public int run() throws Exception {
-    if (threads <= 0) {
-      threads = 1;
-    }
-
-    dbInjector = createDbInjector(MULTI_USER);
+    Injector dbInjector = createDbInjector(MULTI_USER);
     manager.add(dbInjector, dbInjector.createChildInjector(SchemaVersionCheck.module()));
     manager.start();
     dbInjector.injectMembers(this);
 
     try (ReviewDb db = database.open()) {
-      todo = db.accountExternalIds().all().toList();
-      synchronized (monitor) {
-        monitor.beginTask("Converting local usernames", todo.size());
-      }
-    }
+      Collection<ExternalId> todo = ExternalId.from(db.accountExternalIds().all().toList());
+      monitor.beginTask("Converting local usernames", todo.size());
 
-    final List<Worker> workers = new ArrayList<>(threads);
-    for (int tid = 0; tid < threads; tid++) {
-      Worker t = new Worker();
-      t.start();
-      workers.add(t);
+      for (ExternalId extId : todo) {
+        convertLocalUserToLowerCase(extId);
+        monitor.update(1);
+      }
+
+      externalIdsBatchUpdate.commit(db, "Convert local usernames to lower case");
     }
-    for (Worker t : workers) {
-      t.join();
-    }
-    synchronized (monitor) {
-      monitor.endTask();
-    }
+    monitor.endTask();
     manager.stop();
     return 0;
   }
 
-  private void convertLocalUserToLowerCase(final ReviewDb db, final AccountExternalId extId) {
-    if (extId.isScheme(AccountExternalId.SCHEME_GERRIT)) {
-      final String localUser = extId.getSchemeRest();
-      final String localUserLowerCase = localUser.toLowerCase(Locale.US);
+  private void convertLocalUserToLowerCase(ExternalId extId) {
+    if (extId.isScheme(SCHEME_GERRIT)) {
+      String localUser = extId.key().id();
+      String localUserLowerCase = localUser.toLowerCase(Locale.US);
       if (!localUser.equals(localUserLowerCase)) {
-        final AccountExternalId.Key extIdKeyLowerCase =
-            new AccountExternalId.Key(AccountExternalId.SCHEME_GERRIT, localUserLowerCase);
-        final AccountExternalId extIdLowerCase =
-            new AccountExternalId(extId.getAccountId(), extIdKeyLowerCase);
-        try {
-          db.accountExternalIds().insert(Collections.singleton(extIdLowerCase));
-          db.accountExternalIds().delete(Collections.singleton(extId));
-        } catch (OrmException error) {
-          System.err.println("ERR " + error.getMessage());
-        }
-      }
-    }
-  }
-
-  private AccountExternalId next() {
-    synchronized (todo) {
-      if (todo.isEmpty()) {
-        return null;
-      }
-      return todo.remove(todo.size() - 1);
-    }
-  }
-
-  private class Worker extends Thread {
-    @Override
-    public void run() {
-      try (ReviewDb db = database.open()) {
-        for (; ; ) {
-          final AccountExternalId extId = next();
-          if (extId == null) {
-            break;
-          }
-          convertLocalUserToLowerCase(db, extId);
-          synchronized (monitor) {
-            monitor.update(1);
-          }
-        }
-      } catch (OrmException e) {
-        e.printStackTrace();
+        ExternalId extIdLowerCase =
+            ExternalId.create(
+                SCHEME_GERRIT,
+                localUserLowerCase,
+                extId.accountId(),
+                extId.email(),
+                extId.password());
+        externalIdsBatchUpdate.replace(extId, extIdLowerCase);
       }
     }
   }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
new file mode 100644
index 0000000..86c5f45e
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.init;
+
+import static com.google.gerrit.server.account.ExternalId.toAccountExternalIds;
+
+import com.google.gerrit.pgm.init.api.InitFlags;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdentProvider;
+import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.ExternalIds;
+import com.google.gerrit.server.account.ExternalIdsUpdate;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Collection;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.RepositoryCache.FileKey;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.FS;
+
+public class ExternalIdsOnInit {
+  private final InitFlags flags;
+  private final SitePaths site;
+  private final String allUsers;
+
+  @Inject
+  public ExternalIdsOnInit(InitFlags flags, SitePaths site, AllUsersNameOnInitProvider allUsers) {
+    this.flags = flags;
+    this.site = site;
+    this.allUsers = allUsers.get();
+  }
+
+  public synchronized void insert(ReviewDb db, String commitMessage, Collection<ExternalId> extIds)
+      throws OrmException, IOException, ConfigInvalidException {
+    db.accountExternalIds().insert(toAccountExternalIds(extIds));
+
+    File path = getPath();
+    if (path != null) {
+      try (Repository repo = new FileRepository(path);
+          RevWalk rw = new RevWalk(repo);
+          ObjectInserter ins = repo.newObjectInserter()) {
+        ObjectId rev = ExternalIds.readRevision(repo);
+
+        NoteMap noteMap = ExternalIds.readNoteMap(rw, rev);
+        for (ExternalId extId : extIds) {
+          ExternalIdsUpdate.insert(rw, ins, noteMap, extId);
+        }
+
+        PersonIdent serverIdent = new GerritPersonIdentProvider(flags.cfg).get();
+        ExternalIdsUpdate.commit(
+            repo, rw, ins, rev, noteMap, commitMessage, serverIdent, serverIdent);
+      }
+    }
+  }
+
+  private File getPath() {
+    Path basePath = site.resolve(flags.cfg.getString("gerrit", null, "basePath"));
+    if (basePath == null) {
+      throw new IllegalStateException("gerrit.basePath must be configured");
+    }
+    return FileKey.resolve(basePath.resolve(allUsers).toFile(), FS.DETECTED);
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
index 2fe4ec3..68b2b96 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -23,13 +23,13 @@
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.client.AccountGroupName;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gwtorm.server.SchemaFactory;
@@ -48,15 +48,20 @@
   private final ConsoleUI ui;
   private final InitFlags flags;
   private final VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory;
+  private final ExternalIdsOnInit externalIds;
   private SchemaFactory<ReviewDb> dbFactory;
   private AccountIndexCollection indexCollection;
 
   @Inject
   InitAdminUser(
-      InitFlags flags, ConsoleUI ui, VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory) {
+      InitFlags flags,
+      ConsoleUI ui,
+      VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory,
+      ExternalIdsOnInit externalIds) {
     this.flags = flags;
     this.ui = ui;
     this.authorizedKeysFactory = authorizedKeysFactory;
+    this.externalIds = externalIds;
   }
 
   @Override
@@ -90,24 +95,13 @@
           AccountSshKey sshKey = readSshKey(id);
           String email = readEmail(sshKey);
 
-          List<AccountExternalId> extIds = new ArrayList<>(2);
-          AccountExternalId extUser =
-              new AccountExternalId(
-                  id, new AccountExternalId.Key(AccountExternalId.SCHEME_USERNAME, username));
-          if (!Strings.isNullOrEmpty(httpPassword)) {
-            extUser.setPassword(httpPassword);
-          }
-          extIds.add(extUser);
-          db.accountExternalIds().insert(Collections.singleton(extUser));
+          List<ExternalId> extIds = new ArrayList<>(2);
+          extIds.add(ExternalId.createUsername(username, id, httpPassword));
 
           if (email != null) {
-            AccountExternalId extMailto =
-                new AccountExternalId(
-                    id, new AccountExternalId.Key(AccountExternalId.SCHEME_MAILTO, email));
-            extMailto.setEmailAddress(email);
-            extIds.add(extMailto);
-            db.accountExternalIds().insert(Collections.singleton(extMailto));
+            extIds.add(ExternalId.createEmail(id, email));
           }
+          externalIds.insert(db, "Add external IDs for initial admin user", extIds);
 
           Account a = new Account(id, TimeUtil.nowTs());
           a.setFullName(name);
@@ -123,7 +117,7 @@
           if (sshKey != null) {
             VersionedAuthorizedKeysOnInit authorizedKeys = authorizedKeysFactory.create(id).load();
             authorizedKeys.addKey(sshKey.getSshPublicKey());
-            authorizedKeys.save("Added SSH key for initial admin user\n");
+            authorizedKeys.save("Add SSH key for initial admin user\n");
           }
 
           AccountGroup adminGroup = db.accountGroups().get(adminGroupName.getId());
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java
index 4e457e1..fb8e296 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java
@@ -25,16 +25,16 @@
 /**
  * Information about a single user.
  *
- * <p>A user may have multiple identities they can use to login to Gerrit (see {@link
- * AccountExternalId}), but in such cases they always map back to a single Account entity.
+ * <p>A user may have multiple identities they can use to login to Gerrit (see ExternalId), but in
+ * such cases they always map back to a single Account entity.
  *
  * <p>Entities "owned" by an Account (that is, their primary key contains the {@link Account.Id} key
  * as part of their key structure):
  *
  * <ul>
- *   <li>{@link AccountExternalId}: OpenID identities and email addresses known to be registered to
- *       this user. Multiple records can exist when the user has more than one public identity, such
- *       as a work and a personal email address.
+ *   <li>ExternalId: OpenID identities and email addresses known to be registered to this user.
+ *       Multiple records can exist when the user has more than one public identity, such as a work
+ *       and a personal email address.
  *   <li>{@link AccountGroupMember}: membership of the user in a specific human managed {@link
  *       AccountGroup}. Multiple records can exist when the user is a member of more than one group.
  *   <li>{@link AccountSshKey}: user's public SSH keys, for authentication through the internal SSH
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountExternalId.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountExternalId.java
index a789580..3c8f2fa 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountExternalId.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountExternalId.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gwtorm.client.Column;
 import com.google.gwtorm.client.StringKey;
+import java.util.Objects;
 
 /** Association of an external account identifier to a local {@link Account}. */
 public final class AccountExternalId {
@@ -87,6 +88,8 @@
   @Column(id = 3, notNull = false)
   protected String emailAddress;
 
+  // Encoded version of the hashed and salted password, to be interpreted by the
+  // {@link HashedPassword} class.
   @Column(id = 4, notNull = false)
   protected String password;
 
@@ -140,12 +143,12 @@
     return null != scheme ? getExternalId().substring(scheme.length() + 1) : null;
   }
 
-  public String getPassword() {
-    return password;
+  public void setPassword(String hashed) {
+    password = hashed;
   }
 
-  public void setPassword(String p) {
-    password = p;
+  public String getPassword() {
+    return password;
   }
 
   public boolean isTrusted() {
@@ -163,4 +166,21 @@
   public void setCanDelete(final boolean t) {
     canDelete = t;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof AccountExternalId) {
+      AccountExternalId extId = (AccountExternalId) o;
+      return Objects.equals(key, extId.key)
+          && Objects.equals(accountId, extId.accountId)
+          && Objects.equals(emailAddress, extId.emailAddress)
+          && Objects.equals(password, extId.password);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(key, accountId, emailAddress, password);
+  }
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
index 176d6a9..b892e3d 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
@@ -34,6 +34,9 @@
   /** Configuration settings for a project {@code refs/meta/config} */
   public static final String REFS_CONFIG = "refs/meta/config";
 
+  /** Note tree listing external IDs */
+  public static final String REFS_EXTERNAL_IDS = "refs/meta/external-ids";
+
   /** Preference settings for a user {@code refs/users} */
   public static final String REFS_USERS = "refs/users/";
 
diff --git a/gerrit-server/BUILD b/gerrit-server/BUILD
index be45591..058ca02 100644
--- a/gerrit-server/BUILD
+++ b/gerrit-server/BUILD
@@ -230,9 +230,11 @@
         "//lib:guava",
         "//lib:guava-retrying",
         "//lib:protobuf",
+        "//lib/bouncycastle:bcprov",
         "//lib/dropwizard:dropwizard-core",
         "//lib/guice:guice-assistedinject",
         "//lib/prolog:runtime",
+        "//lib/commons:codec",
     ],
 )
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java
index ebed0f9..029b54d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java
@@ -16,8 +16,8 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.server.account.CapabilityControl;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.inject.servlet.RequestScoped;
 import java.util.function.Consumer;
@@ -44,7 +44,7 @@
   private AccessPath accessPath = AccessPath.UNKNOWN;
 
   private CapabilityControl capabilities;
-  private PropertyKey<AccountExternalId.Key> lastLoginExternalIdPropertyKey = PropertyKey.create();
+  private PropertyKey<ExternalId.Key> lastLoginExternalIdPropertyKey = PropertyKey.create();
 
   protected CurrentUser(CapabilityControl.Factory capabilityControlFactory) {
     this.capabilityControlFactory = capabilityControlFactory;
@@ -151,11 +151,11 @@
    */
   public <T> void put(PropertyKey<T> key, @Nullable T value) {}
 
-  public void setLastLoginExternalIdKey(AccountExternalId.Key externalIdKey) {
+  public void setLastLoginExternalIdKey(ExternalId.Key externalIdKey) {
     put(lastLoginExternalIdPropertyKey, externalIdKey);
   }
 
-  public AccountExternalId.Key getLastLoginExternalIdKey() {
+  public ExternalId.Key getLastLoginExternalIdKey() {
     return get(lastLoginExternalIdPropertyKey);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java
index 3ba457c..0f9ec8d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java
@@ -17,7 +17,6 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.client.AccountFieldName;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.mail.send.EmailSender;
 import com.google.inject.Inject;
@@ -53,8 +52,8 @@
 
   @Override
   public boolean hasEmailAddress(IdentifiedUser user, String email) {
-    for (AccountExternalId ext : user.state().getExternalIds()) {
-      if (email != null && email.equalsIgnoreCase(ext.getEmailAddress())) {
+    for (ExternalId ext : user.state().getExternalIds()) {
+      if (email != null && email.equalsIgnoreCase(ext.email())) {
         return true;
       }
     }
@@ -63,11 +62,11 @@
 
   @Override
   public Set<String> getEmailAddresses(IdentifiedUser user) {
-    Collection<AccountExternalId> ids = user.state().getExternalIds();
+    Collection<ExternalId> ids = user.state().getExternalIds();
     Set<String> emails = Sets.newHashSetWithExpectedSize(ids.size());
-    for (AccountExternalId ext : ids) {
-      if (!Strings.isNullOrEmpty(ext.getEmailAddress())) {
-        emails.add(ext.getEmailAddress());
+    for (ExternalId ext : ids) {
+      if (!Strings.isNullOrEmpty(ext.email())) {
+        emails.add(ext.email());
       }
     }
     return emails;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java
index 56c41e0..d45ecd8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java
@@ -18,7 +18,6 @@
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
@@ -94,12 +93,15 @@
         for (Account a : db.accounts().byPreferredEmail(email)) {
           r.add(a.getId());
         }
-        for (AccountState accountState :
-            accountQueryProvider
-                .get()
-                .byExternalId(
-                    (new AccountExternalId.Key(AccountExternalId.SCHEME_MAILTO, email)).get())) {
-          r.add(accountState.getAccount().getId());
+        for (AccountState accountState : accountQueryProvider.get().byEmailPrefix(email)) {
+          if (accountState
+              .getExternalIds()
+              .stream()
+              .filter(e -> email.equals(e.email()))
+              .findAny()
+              .isPresent()) {
+            r.add(accountState.getAccount().getId());
+          }
         }
         return ImmutableSet.copyOf(r);
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 535dfcb..245a0be 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -14,13 +14,14 @@
 
 package com.google.gerrit.server.account;
 
+import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -38,7 +39,6 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import java.io.IOException;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -138,9 +138,9 @@
   private static AccountState missing(Account.Id accountId) {
     Account account = new Account(accountId, TimeUtil.nowTs());
     account.setActive(false);
-    Collection<AccountExternalId> ids = Collections.emptySet();
     Set<AccountGroup.UUID> anon = ImmutableSet.of();
-    return new AccountState(account, anon, ids, new HashMap<ProjectWatchKey, Set<NotifyType>>());
+    return new AccountState(
+        account, anon, Collections.emptySet(), new HashMap<ProjectWatchKey, Set<NotifyType>>());
   }
 
   static class ByIdLoader extends CacheLoader<Account.Id, AccountState> {
@@ -184,8 +184,8 @@
         return missing(who);
       }
 
-      Collection<AccountExternalId> externalIds =
-          Collections.unmodifiableCollection(db.accountExternalIds().byAccount(who).toList());
+      Set<ExternalId> externalIds =
+          ExternalId.from(db.accountExternalIds().byAccount(who).toList());
 
       Set<AccountGroup.UUID> internalGroups = new HashSet<>();
       for (AccountGroupMember g : db.accountGroupMembers().byAccount(who)) {
@@ -219,11 +219,8 @@
 
     @Override
     public Optional<Account.Id> load(String username) throws Exception {
-      AccountExternalId.Key key =
-          new AccountExternalId.Key( //
-              AccountExternalId.SCHEME_USERNAME, //
-              username);
-      AccountState accountState = accountQueryProvider.get().oneByExternalId(key.get());
+      AccountState accountState =
+          accountQueryProvider.get().oneByExternalId(SCHEME_USERNAME, username);
       return Optional.ofNullable(accountState).map(s -> s.getAccount().getId());
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
index 9bbf8ac..77d28f9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.account;
 
+import static java.util.stream.Collectors.toSet;
+
 import com.google.common.base.Strings;
 import com.google.gerrit.audit.AuditService;
 import com.google.gerrit.common.TimeUtil;
@@ -23,7 +25,6 @@
 import com.google.gerrit.common.errors.NameAlreadyUsedException;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -31,17 +32,16 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
-import java.util.List;
 import java.util.Optional;
 import java.util.concurrent.atomic.AtomicBoolean;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -60,6 +60,7 @@
   private final AtomicBoolean awaitsFirstAccountCheck;
   private final AuditService auditService;
   private final Provider<InternalAccountQuery> accountQueryProvider;
+  private final ExternalIdsUpdate.Server externalIdsUpdateFactory;
 
   @Inject
   AccountManager(
@@ -71,7 +72,8 @@
       ChangeUserName.Factory changeUserNameFactory,
       ProjectCache projectCache,
       AuditService auditService,
-      Provider<InternalAccountQuery> accountQueryProvider) {
+      Provider<InternalAccountQuery> accountQueryProvider,
+      ExternalIdsUpdate.Server externalIdsUpdateFactory) {
     this.schema = schema;
     this.byIdCache = byIdCache;
     this.byEmailCache = byEmailCache;
@@ -82,6 +84,7 @@
     this.awaitsFirstAccountCheck = new AtomicBoolean(true);
     this.auditService = auditService;
     this.accountQueryProvider = accountQueryProvider;
+    this.externalIdsUpdateFactory = externalIdsUpdateFactory;
   }
 
   /** @return user identified by this external identity string */
@@ -108,8 +111,7 @@
     who = realm.authenticate(who);
     try {
       try (ReviewDb db = schema.open()) {
-        AccountExternalId.Key key = id(who);
-        AccountExternalId id = getAccountExternalId(key);
+        ExternalId id = findExternalId(who.getExternalIdKey());
         if (id == null) {
           // New account, automatically create and return.
           //
@@ -117,25 +119,25 @@
         }
 
         // Account exists
-        Account act = byIdCache.get(id.getAccountId()).getAccount();
+        Account act = byIdCache.get(id.accountId()).getAccount();
         if (!act.isActive()) {
           throw new AccountException("Authentication error, account inactive");
         }
 
         // return the identity to the caller.
         update(db, who, id);
-        return new AuthResult(id.getAccountId(), key, false);
+        return new AuthResult(id.accountId(), who.getExternalIdKey(), false);
       }
-    } catch (OrmException e) {
+    } catch (OrmException | ConfigInvalidException e) {
       throw new AccountException("Authentication error", e);
     }
   }
 
-  private AccountExternalId getAccountExternalId(AccountExternalId.Key key) throws OrmException {
-    AccountState accountState = accountQueryProvider.get().oneByExternalId(key.get());
+  private ExternalId findExternalId(ExternalId.Key key) throws OrmException {
+    AccountState accountState = accountQueryProvider.get().oneByExternalId(key);
     if (accountState != null) {
-      for (AccountExternalId extId : accountState.getExternalIds()) {
-        if (extId.getKey().equals(key)) {
+      for (ExternalId extId : accountState.getExternalIds()) {
+        if (extId.key().equals(key)) {
           return extId;
         }
       }
@@ -143,24 +145,28 @@
     return null;
   }
 
-  private void update(ReviewDb db, AuthRequest who, AccountExternalId extId)
-      throws OrmException, IOException {
-    IdentifiedUser user = userFactory.create(extId.getAccountId());
+  private void update(ReviewDb db, AuthRequest who, ExternalId extId)
+      throws OrmException, IOException, ConfigInvalidException {
+    IdentifiedUser user = userFactory.create(extId.accountId());
     Account toUpdate = null;
 
     // If the email address was modified by the authentication provider,
     // update our records to match the changed email.
     //
     String newEmail = who.getEmailAddress();
-    String oldEmail = extId.getEmailAddress();
+    String oldEmail = extId.email();
     if (newEmail != null && !newEmail.equals(oldEmail)) {
       if (oldEmail != null && oldEmail.equals(user.getAccount().getPreferredEmail())) {
         toUpdate = load(toUpdate, user.getAccountId(), db);
         toUpdate.setPreferredEmail(newEmail);
       }
 
-      extId.setEmailAddress(newEmail);
-      db.accountExternalIds().update(Collections.singleton(extId));
+      externalIdsUpdateFactory
+          .create()
+          .replace(
+              db,
+              extId,
+              ExternalId.create(extId.key(), extId.accountId(), newEmail, extId.password()));
     }
 
     if (!realm.allowsEdit(AccountFieldName.FULL_NAME)
@@ -206,14 +212,14 @@
   }
 
   private AuthResult create(ReviewDb db, AuthRequest who)
-      throws OrmException, AccountException, IOException {
+      throws OrmException, AccountException, IOException, ConfigInvalidException {
     Account.Id newId = new Account.Id(db.nextAccountId());
     Account account = new Account(newId, TimeUtil.nowTs());
-    AccountExternalId extId = createId(newId, who);
 
-    extId.setEmailAddress(who.getEmailAddress());
+    ExternalId extId =
+        ExternalId.createWithEmail(who.getExternalIdKey(), newId, who.getEmailAddress());
     account.setFullName(who.getDisplayName());
-    account.setPreferredEmail(extId.getEmailAddress());
+    account.setPreferredEmail(extId.email());
 
     boolean isFirstAccount =
         awaitsFirstAccountCheck.getAndSet(false) && db.accounts().anyAccounts().toList().isEmpty();
@@ -221,18 +227,19 @@
     try {
       db.accounts().upsert(Collections.singleton(account));
 
-      AccountExternalId existingExtId = db.accountExternalIds().get(extId.getKey());
-      if (existingExtId != null && !existingExtId.getAccountId().equals(extId.getAccountId())) {
+      ExternalId existingExtId =
+          ExternalId.from(db.accountExternalIds().get(extId.key().asAccountExternalIdKey()));
+      if (existingExtId != null && !existingExtId.accountId().equals(extId.accountId())) {
         // external ID is assigned to another account, do not overwrite
         db.accounts().delete(Collections.singleton(account));
         throw new AccountException(
             "Cannot assign external ID \""
-                + extId.getExternalId()
+                + extId.key().get()
                 + "\" to account "
                 + newId
                 + "; external ID already in use.");
       }
-      db.accountExternalIds().upsert(Collections.singleton(extId));
+      externalIdsUpdateFactory.create().upsert(db, extId);
     } finally {
       // If adding the account failed, it may be that it actually was the
       // first account. So we reset the 'check for first account'-guard, as
@@ -291,7 +298,7 @@
     byEmailCache.evict(account.getPreferredEmail());
     byIdCache.evict(account.getId());
     realm.onCreateAccount(who, account);
-    return new AuthResult(newId, extId.getKey(), true);
+    return new AuthResult(newId, extId.key(), true);
   }
 
   /**
@@ -313,11 +320,11 @@
   private void handleSettingUserNameFailure(
       ReviewDb db,
       Account account,
-      AccountExternalId extId,
+      ExternalId extId,
       String errorMessage,
       Exception e,
       boolean logException)
-      throws AccountUserNameException, OrmException {
+      throws AccountUserNameException, OrmException, IOException, ConfigInvalidException {
     if (logException) {
       log.error(errorMessage, e);
     } else {
@@ -333,16 +340,11 @@
       // this is why the best we can do here is to fail early and cleanup
       // the database
       db.accounts().delete(Collections.singleton(account));
-      db.accountExternalIds().delete(Collections.singleton(extId));
+      externalIdsUpdateFactory.create().delete(db, extId);
       throw new AccountUserNameException(errorMessage, e);
     }
   }
 
-  private static AccountExternalId createId(Account.Id newId, AuthRequest who) {
-    String ext = who.getExternalId();
-    return new AccountExternalId(newId, new AccountExternalId.Key(ext));
-  }
-
   /**
    * Link another authentication identity to an existing account.
    *
@@ -353,19 +355,19 @@
    *     this time.
    */
   public AuthResult link(Account.Id to, AuthRequest who)
-      throws AccountException, OrmException, IOException {
+      throws AccountException, OrmException, IOException, ConfigInvalidException {
     try (ReviewDb db = schema.open()) {
-      AccountExternalId.Key key = id(who);
-      AccountExternalId extId = getAccountExternalId(key);
+      ExternalId extId = findExternalId(who.getExternalIdKey());
       if (extId != null) {
-        if (!extId.getAccountId().equals(to)) {
+        if (!extId.accountId().equals(to)) {
           throw new AccountException("Identity in use by another account");
         }
         update(db, who, extId);
       } else {
-        extId = createId(to, who);
-        extId.setEmailAddress(who.getEmailAddress());
-        db.accountExternalIds().insert(Collections.singleton(extId));
+        externalIdsUpdateFactory
+            .create()
+            .insert(
+                db, ExternalId.createWithEmail(who.getExternalIdKey(), to, who.getEmailAddress()));
 
         if (who.getEmailAddress() != null) {
           Account a = db.accounts().get(to);
@@ -381,7 +383,7 @@
         byIdCache.evict(to);
       }
 
-      return new AuthResult(to, key, false);
+      return new AuthResult(to, who.getExternalIdKey(), false);
     }
   }
 
@@ -399,31 +401,28 @@
    *     this time.
    */
   public AuthResult updateLink(Account.Id to, AuthRequest who)
-      throws OrmException, AccountException, IOException {
+      throws OrmException, AccountException, IOException, ConfigInvalidException {
     try (ReviewDb db = schema.open()) {
-      AccountExternalId.Key key = id(who);
-      List<AccountExternalId.Key> filteredKeysByScheme =
-          filterKeysByScheme(key.getScheme(), db.accountExternalIds().byAccount(to));
-      if (!filteredKeysByScheme.isEmpty()
-          && (filteredKeysByScheme.size() > 1 || !filteredKeysByScheme.contains(key))) {
-        db.accountExternalIds().deleteKeys(filteredKeysByScheme);
+      Collection<ExternalId> filteredExtIdsByScheme =
+          ExternalId.from(db.accountExternalIds().byAccount(to).toList())
+              .stream()
+              .filter(e -> e.isScheme(who.getExternalIdKey().scheme()))
+              .collect(toSet());
+
+      if (!filteredExtIdsByScheme.isEmpty()
+          && (filteredExtIdsByScheme.size() > 1
+              || !filteredExtIdsByScheme
+                  .stream()
+                  .filter(e -> e.key().equals(who.getExternalIdKey()))
+                  .findAny()
+                  .isPresent())) {
+        externalIdsUpdateFactory.create().delete(db, filteredExtIdsByScheme);
       }
       byIdCache.evict(to);
       return link(to, who);
     }
   }
 
-  private List<AccountExternalId.Key> filterKeysByScheme(
-      String keyScheme, ResultSet<AccountExternalId> externalIds) {
-    List<AccountExternalId.Key> filteredExternalIds = new ArrayList<>();
-    for (AccountExternalId accountExternalId : externalIds) {
-      if (accountExternalId.isScheme(keyScheme)) {
-        filteredExternalIds.add(accountExternalId.getKey());
-      }
-    }
-    return filteredExternalIds;
-  }
-
   /**
    * Unlink an authentication identity from an existing account.
    *
@@ -434,15 +433,15 @@
    *     at this time.
    */
   public AuthResult unlink(Account.Id from, AuthRequest who)
-      throws AccountException, OrmException, IOException {
+      throws AccountException, OrmException, IOException, ConfigInvalidException {
     try (ReviewDb db = schema.open()) {
-      AccountExternalId.Key key = id(who);
-      AccountExternalId extId = getAccountExternalId(key);
+      ExternalId extId = findExternalId(who.getExternalIdKey());
       if (extId != null) {
-        if (!extId.getAccountId().equals(from)) {
-          throw new AccountException("Identity '" + key.get() + "' in use by another account");
+        if (!extId.accountId().equals(from)) {
+          throw new AccountException(
+              "Identity '" + who.getExternalIdKey().get() + "' in use by another account");
         }
-        db.accountExternalIds().delete(Collections.singleton(extId));
+        externalIdsUpdateFactory.create().delete(db, extId);
 
         if (who.getEmailAddress() != null) {
           Account a = db.accounts().get(from);
@@ -456,14 +455,10 @@
         }
 
       } else {
-        throw new AccountException("Identity '" + key.get() + "' not found");
+        throw new AccountException("Identity '" + who.getExternalIdKey().get() + "' not found");
       }
 
-      return new AuthResult(from, key, false);
+      return new AuthResult(from, who.getExternalIdKey(), false);
     }
   }
-
-  private static AccountExternalId.Key id(AuthRequest who) {
-    return new AccountExternalId.Key(who.getExternalId());
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
index b811c84..4b9b0fb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
@@ -14,15 +14,15 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_MAILTO;
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.base.Function;
+import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.CurrentUser.PropertyKey;
 import com.google.gerrit.server.IdentifiedUser;
@@ -32,21 +32,26 @@
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
+import org.apache.commons.codec.DecoderException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class AccountState {
+  private static final Logger logger = LoggerFactory.getLogger(AccountState.class);
+
   public static final Function<AccountState, Account.Id> ACCOUNT_ID_FUNCTION =
       a -> a.getAccount().getId();
 
   private final Account account;
   private final Set<AccountGroup.UUID> internalGroups;
-  private final Collection<AccountExternalId> externalIds;
+  private final Collection<ExternalId> externalIds;
   private final Map<ProjectWatchKey, Set<NotifyType>> projectWatches;
   private Cache<IdentifiedUser.PropertyKey<Object>, Object> properties;
 
   public AccountState(
       Account account,
       Set<AccountGroup.UUID> actualGroups,
-      Collection<AccountExternalId> externalIds,
+      Collection<ExternalId> externalIds,
       Map<ProjectWatchKey, Set<NotifyType>> projectWatches) {
     this.account = account;
     this.internalGroups = actualGroups;
@@ -63,25 +68,38 @@
   /**
    * Get the username, if one has been declared for this user.
    *
-   * <p>The username is the {@link AccountExternalId} using the scheme {@link
-   * AccountExternalId#SCHEME_USERNAME}.
+   * <p>The username is the {@link ExternalId} using the scheme {@link ExternalId#SCHEME_USERNAME}.
    */
   public String getUserName() {
     return account.getUserName();
   }
 
-  /** @return the password matching the requested username; or null. */
-  public String getPassword(String username) {
-    for (AccountExternalId id : getExternalIds()) {
-      if (id.isScheme(AccountExternalId.SCHEME_USERNAME) && username.equals(id.getSchemeRest())) {
-        return id.getPassword();
+  public boolean checkPassword(String password, String username) {
+    if (password == null) {
+      return false;
+    }
+    for (ExternalId id : getExternalIds()) {
+      // Only process the "username:$USER" entry, which is unique.
+      if (!id.isScheme(SCHEME_USERNAME) || !username.equals(id.key().id())) {
+        continue;
+      }
+
+      String hashedStr = id.password();
+      if (!Strings.isNullOrEmpty(hashedStr)) {
+        try {
+          return HashedPassword.decode(hashedStr).checkPassword(password);
+        } catch (DecoderException e) {
+          logger.error(
+              String.format("DecoderException for user %s: %s ", username, e.getMessage()));
+          return false;
+        }
       }
     }
-    return null;
+    return false;
   }
 
   /** The external identities that identify the account holder. */
-  public Collection<AccountExternalId> getExternalIds() {
+  public Collection<ExternalId> getExternalIds() {
     return externalIds;
   }
 
@@ -95,20 +113,20 @@
     return internalGroups;
   }
 
-  public static String getUserName(Collection<AccountExternalId> ids) {
-    for (AccountExternalId id : ids) {
-      if (id.isScheme(SCHEME_USERNAME)) {
-        return id.getSchemeRest();
+  public static String getUserName(Collection<ExternalId> ids) {
+    for (ExternalId extId : ids) {
+      if (extId.isScheme(SCHEME_USERNAME)) {
+        return extId.key().id();
       }
     }
     return null;
   }
 
-  public static Set<String> getEmails(Collection<AccountExternalId> ids) {
+  public static Set<String> getEmails(Collection<ExternalId> ids) {
     Set<String> emails = new HashSet<>();
-    for (AccountExternalId id : ids) {
-      if (id.isScheme(SCHEME_MAILTO)) {
-        emails.add(id.getSchemeRest());
+    for (ExternalId extId : ids) {
+      if (extId.isScheme(SCHEME_MAILTO)) {
+        emails.add(extId.key().id());
       }
     }
     return emails;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java
index 1431640..d1dd4b0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java
@@ -14,11 +14,9 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_EXTERNAL;
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_GERRIT;
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_MAILTO;
-
-import com.google.gerrit.reviewdb.client.AccountExternalId;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_EXTERNAL;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_MAILTO;
 
 /**
  * Information for {@link AccountManager#authenticate(AuthRequest)}.
@@ -30,17 +28,15 @@
  */
 public class AuthRequest {
   /** Create a request for a local username, such as from LDAP. */
-  public static AuthRequest forUser(final String username) {
-    final AccountExternalId.Key i = new AccountExternalId.Key(SCHEME_GERRIT, username);
-    final AuthRequest r = new AuthRequest(i.get());
+  public static AuthRequest forUser(String username) {
+    AuthRequest r = new AuthRequest(ExternalId.Key.create(SCHEME_GERRIT, username));
     r.setUserName(username);
     return r;
   }
 
   /** Create a request for an external username. */
   public static AuthRequest forExternalUser(String username) {
-    AccountExternalId.Key i = new AccountExternalId.Key(SCHEME_EXTERNAL, username);
-    AuthRequest r = new AuthRequest(i.get());
+    AuthRequest r = new AuthRequest(ExternalId.Key.create(SCHEME_EXTERNAL, username));
     r.setUserName(username);
     return r;
   }
@@ -51,14 +47,13 @@
    * <p>This type of request should be used only to attach a new email address to an existing user
    * account.
    */
-  public static AuthRequest forEmail(final String email) {
-    final AccountExternalId.Key i = new AccountExternalId.Key(SCHEME_MAILTO, email);
-    final AuthRequest r = new AuthRequest(i.get());
+  public static AuthRequest forEmail(String email) {
+    AuthRequest r = new AuthRequest(ExternalId.Key.create(SCHEME_MAILTO, email));
     r.setEmailAddress(email);
     return r;
   }
 
-  private String externalId;
+  private ExternalId.Key externalId;
   private String password;
   private String displayName;
   private String emailAddress;
@@ -67,29 +62,24 @@
   private String authPlugin;
   private String authProvider;
 
-  public AuthRequest(final String externalId) {
+  public AuthRequest(ExternalId.Key externalId) {
     this.externalId = externalId;
   }
 
-  public String getExternalId() {
+  public ExternalId.Key getExternalIdKey() {
     return externalId;
   }
 
-  public boolean isScheme(final String scheme) {
-    return getExternalId().startsWith(scheme);
-  }
-
   public String getLocalUser() {
-    if (isScheme(SCHEME_GERRIT)) {
-      return getExternalId().substring(SCHEME_GERRIT.length());
+    if (externalId.isScheme(SCHEME_GERRIT)) {
+      return externalId.id();
     }
     return null;
   }
 
-  public void setLocalUser(final String localUser) {
-    if (isScheme(SCHEME_GERRIT)) {
-      final AccountExternalId.Key key = new AccountExternalId.Key(SCHEME_GERRIT, localUser);
-      externalId = key.get();
+  public void setLocalUser(String localUser) {
+    if (externalId.isScheme(SCHEME_GERRIT)) {
+      externalId = ExternalId.Key.create(SCHEME_GERRIT, localUser);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthResult.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthResult.java
index 1e75b63..4aced52 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthResult.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthResult.java
@@ -15,16 +15,14 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 
 /** Result from {@link AccountManager#authenticate(AuthRequest)}. */
 public class AuthResult {
   private final Account.Id accountId;
-  private final AccountExternalId.Key externalId;
+  private final ExternalId.Key externalId;
   private final boolean isNew;
 
-  public AuthResult(
-      final Account.Id accountId, final AccountExternalId.Key externalId, final boolean isNew) {
+  public AuthResult(Account.Id accountId, ExternalId.Key externalId, boolean isNew) {
     this.accountId = accountId;
     this.externalId = externalId;
     this.isNew = isNew;
@@ -36,7 +34,7 @@
   }
 
   /** External identity used to authenticate the user. */
-  public AccountExternalId.Key getExternalId() {
+  public ExternalId.Key getExternalId() {
     return externalId;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java
index 39c732e..f60ee45 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.errors.NameAlreadyUsedException;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ssh.SshKeyCache;
@@ -29,11 +29,10 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.concurrent.Callable;
 import java.util.regex.Pattern;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /** Operation to change the username of an account. */
 public class ChangeUserName implements Callable<VoidResult> {
@@ -48,6 +47,7 @@
 
   private final AccountCache accountCache;
   private final SshKeyCache sshKeyCache;
+  private final ExternalIdsUpdate.Server externalIdsUpdateFactory;
 
   private final ReviewDb db;
   private final IdentifiedUser user;
@@ -55,14 +55,15 @@
 
   @Inject
   ChangeUserName(
-      final AccountCache accountCache,
-      final SshKeyCache sshKeyCache,
-      @Assisted final ReviewDb db,
-      @Assisted final IdentifiedUser user,
-      @Nullable @Assisted final String newUsername) {
+      AccountCache accountCache,
+      SshKeyCache sshKeyCache,
+      ExternalIdsUpdate.Server externalIdsUpdateFactory,
+      @Assisted ReviewDb db,
+      @Assisted IdentifiedUser user,
+      @Nullable @Assisted String newUsername) {
     this.accountCache = accountCache;
     this.sshKeyCache = sshKeyCache;
-
+    this.externalIdsUpdateFactory = externalIdsUpdateFactory;
     this.db = db;
     this.user = user;
     this.newUsername = newUsername;
@@ -70,33 +71,38 @@
 
   @Override
   public VoidResult call()
-      throws OrmException, NameAlreadyUsedException, InvalidUserNameException, IOException {
-    final Collection<AccountExternalId> old = old();
+      throws OrmException, NameAlreadyUsedException, InvalidUserNameException, IOException,
+          ConfigInvalidException {
+    Collection<ExternalId> old =
+        ExternalId.from(db.accountExternalIds().byAccount(user.getAccountId()).toList())
+            .stream()
+            .filter(e -> e.isScheme(SCHEME_USERNAME))
+            .collect(toSet());
     if (!old.isEmpty()) {
       throw new IllegalStateException(USERNAME_CANNOT_BE_CHANGED);
     }
 
+    ExternalIdsUpdate externalIdsUpdate = externalIdsUpdateFactory.create();
     if (newUsername != null && !newUsername.isEmpty()) {
       if (!USER_NAME_PATTERN.matcher(newUsername).matches()) {
         throw new InvalidUserNameException();
       }
 
-      final AccountExternalId.Key key = new AccountExternalId.Key(SCHEME_USERNAME, newUsername);
+      ExternalId.Key key = ExternalId.Key.create(SCHEME_USERNAME, newUsername);
       try {
-        final AccountExternalId id = new AccountExternalId(user.getAccountId(), key);
-
-        for (AccountExternalId i : old) {
-          if (i.getPassword() != null) {
-            id.setPassword(i.getPassword());
+        String password = null;
+        for (ExternalId i : old) {
+          if (i.password() != null) {
+            password = i.password();
           }
         }
-
-        db.accountExternalIds().insert(Collections.singleton(id));
+        externalIdsUpdate.insert(db, ExternalId.create(key, user.getAccountId(), null, password));
       } catch (OrmDuplicateKeyException dupeErr) {
         // If we are using this identity, don't report the exception.
         //
-        AccountExternalId other = db.accountExternalIds().get(key);
-        if (other != null && other.getAccountId().equals(user.getAccountId())) {
+        ExternalId other =
+            ExternalId.from(db.accountExternalIds().get(key.asAccountExternalIdKey()));
+        if (other != null && other.accountId().equals(user.getAccountId())) {
           return VoidResult.INSTANCE;
         }
 
@@ -108,10 +114,10 @@
 
     // If we have any older user names, remove them.
     //
-    db.accountExternalIds().delete(old);
-    for (AccountExternalId i : old) {
-      sshKeyCache.evict(i.getSchemeRest());
-      accountCache.evictByUsername(i.getSchemeRest());
+    externalIdsUpdate.delete(db, old);
+    for (ExternalId extId : old) {
+      sshKeyCache.evict(extId.key().id());
+      accountCache.evictByUsername(extId.key().id());
     }
 
     accountCache.evict(user.getAccountId());
@@ -119,14 +125,4 @@
     sshKeyCache.evict(newUsername);
     return VoidResult.INSTANCE;
   }
-
-  private Collection<AccountExternalId> old() throws OrmException {
-    final Collection<AccountExternalId> r = new ArrayList<>(1);
-    for (AccountExternalId i : db.accountExternalIds().byAccount(user.getAccountId())) {
-      if (i.isScheme(SCHEME_USERNAME)) {
-        r.add(i);
-      }
-    }
-    return r;
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
index 45f9183..9e7e9a4d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.account;
 
+import static com.google.gerrit.server.account.ExternalId.SCHEME_MAILTO;
+
 import com.google.gerrit.audit.AuditService;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.GlobalCapability;
@@ -30,7 +32,6 @@
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -70,6 +71,7 @@
   private final AccountLoader.Factory infoLoader;
   private final DynamicSet<AccountExternalIdCreator> externalIdCreators;
   private final AuditService auditService;
+  private final ExternalIdsUpdate.User externalIdsUpdateFactory;
   private final String username;
 
   @Inject
@@ -85,6 +87,7 @@
       AccountLoader.Factory infoLoader,
       DynamicSet<AccountExternalIdCreator> externalIdCreators,
       AuditService auditService,
+      ExternalIdsUpdate.User externalIdsUpdateFactory,
       @Assisted String username) {
     this.db = db;
     this.currentUser = currentUser;
@@ -97,6 +100,7 @@
     this.infoLoader = infoLoader;
     this.externalIdCreators = externalIdCreators;
     this.auditService = auditService;
+    this.externalIdsUpdateFactory = externalIdsUpdateFactory;
     this.username = username;
   }
 
@@ -120,19 +124,14 @@
 
     Account.Id id = new Account.Id(db.nextAccountId());
 
-    AccountExternalId extUser =
-        new AccountExternalId(
-            id, new AccountExternalId.Key(AccountExternalId.SCHEME_USERNAME, username));
-
-    if (input.httpPassword != null) {
-      extUser.setPassword(input.httpPassword);
-    }
-
-    if (db.accountExternalIds().get(extUser.getKey()) != null) {
+    ExternalId extUser = ExternalId.createUsername(username, id, input.httpPassword);
+    if (db.accountExternalIds().get(extUser.key().asAccountExternalIdKey()) != null) {
       throw new ResourceConflictException("username '" + username + "' already exists");
     }
     if (input.email != null) {
-      if (db.accountExternalIds().get(getEmailKey(input.email)) != null) {
+      if (db.accountExternalIds()
+              .get(ExternalId.Key.create(SCHEME_MAILTO, input.email).asAccountExternalIdKey())
+          != null) {
         throw new UnprocessableEntityException("email '" + input.email + "' already exists");
       }
       if (!OutgoingEmailValidator.isValid(input.email)) {
@@ -140,27 +139,26 @@
       }
     }
 
-    List<AccountExternalId> externalIds = new ArrayList<>();
-    externalIds.add(extUser);
+    List<ExternalId> extIds = new ArrayList<>();
+    extIds.add(extUser);
     for (AccountExternalIdCreator c : externalIdCreators) {
-      externalIds.addAll(c.create(id, username, input.email));
+      extIds.addAll(c.create(id, username, input.email));
     }
 
+    ExternalIdsUpdate externalIdsUpdate = externalIdsUpdateFactory.create();
     try {
-      db.accountExternalIds().insert(externalIds);
+      externalIdsUpdate.insert(db, extIds);
     } catch (OrmDuplicateKeyException duplicateKey) {
       throw new ResourceConflictException("username '" + username + "' already exists");
     }
 
     if (input.email != null) {
-      AccountExternalId extMailto = new AccountExternalId(id, getEmailKey(input.email));
-      extMailto.setEmailAddress(input.email);
       try {
-        db.accountExternalIds().insert(Collections.singleton(extMailto));
+        externalIdsUpdate.insert(db, ExternalId.createEmail(id, input.email));
       } catch (OrmDuplicateKeyException duplicateKey) {
         try {
-          db.accountExternalIds().delete(Collections.singleton(extUser));
-        } catch (OrmException cleanupError) {
+          externalIdsUpdate.delete(db, extUser);
+        } catch (IOException | ConfigInvalidException | OrmException cleanupError) {
           // Ignored
         }
         throw new UnprocessableEntityException("email '" + input.email + "' already exists");
@@ -208,8 +206,4 @@
     }
     return groupIds;
   }
-
-  private AccountExternalId.Key getEmailKey(String email) {
-    return new AccountExternalId.Key(AccountExternalId.SCHEME_MAILTO, email);
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java
index 005630c..b1a5d3b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java
@@ -37,6 +37,7 @@
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -77,7 +78,7 @@
   public Response<EmailInfo> apply(AccountResource rsrc, EmailInput input)
       throws AuthException, BadRequestException, ResourceConflictException,
           ResourceNotFoundException, OrmException, EmailException, MethodNotAllowedException,
-          IOException {
+          IOException, ConfigInvalidException {
     if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
       throw new AuthException("not allowed to add email address");
     }
@@ -104,7 +105,7 @@
   public Response<EmailInfo> apply(IdentifiedUser user, EmailInput input)
       throws AuthException, BadRequestException, ResourceConflictException,
           ResourceNotFoundException, OrmException, EmailException, MethodNotAllowedException,
-          IOException {
+          IOException, ConfigInvalidException {
     if (input.email != null && !email.equals(input.email)) {
       throw new BadRequestException("email address must match URL");
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
index 8541cf8..794a2c1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -34,6 +33,7 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class DeleteEmail implements RestModifyView<AccountResource.Email, Input> {
@@ -59,7 +59,7 @@
   @Override
   public Response<?> apply(AccountResource.Email rsrc, Input input)
       throws AuthException, ResourceNotFoundException, ResourceConflictException,
-          MethodNotAllowedException, OrmException, IOException {
+          MethodNotAllowedException, OrmException, IOException, ConfigInvalidException {
     if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
       throw new AuthException("not allowed to delete email address");
     }
@@ -68,27 +68,28 @@
 
   public Response<?> apply(IdentifiedUser user, String email)
       throws ResourceNotFoundException, ResourceConflictException, MethodNotAllowedException,
-          OrmException, IOException {
+          OrmException, IOException, ConfigInvalidException {
     if (!realm.allowsEdit(AccountFieldName.REGISTER_NEW_EMAIL)) {
       throw new MethodNotAllowedException("realm does not allow deleting emails");
     }
 
-    Set<AccountExternalId> extIds =
+    Set<ExternalId> extIds =
         dbProvider
             .get()
             .accountExternalIds()
             .byAccount(user.getAccountId())
             .toList()
             .stream()
-            .filter(e -> email.equals(e.getEmailAddress()))
+            .map(ExternalId::from)
+            .filter(e -> email.equals(e.email()))
             .collect(toSet());
     if (extIds.isEmpty()) {
       throw new ResourceNotFoundException(email);
     }
 
     try {
-      for (AccountExternalId extId : extIds) {
-        AuthRequest authRequest = new AuthRequest(extId.getKey().get());
+      for (ExternalId extId : extIds) {
+        AuthRequest authRequest = new AuthRequest(extId.key());
         authRequest.setEmailAddress(email);
         accountManager.unlink(user.getAccountId(), authRequest);
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteExternalIds.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteExternalIds.java
index 55e0581..aff843a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteExternalIds.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteExternalIds.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
 
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -24,44 +24,42 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
-@Singleton
 public class DeleteExternalIds implements RestModifyView<AccountResource, List<String>> {
-  private final Provider<ReviewDb> db;
   private final AccountByEmailCache accountByEmailCache;
   private final AccountCache accountCache;
+  private final ExternalIdsUpdate.User externalIdsUpdateFactory;
   private final Provider<CurrentUser> self;
   private final Provider<ReviewDb> dbProvider;
 
   @Inject
   DeleteExternalIds(
-      Provider<ReviewDb> db,
       AccountByEmailCache accountByEmailCache,
       AccountCache accountCache,
+      ExternalIdsUpdate.User externalIdsUpdateFactory,
       Provider<CurrentUser> self,
       Provider<ReviewDb> dbProvider) {
-    this.db = db;
     this.accountByEmailCache = accountByEmailCache;
     this.accountCache = accountCache;
+    this.externalIdsUpdateFactory = externalIdsUpdateFactory;
     this.self = self;
     this.dbProvider = dbProvider;
   }
 
   @Override
   public Response<?> apply(AccountResource resource, List<String> externalIds)
-      throws RestApiException, IOException, OrmException {
+      throws RestApiException, IOException, OrmException, ConfigInvalidException {
     if (self.get() != resource.getUser()) {
       throw new AuthException("not allowed to delete external IDs");
     }
@@ -71,18 +69,20 @@
     }
 
     Account.Id accountId = resource.getUser().getAccountId();
-    Map<AccountExternalId.Key, AccountExternalId> externalIdMap =
-        db.get()
+    Map<ExternalId.Key, ExternalId> externalIdMap =
+        dbProvider
+            .get()
             .accountExternalIds()
             .byAccount(resource.getUser().getAccountId())
             .toList()
             .stream()
-            .collect(Collectors.toMap(i -> i.getKey(), i -> i));
+            .map(ExternalId::from)
+            .collect(Collectors.toMap(i -> i.key(), i -> i));
 
-    List<AccountExternalId> toDelete = new ArrayList<>();
-    AccountExternalId.Key last = resource.getUser().getLastLoginExternalIdKey();
+    List<ExternalId> toDelete = new ArrayList<>();
+    ExternalId.Key last = resource.getUser().getLastLoginExternalIdKey();
     for (String externalIdStr : externalIds) {
-      AccountExternalId id = externalIdMap.get(new AccountExternalId.Key(externalIdStr));
+      ExternalId id = externalIdMap.get(ExternalId.Key.parse(externalIdStr));
 
       if (id == null) {
         throw new UnprocessableEntityException(
@@ -90,7 +90,7 @@
       }
 
       if ((!id.isScheme(SCHEME_USERNAME))
-          && ((last == null) || (!last.get().equals(id.getExternalId())))) {
+          && ((last == null) || (!last.get().equals(id.key().get())))) {
         toDelete.add(id);
       } else {
         throw new ResourceConflictException(
@@ -99,10 +99,10 @@
     }
 
     if (!toDelete.isEmpty()) {
-      dbProvider.get().accountExternalIds().delete(toDelete);
+      externalIdsUpdateFactory.create().delete(dbProvider.get(), toDelete);
       accountCache.evict(accountId);
-      for (AccountExternalId e : toDelete) {
-        accountByEmailCache.evict(e.getEmailAddress());
+      for (ExternalId e : toDelete) {
+        accountByEmailCache.evict(e.email());
       }
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalId.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalId.java
new file mode 100644
index 0000000..45129e3
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalId.java
@@ -0,0 +1,321 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.hash.Hashing;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountExternalId;
+import java.util.Collection;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+
+@AutoValue
+public abstract class ExternalId {
+  private static final String EXTERNAL_ID_SECTION = "externalId";
+  private static final String ACCOUNT_ID_KEY = "accountId";
+  private static final String EMAIL_KEY = "email";
+  private static final String PASSWORD_KEY = "password";
+
+  /**
+   * Scheme used for {@link AuthType#LDAP}, {@link AuthType#CLIENT_SSL_CERT_LDAP}, {@link
+   * AuthType#HTTP_LDAP}, and {@link AuthType#LDAP_BIND} usernames.
+   *
+   * <p>The name {@code gerrit:} was a very poor choice.
+   */
+  public static final String SCHEME_GERRIT = "gerrit";
+
+  /** Scheme used for randomly created identities constructed by a UUID. */
+  public static final String SCHEME_UUID = "uuid";
+
+  /** Scheme used to represent only an email address. */
+  public static final String SCHEME_MAILTO = "mailto";
+
+  /** Scheme for the username used to authenticate an account, e.g. over SSH. */
+  public static final String SCHEME_USERNAME = "username";
+
+  /** Scheme used for GPG public keys. */
+  public static final String SCHEME_GPGKEY = "gpgkey";
+
+  /** Scheme for external auth used during authentication, e.g. OAuth Token */
+  public static final String SCHEME_EXTERNAL = "external";
+
+  @AutoValue
+  public abstract static class Key {
+    public static Key create(@Nullable String scheme, String id) {
+      return new AutoValue_ExternalId_Key(scheme, id);
+    }
+
+    public static ExternalId.Key from(AccountExternalId.Key externalIdKey) {
+      return parse(externalIdKey.get());
+    }
+
+    /**
+     * Parses an external ID key from a string in the format "scheme:id" or "id".
+     *
+     * @return the parsed external ID key
+     */
+    public static Key parse(String externalId) {
+      int c = externalId.indexOf(':');
+      if (c < 1 || c >= externalId.length() - 1) {
+        return create(null, externalId);
+      }
+      return create(externalId.substring(0, c), externalId.substring(c + 1));
+    }
+
+    public static Set<AccountExternalId.Key> toAccountExternalIdKeys(
+        Collection<ExternalId.Key> extIdKeys) {
+      return extIdKeys.stream().map(k -> k.asAccountExternalIdKey()).collect(toSet());
+    }
+
+    public abstract @Nullable String scheme();
+
+    public abstract String id();
+
+    public boolean isScheme(String scheme) {
+      return scheme.equals(scheme());
+    }
+
+    public AccountExternalId.Key asAccountExternalIdKey() {
+      if (scheme() != null) {
+        return new AccountExternalId.Key(scheme(), id());
+      }
+      return new AccountExternalId.Key(id());
+    }
+
+    /**
+     * Returns the SHA1 of the external ID that is used as note ID in the refs/meta/external-ids
+     * notes branch.
+     */
+    public ObjectId sha1() {
+      return ObjectId.fromRaw(Hashing.sha1().hashString(get(), UTF_8).asBytes());
+    }
+
+    /**
+     * Exports this external ID key as string with the format "scheme:id", or "id" id scheme is
+     * null.
+     *
+     * <p>This string representation is used as subsection name in the Git config file that stores
+     * the external ID.
+     */
+    public String get() {
+      if (scheme() != null) {
+        return scheme() + ":" + id();
+      }
+      return id();
+    }
+
+    @Override
+    public String toString() {
+      return get();
+    }
+  }
+
+  public static ExternalId create(String scheme, String id, Account.Id accountId) {
+    return new AutoValue_ExternalId(Key.create(scheme, id), accountId, null, null);
+  }
+
+  public static ExternalId create(
+      String scheme,
+      String id,
+      Account.Id accountId,
+      @Nullable String email,
+      @Nullable String hashedPassword) {
+    return create(Key.create(scheme, id), accountId, email, hashedPassword);
+  }
+
+  public static ExternalId create(Key key, Account.Id accountId) {
+    return create(key, accountId, null, null);
+  }
+
+  public static ExternalId create(
+      Key key, Account.Id accountId, @Nullable String email, @Nullable String hashedPassword) {
+    return new AutoValue_ExternalId(key, accountId, email, hashedPassword);
+  }
+
+  public static ExternalId createWithPassword(
+      Key key, Account.Id accountId, @Nullable String email, String plainPassword) {
+    plainPassword = Strings.emptyToNull(plainPassword);
+    String hashedPassword =
+        plainPassword != null ? HashedPassword.fromPassword(plainPassword).encode() : null;
+    return create(key, accountId, email, hashedPassword);
+  }
+
+  public static ExternalId createUsername(String id, Account.Id accountId, String plainPassword) {
+    return createWithPassword(Key.create(SCHEME_USERNAME, id), accountId, null, plainPassword);
+  }
+
+  public static ExternalId createWithEmail(
+      String scheme, String id, Account.Id accountId, String email) {
+    return createWithEmail(Key.create(scheme, id), accountId, email);
+  }
+
+  public static ExternalId createWithEmail(Key key, Account.Id accountId, String email) {
+    return new AutoValue_ExternalId(key, accountId, email, null);
+  }
+
+  public static ExternalId createEmail(Account.Id accountId, String email) {
+    return createWithEmail(SCHEME_MAILTO, email, accountId, email);
+  }
+
+  /**
+   * Parses an external ID from a byte array that contain the external ID as an Git config file
+   * text.
+   *
+   * <p>The Git config must have exactly one externalId subsection with an accountId and optionally
+   * email and password:
+   *
+   * <pre>
+   * [externalId "username:jdoe"]
+   *   accountId = 1003407
+   *   email = jdoe@example.com
+   *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
+   * </pre>
+   */
+  public static ExternalId parse(String noteId, byte[] raw) throws ConfigInvalidException {
+    Config externalIdConfig = new Config();
+    try {
+      externalIdConfig.fromText(new String(raw, UTF_8));
+    } catch (ConfigInvalidException e) {
+      throw invalidConfig(noteId, e.getMessage());
+    }
+
+    Set<String> externalIdKeys = externalIdConfig.getSubsections(EXTERNAL_ID_SECTION);
+    if (externalIdKeys.size() != 1) {
+      throw invalidConfig(
+          noteId,
+          String.format(
+              "Expected exactly 1 %s section, found %d",
+              EXTERNAL_ID_SECTION, externalIdKeys.size()));
+    }
+
+    String externalIdKeyStr = Iterables.getOnlyElement(externalIdKeys);
+    Key externalIdKey = Key.parse(externalIdKeyStr);
+    if (externalIdKey == null) {
+      throw invalidConfig(noteId, String.format("Invalid external id: %s", externalIdKeyStr));
+    }
+
+    String accountIdStr =
+        externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY);
+    String email = externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, EMAIL_KEY);
+    String password =
+        externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, PASSWORD_KEY);
+    if (accountIdStr == null) {
+      throw invalidConfig(
+          noteId,
+          String.format(
+              "Missing value for %s.%s.%s", EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
+    }
+    Integer accountId = Ints.tryParse(accountIdStr);
+    if (accountId == null) {
+      throw invalidConfig(
+          noteId,
+          String.format(
+              "Value %s for %s.%s.%s is invalid, expected account ID",
+              accountIdStr, EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
+    }
+
+    return new AutoValue_ExternalId(externalIdKey, new Account.Id(accountId), email, password);
+  }
+
+  private static ConfigInvalidException invalidConfig(String noteId, String message) {
+    return new ConfigInvalidException(
+        String.format("Invalid external id config for note %s: %s", noteId, message));
+  }
+
+  public static ExternalId from(AccountExternalId externalId) {
+    if (externalId == null) {
+      return null;
+    }
+
+    return new AutoValue_ExternalId(
+        ExternalId.Key.parse(externalId.getExternalId()),
+        externalId.getAccountId(),
+        externalId.getEmailAddress(),
+        externalId.getPassword());
+  }
+
+  public static Set<ExternalId> from(Collection<AccountExternalId> externalIds) {
+    if (externalIds == null) {
+      return ImmutableSet.of();
+    }
+    return externalIds.stream().map(ExternalId::from).collect(toSet());
+  }
+
+  public static Set<AccountExternalId> toAccountExternalIds(Collection<ExternalId> extIds) {
+    return extIds.stream().map(e -> e.asAccountExternalId()).collect(toSet());
+  }
+
+  public abstract Key key();
+
+  public abstract Account.Id accountId();
+
+  public abstract @Nullable String email();
+
+  public abstract @Nullable String password();
+
+  public boolean isScheme(String scheme) {
+    return key().isScheme(scheme);
+  }
+
+  public AccountExternalId asAccountExternalId() {
+    AccountExternalId extId = new AccountExternalId(accountId(), key().asAccountExternalIdKey());
+    extId.setEmailAddress(email());
+    extId.setPassword(password());
+    return extId;
+  }
+
+  /**
+   * Exports this external ID as Git config file text.
+   *
+   * <p>The Git config has exactly one externalId subsection with an accountId and optionally email
+   * and password:
+   *
+   * <pre>
+   * [externalId "username:jdoe"]
+   *   accountId = 1003407
+   *   email = jdoe@example.com
+   *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
+   * </pre>
+   */
+  @Override
+  public String toString() {
+    Config c = new Config();
+    writeToConfig(c);
+    return c.toText();
+  }
+
+  public void writeToConfig(Config c) {
+    String externalIdKey = key().get();
+    c.setInt(EXTERNAL_ID_SECTION, externalIdKey, ACCOUNT_ID_KEY, accountId().get());
+    if (email() != null) {
+      c.setString(EXTERNAL_ID_SECTION, externalIdKey, EMAIL_KEY, email());
+    }
+    if (password() != null) {
+      c.setString(EXTERNAL_ID_SECTION, externalIdKey, PASSWORD_KEY, password());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIds.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIds.java
new file mode 100644
index 0000000..c937935
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIds.java
@@ -0,0 +1,105 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Class to read external IDs from NoteDb.
+ *
+ * <p>In NoteDb external IDs are stored in the All-Users repository in a Git Notes branch called
+ * refs/meta/external-ids where the sha1 of the external ID is used as note name. Each note content
+ * is a git config file that contains an external ID. It has exactly one externalId subsection with
+ * an accountId and optionally email and password:
+ *
+ * <pre>
+ * [externalId "username:jdoe"]
+ *   accountId = 1003407
+ *   email = jdoe@example.com
+ *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
+ * </pre>
+ */
+@Singleton
+public class ExternalIds {
+  public static final int MAX_NOTE_SZ = 1 << 19;
+
+  public static ObjectId readRevision(Repository repo) throws IOException {
+    Ref ref = repo.exactRef(RefNames.REFS_EXTERNAL_IDS);
+    return ref != null ? ref.getObjectId() : ObjectId.zeroId();
+  }
+
+  public static NoteMap readNoteMap(RevWalk rw, ObjectId rev) throws IOException {
+    if (!rev.equals(ObjectId.zeroId())) {
+      return NoteMap.read(rw.getObjectReader(), rw.parseCommit(rev));
+    }
+    return NoteMap.newEmptyMap();
+  }
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+
+  @Inject
+  public ExternalIds(GitRepositoryManager repoManager, AllUsersName allUsersName) {
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+  }
+
+  public ObjectId readRevision() throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return readRevision(repo);
+    }
+  }
+
+  /** Reads and returns the specified external ID. */
+  @Nullable
+  public ExternalId get(ExternalId.Key key) throws IOException, ConfigInvalidException {
+    try (Repository repo = repoManager.openRepository(allUsersName);
+        RevWalk rw = new RevWalk(repo)) {
+      ObjectId rev = readRevision(repo);
+      if (rev.equals(ObjectId.zeroId())) {
+        return null;
+      }
+
+      return parse(key, rw, rev);
+    }
+  }
+
+  private ExternalId parse(ExternalId.Key key, RevWalk rw, ObjectId rev)
+      throws IOException, ConfigInvalidException {
+    NoteMap noteMap = readNoteMap(rw, rev);
+    ObjectId noteId = key.sha1();
+    if (!noteMap.contains(noteId)) {
+      return null;
+    }
+
+    byte[] raw =
+        rw.getObjectReader().open(noteMap.get(noteId), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
+    return ExternalId.parse(noteId.name(), raw);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsBatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsBatchUpdate.java
new file mode 100644
index 0000000..531e562
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsBatchUpdate.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.gerrit.server.account.ExternalId.toAccountExternalIds;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * This class allows to do batch updates to external IDs.
+ *
+ * <p>For NoteDb all updates will result in a single commit to the refs/meta/external-ids branch.
+ * This means callers can prepare many updates by invoking {@link #replace(ExternalId, ExternalId)}
+ * multiple times and when {@link ExternalIdsBatchUpdate#commit(ReviewDb, String)} is invoked a
+ * single NoteDb commit is created that contains all the prepared updates.
+ */
+public class ExternalIdsBatchUpdate {
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverIdent;
+  private final Set<ExternalId> toAdd = new HashSet<>();
+  private final Set<ExternalId> toDelete = new HashSet<>();
+
+  @Inject
+  public ExternalIdsBatchUpdate(
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent serverIdent) {
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.serverIdent = serverIdent;
+  }
+
+  /**
+   * Adds an external ID replacement to the batch.
+   *
+   * <p>The actual replacement is only done when {@link #commit(ReviewDb, String)} is invoked.
+   */
+  public void replace(ExternalId extIdToDelete, ExternalId extIdToAdd) {
+    ExternalIdsUpdate.checkSameAccount(ImmutableSet.of(extIdToDelete, extIdToAdd));
+    toAdd.add(extIdToAdd);
+    toDelete.add(extIdToDelete);
+  }
+
+  /**
+   * Commits this batch.
+   *
+   * <p>This means external ID replacements which were prepared by invoking {@link
+   * #replace(ExternalId, ExternalId)} are now executed. Deletion of external IDs is done before
+   * adding the new external IDs. This means if an external ID is specified for deletion and an
+   * external ID with the same key is specified to be added, the old external ID with that key is
+   * deleted first and then the new external ID is added (so the external ID for that key is
+   * replaced).
+   *
+   * <p>For NoteDb a single commit is created that contains all the external ID updates.
+   */
+  public void commit(ReviewDb db, String commitMessage)
+      throws IOException, OrmException, ConfigInvalidException {
+    if (toDelete.isEmpty() && toAdd.isEmpty()) {
+      return;
+    }
+
+    db.accountExternalIds().delete(toAccountExternalIds(toDelete));
+    db.accountExternalIds().insert(toAccountExternalIds(toAdd));
+
+    try (Repository repo = repoManager.openRepository(allUsersName);
+        RevWalk rw = new RevWalk(repo);
+        ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId rev = ExternalIds.readRevision(repo);
+
+      NoteMap noteMap = ExternalIds.readNoteMap(rw, rev);
+
+      for (ExternalId extId : toDelete) {
+        ExternalIdsUpdate.remove(rw, noteMap, extId);
+      }
+
+      for (ExternalId extId : toAdd) {
+        ExternalIdsUpdate.insert(rw, ins, noteMap, extId);
+      }
+
+      ExternalIdsUpdate.commit(
+          repo, rw, ins, rev, noteMap, commitMessage, serverIdent, serverIdent);
+    }
+
+    toAdd.clear();
+    toDelete.clear();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsUpdate.java
new file mode 100644
index 0000000..c09dc11
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsUpdate.java
@@ -0,0 +1,636 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.account.ExternalId.Key.toAccountExternalIdKeys;
+import static com.google.gerrit.server.account.ExternalId.toAccountExternalIds;
+import static com.google.gerrit.server.account.ExternalIds.MAX_NOTE_SZ;
+import static com.google.gerrit.server.account.ExternalIds.readNoteMap;
+import static com.google.gerrit.server.account.ExternalIds.readRevision;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toSet;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+import static org.eclipse.jgit.lib.Constants.OBJ_TREE;
+
+import com.github.rholder.retry.RetryException;
+import com.github.rholder.retry.Retryer;
+import com.github.rholder.retry.RetryerBuilder;
+import com.github.rholder.retry.StopStrategies;
+import com.github.rholder.retry.WaitStrategies;
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Throwables;
+import com.google.common.collect.Iterables;
+import com.google.common.util.concurrent.Runnables;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.LockFailureException;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Updates externalIds in ReviewDb and NoteDb.
+ *
+ * <p>In NoteDb external IDs are stored in the All-Users repository in a Git Notes branch called
+ * refs/meta/external-ids where the sha1 of the external ID is used as note name. Each note content
+ * is a git config file that contains an external ID. It has exactly one externalId subsection with
+ * an accountId and optionally email and password:
+ *
+ * <pre>
+ * [externalId "username:jdoe"]
+ *   accountId = 1003407
+ *   email = jdoe@example.com
+ *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
+ * </pre>
+ *
+ * For NoteDb each method call results in one commit on refs/meta/external-ids branch.
+ */
+public class ExternalIdsUpdate {
+  private static final String COMMIT_MSG = "Update external IDs";
+
+  /**
+   * Factory to create an ExternalIdsUpdate instance for updating external IDs by the Gerrit server.
+   *
+   * <p>The Gerrit server identity will be used as author and committer for all commits that update
+   * the external IDs.
+   */
+  @Singleton
+  public static class Server {
+    private final GitRepositoryManager repoManager;
+    private final AllUsersName allUsersName;
+    private final Provider<PersonIdent> serverIdent;
+
+    @Inject
+    public Server(
+        GitRepositoryManager repoManager,
+        AllUsersName allUsersName,
+        @GerritPersonIdent Provider<PersonIdent> serverIdent) {
+      this.repoManager = repoManager;
+      this.allUsersName = allUsersName;
+      this.serverIdent = serverIdent;
+    }
+
+    public ExternalIdsUpdate create() {
+      PersonIdent i = serverIdent.get();
+      return new ExternalIdsUpdate(repoManager, allUsersName, i, i);
+    }
+  }
+
+  /**
+   * Factory to create an ExternalIdsUpdate instance for updating external IDs by the current user.
+   *
+   * <p>The identity of the current user will be used as author for all commits that update the
+   * external IDs. The Gerrit server identity will be used as committer.
+   */
+  @Singleton
+  public static class User {
+    private final GitRepositoryManager repoManager;
+    private final AllUsersName allUsersName;
+    private final Provider<PersonIdent> serverIdent;
+    private final Provider<IdentifiedUser> identifiedUser;
+
+    @Inject
+    public User(
+        GitRepositoryManager repoManager,
+        AllUsersName allUsersName,
+        @GerritPersonIdent Provider<PersonIdent> serverIdent,
+        Provider<IdentifiedUser> identifiedUser) {
+      this.repoManager = repoManager;
+      this.allUsersName = allUsersName;
+      this.serverIdent = serverIdent;
+      this.identifiedUser = identifiedUser;
+    }
+
+    public ExternalIdsUpdate create() {
+      PersonIdent i = serverIdent.get();
+      return new ExternalIdsUpdate(
+          repoManager, allUsersName, createPersonIdent(i, identifiedUser.get()), i);
+    }
+
+    private PersonIdent createPersonIdent(PersonIdent ident, IdentifiedUser user) {
+      return user.newCommitterIdent(ident.getWhen(), ident.getTimeZone());
+    }
+  }
+
+  @VisibleForTesting
+  public static RetryerBuilder<Void> retryerBuilder() {
+    return RetryerBuilder.<Void>newBuilder()
+        .retryIfException(e -> e instanceof LockFailureException)
+        .withWaitStrategy(
+            WaitStrategies.join(
+                WaitStrategies.exponentialWait(2, TimeUnit.SECONDS),
+                WaitStrategies.randomWait(50, TimeUnit.MILLISECONDS)))
+        .withStopStrategy(StopStrategies.stopAfterDelay(10, TimeUnit.SECONDS));
+  }
+
+  private static final Retryer<Void> RETRYER = retryerBuilder().build();
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final PersonIdent committerIdent;
+  private final PersonIdent authorIdent;
+  private final Runnable afterReadRevision;
+  private final Retryer<Void> retryer;
+
+  private ExternalIdsUpdate(
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      PersonIdent committerIdent,
+      PersonIdent authorIdent) {
+    this(repoManager, allUsersName, committerIdent, authorIdent, Runnables.doNothing(), RETRYER);
+  }
+
+  @VisibleForTesting
+  public ExternalIdsUpdate(
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      PersonIdent committerIdent,
+      PersonIdent authorIdent,
+      Runnable afterReadRevision,
+      Retryer<Void> retryer) {
+    this.repoManager = checkNotNull(repoManager, "repoManager");
+    this.allUsersName = checkNotNull(allUsersName, "allUsersName");
+    this.committerIdent = checkNotNull(committerIdent, "committerIdent");
+    this.authorIdent = checkNotNull(authorIdent, "authorIdent");
+    this.afterReadRevision = checkNotNull(afterReadRevision, "afterReadRevision");
+    this.retryer = checkNotNull(retryer, "retryer");
+  }
+
+  /**
+   * Inserts a new external ID.
+   *
+   * <p>If the external ID already exists, the insert fails with {@link OrmDuplicateKeyException}.
+   */
+  public void insert(ReviewDb db, ExternalId extId)
+      throws IOException, ConfigInvalidException, OrmException {
+    insert(db, Collections.singleton(extId));
+  }
+
+  /**
+   * Inserts new external IDs.
+   *
+   * <p>If any of the external ID already exists, the insert fails with {@link
+   * OrmDuplicateKeyException}.
+   */
+  public void insert(ReviewDb db, Collection<ExternalId> extIds)
+      throws IOException, ConfigInvalidException, OrmException {
+    db.accountExternalIds().insert(toAccountExternalIds(extIds));
+
+    updateNoteMap(
+        o -> {
+          for (ExternalId extId : extIds) {
+            insert(o.rw(), o.ins(), o.noteMap(), extId);
+          }
+        });
+  }
+
+  /**
+   * Inserts or updates an external ID.
+   *
+   * <p>If the external ID already exists, it is overwritten, otherwise it is inserted.
+   */
+  public void upsert(ReviewDb db, ExternalId extId)
+      throws IOException, ConfigInvalidException, OrmException {
+    upsert(db, Collections.singleton(extId));
+  }
+
+  /**
+   * Inserts or updates external IDs.
+   *
+   * <p>If any of the external IDs already exists, it is overwritten. New external IDs are inserted.
+   */
+  public void upsert(ReviewDb db, Collection<ExternalId> extIds)
+      throws IOException, ConfigInvalidException, OrmException {
+    db.accountExternalIds().upsert(toAccountExternalIds(extIds));
+
+    updateNoteMap(
+        o -> {
+          for (ExternalId extId : extIds) {
+            upsert(o.rw(), o.ins(), o.noteMap(), extId);
+          }
+        });
+  }
+
+  /**
+   * Deletes an external ID.
+   *
+   * <p>The deletion fails with {@link IllegalStateException} if there is an existing external ID
+   * that has the same key, but otherwise doesn't match the specified external ID.
+   */
+  public void delete(ReviewDb db, ExternalId extId)
+      throws IOException, ConfigInvalidException, OrmException {
+    delete(db, Collections.singleton(extId));
+  }
+
+  /**
+   * Deletes external IDs.
+   *
+   * <p>The deletion fails with {@link IllegalStateException} if there is an existing external ID
+   * that has the same key as any of the external IDs that should be deleted, but otherwise doesn't
+   * match the that external ID.
+   */
+  public void delete(ReviewDb db, Collection<ExternalId> extIds)
+      throws IOException, ConfigInvalidException, OrmException {
+    db.accountExternalIds().delete(toAccountExternalIds(extIds));
+
+    updateNoteMap(
+        o -> {
+          for (ExternalId extId : extIds) {
+            remove(o.rw(), o.noteMap(), extId);
+          }
+        });
+  }
+
+  /**
+   * Delete an external ID by key.
+   *
+   * <p>The external ID is only deleted if it belongs to the specified account. If it belongs to
+   * another account the deletion fails with {@link IllegalStateException}.
+   */
+  public void delete(ReviewDb db, Account.Id accountId, ExternalId.Key extIdKey)
+      throws IOException, ConfigInvalidException, OrmException {
+    delete(db, accountId, Collections.singleton(extIdKey));
+  }
+
+  /**
+   * Delete external IDs by external ID key.
+   *
+   * <p>The external IDs are only deleted if they belongs to the specified account. If any of the
+   * external IDs belongs to another account the deletion fails with {@link IllegalStateException}.
+   */
+  public void delete(ReviewDb db, Account.Id accountId, Collection<ExternalId.Key> extIdKeys)
+      throws IOException, ConfigInvalidException, OrmException {
+    db.accountExternalIds().deleteKeys(toAccountExternalIdKeys(extIdKeys));
+
+    updateNoteMap(
+        o -> {
+          for (ExternalId.Key extIdKey : extIdKeys) {
+            remove(o.rw(), o.noteMap(), accountId, extIdKey);
+          }
+        });
+  }
+
+  /** Deletes all external IDs of the specified account. */
+  public void deleteAll(ReviewDb db, Account.Id accountId)
+      throws IOException, ConfigInvalidException, OrmException {
+    delete(db, ExternalId.from(db.accountExternalIds().byAccount(accountId).toList()));
+  }
+
+  /**
+   * Replaces external IDs for an account by external ID keys.
+   *
+   * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
+   * external ID key is specified for deletion and an external ID with the same key is specified to
+   * be added, the old external ID with that key is deleted first and then the new external ID is
+   * added (so the external ID for that key is replaced).
+   *
+   * <p>If any of the specified external IDs belongs to another account the replacement fails with
+   * {@link IllegalStateException}.
+   */
+  public void replace(
+      ReviewDb db,
+      Account.Id accountId,
+      Collection<ExternalId.Key> toDelete,
+      Collection<ExternalId> toAdd)
+      throws IOException, ConfigInvalidException, OrmException {
+    checkSameAccount(toAdd, accountId);
+
+    db.accountExternalIds().deleteKeys(toAccountExternalIdKeys(toDelete));
+    db.accountExternalIds().insert(toAccountExternalIds(toAdd));
+
+    updateNoteMap(
+        o -> {
+          for (ExternalId.Key extIdKey : toDelete) {
+            remove(o.rw(), o.noteMap(), accountId, extIdKey);
+          }
+
+          for (ExternalId extId : toAdd) {
+            insert(o.rw(), o.ins(), o.noteMap(), extId);
+          }
+        });
+  }
+
+  /**
+   * Replaces an external ID.
+   *
+   * <p>If the specified external IDs belongs to different accounts the replacement fails with
+   * {@link IllegalStateException}.
+   */
+  public void replace(ReviewDb db, ExternalId toDelete, ExternalId toAdd)
+      throws IOException, ConfigInvalidException, OrmException {
+    replace(db, Collections.singleton(toDelete), Collections.singleton(toAdd));
+  }
+
+  /**
+   * Replaces external IDs.
+   *
+   * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
+   * external ID is specified for deletion and an external ID with the same key is specified to be
+   * added, the old external ID with that key is deleted first and then the new external ID is added
+   * (so the external ID for that key is replaced).
+   *
+   * <p>If the specified external IDs belong to different accounts the replacement fails with {@link
+   * IllegalStateException}.
+   */
+  public void replace(ReviewDb db, Collection<ExternalId> toDelete, Collection<ExternalId> toAdd)
+      throws IOException, ConfigInvalidException, OrmException {
+    Account.Id accountId = checkSameAccount(Iterables.concat(toDelete, toAdd));
+    if (accountId == null) {
+      // toDelete and toAdd are empty -> nothing to do
+      return;
+    }
+
+    replace(db, accountId, toDelete.stream().map(e -> e.key()).collect(toSet()), toAdd);
+  }
+
+  /**
+   * Checks that all specified external IDs belong to the same account.
+   *
+   * @return the ID of the account to which all specified external IDs belong.
+   */
+  public static Account.Id checkSameAccount(Iterable<ExternalId> extIds) {
+    return checkSameAccount(extIds, null);
+  }
+
+  /**
+   * Checks that all specified external IDs belong to specified account. If no account is specified
+   * it is checked that all specified external IDs belong to the same account.
+   *
+   * @return the ID of the account to which all specified external IDs belong.
+   */
+  public static Account.Id checkSameAccount(
+      Iterable<ExternalId> extIds, @Nullable Account.Id accountId) {
+    for (ExternalId extId : extIds) {
+      if (accountId == null) {
+        accountId = extId.accountId();
+        continue;
+      }
+      checkState(
+          accountId.equals(extId.accountId()),
+          "external id %s belongs to account %s, expected account %s",
+          extId.key().get(),
+          extId.accountId().get(),
+          accountId.get());
+    }
+    return accountId;
+  }
+
+  /**
+   * Inserts a new external ID and sets it in the note map.
+   *
+   * <p>If the external ID already exists, the insert fails with {@link OrmDuplicateKeyException}.
+   */
+  public static void insert(RevWalk rw, ObjectInserter ins, NoteMap noteMap, ExternalId extId)
+      throws OrmDuplicateKeyException, ConfigInvalidException, IOException {
+    if (noteMap.contains(extId.key().sha1())) {
+      throw new OrmDuplicateKeyException(
+          String.format("external id %s already exists", extId.key().get()));
+    }
+    upsert(rw, ins, noteMap, extId);
+  }
+
+  /**
+   * Insert or updates an new external ID and sets it in the note map.
+   *
+   * <p>If the external ID already exists it is overwritten.
+   */
+  private static void upsert(RevWalk rw, ObjectInserter ins, NoteMap noteMap, ExternalId extId)
+      throws IOException, ConfigInvalidException {
+    ObjectId noteId = extId.key().sha1();
+    Config c = new Config();
+    if (noteMap.contains(extId.key().sha1())) {
+      byte[] raw =
+          rw.getObjectReader().open(noteMap.get(noteId), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
+      try {
+        c.fromText(new String(raw, UTF_8));
+      } catch (ConfigInvalidException e) {
+        throw new ConfigInvalidException(
+            String.format("Invalid external id config for note %s: %s", noteId, e.getMessage()));
+      }
+    }
+    extId.writeToConfig(c);
+    byte[] raw = c.toText().getBytes(UTF_8);
+    ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+    noteMap.set(noteId, dataBlob);
+  }
+
+  /**
+   * Removes an external ID from the note map.
+   *
+   * <p>The removal fails with {@link IllegalStateException} if there is an existing external ID
+   * that has the same key, but otherwise doesn't match the specified external ID.
+   */
+  public static void remove(RevWalk rw, NoteMap noteMap, ExternalId extId)
+      throws IOException, ConfigInvalidException {
+    ObjectId noteId = extId.key().sha1();
+    if (!noteMap.contains(noteId)) {
+      return;
+    }
+
+    byte[] raw =
+        rw.getObjectReader().open(noteMap.get(noteId), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
+    ExternalId actualExtId = ExternalId.parse(noteId.name(), raw);
+    checkState(
+        extId.equals(actualExtId),
+        "external id %s should be removed, but it's not matching the actual external id %s",
+        extId.toString(),
+        actualExtId.toString());
+    noteMap.remove(noteId);
+  }
+
+  /**
+   * Removes an external ID from the note map by external ID key.
+   *
+   * <p>The external ID is only deleted if it belongs to the specified account. If the external IDs
+   * belongs to another account the deletion fails with {@link IllegalStateException}.
+   */
+  private static void remove(
+      RevWalk rw, NoteMap noteMap, Account.Id accountId, ExternalId.Key extIdKey)
+      throws IOException, ConfigInvalidException {
+    ObjectId noteId = extIdKey.sha1();
+    if (!noteMap.contains(noteId)) {
+      return;
+    }
+
+    byte[] raw =
+        rw.getObjectReader().open(noteMap.get(noteId), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
+    ExternalId extId = ExternalId.parse(noteId.name(), raw);
+    checkState(
+        accountId.equals(extId.accountId()),
+        "external id %s should be removed for account %s,"
+            + " but external id belongs to account %s",
+        extIdKey.get(),
+        accountId.get(),
+        extId.accountId().get());
+    noteMap.remove(noteId);
+  }
+
+  private void updateNoteMap(MyConsumer<OpenRepo> update)
+      throws IOException, ConfigInvalidException, OrmException {
+    try (Repository repo = repoManager.openRepository(allUsersName);
+        RevWalk rw = new RevWalk(repo);
+        ObjectInserter ins = repo.newObjectInserter()) {
+      retryer.call(new TryNoteMapUpdate(repo, rw, ins, update));
+    } catch (ExecutionException | RetryException e) {
+      if (e.getCause() != null) {
+        Throwables.throwIfInstanceOf(e.getCause(), IOException.class);
+        Throwables.throwIfInstanceOf(e.getCause(), ConfigInvalidException.class);
+        Throwables.throwIfInstanceOf(e.getCause(), OrmException.class);
+      }
+      throw new OrmException(e);
+    }
+  }
+
+  private void commit(
+      Repository repo, RevWalk rw, ObjectInserter ins, ObjectId rev, NoteMap noteMap)
+      throws IOException {
+    commit(repo, rw, ins, rev, noteMap, COMMIT_MSG, committerIdent, authorIdent);
+  }
+
+  /** Commits updates to the external IDs. */
+  public static void commit(
+      Repository repo,
+      RevWalk rw,
+      ObjectInserter ins,
+      ObjectId rev,
+      NoteMap noteMap,
+      String commitMessage,
+      PersonIdent committerIdent,
+      PersonIdent authorIdent)
+      throws IOException {
+    CommitBuilder cb = new CommitBuilder();
+    cb.setMessage(commitMessage);
+    cb.setTreeId(noteMap.writeTree(ins));
+    cb.setAuthor(authorIdent);
+    cb.setCommitter(committerIdent);
+    if (!rev.equals(ObjectId.zeroId())) {
+      cb.setParentId(rev);
+    } else {
+      cb.setParentIds(); // Ref is currently nonexistent, commit has no parents.
+    }
+    if (cb.getTreeId() == null) {
+      if (rev.equals(ObjectId.zeroId())) {
+        cb.setTreeId(emptyTree(ins)); // No parent, assume empty tree.
+      } else {
+        RevCommit p = rw.parseCommit(rev);
+        cb.setTreeId(p.getTree()); // Copy tree from parent.
+      }
+    }
+    ObjectId commitId = ins.insert(cb);
+    ins.flush();
+
+    RefUpdate u = repo.updateRef(RefNames.REFS_EXTERNAL_IDS);
+    u.setRefLogIdent(committerIdent);
+    u.setRefLogMessage("Update external IDs", false);
+    u.setExpectedOldObjectId(rev);
+    u.setNewObjectId(commitId);
+    RefUpdate.Result res = u.update();
+    switch (res) {
+      case NEW:
+      case FAST_FORWARD:
+      case NO_CHANGE:
+      case RENAMED:
+      case FORCED:
+        break;
+      case LOCK_FAILURE:
+        throw new LockFailureException("Updating external IDs failed with " + res);
+      case IO_FAILURE:
+      case NOT_ATTEMPTED:
+      case REJECTED:
+      case REJECTED_CURRENT_BRANCH:
+      default:
+        throw new IOException("Updating external IDs failed with " + res);
+    }
+  }
+
+  private static ObjectId emptyTree(ObjectInserter ins) throws IOException {
+    return ins.insert(OBJ_TREE, new byte[] {});
+  }
+
+  private static interface MyConsumer<T> {
+    void accept(T t) throws IOException, ConfigInvalidException, OrmException;
+  }
+
+  @AutoValue
+  abstract static class OpenRepo {
+    static OpenRepo create(Repository repo, RevWalk rw, ObjectInserter ins, NoteMap noteMap) {
+      return new AutoValue_ExternalIdsUpdate_OpenRepo(repo, rw, ins, noteMap);
+    }
+
+    abstract Repository repo();
+
+    abstract RevWalk rw();
+
+    abstract ObjectInserter ins();
+
+    abstract NoteMap noteMap();
+  }
+
+  private class TryNoteMapUpdate implements Callable<Void> {
+    private final Repository repo;
+    private final RevWalk rw;
+    private final ObjectInserter ins;
+    private final MyConsumer<OpenRepo> update;
+
+    private TryNoteMapUpdate(
+        Repository repo, RevWalk rw, ObjectInserter ins, MyConsumer<OpenRepo> update) {
+      this.repo = repo;
+      this.rw = rw;
+      this.ins = ins;
+      this.update = update;
+    }
+
+    @Override
+    public Void call() throws Exception {
+      ObjectId rev = readRevision(repo);
+
+      afterReadRevision.run();
+
+      NoteMap noteMap = readNoteMap(rw, rev);
+      update.accept(OpenRepo.create(repo, rw, ins, noteMap));
+
+      commit(repo, rw, ins, rev, noteMap);
+      return null;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetExternalIds.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetExternalIds.java
index e215c9b..6ea911f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetExternalIds.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetExternalIds.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
@@ -22,7 +22,6 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.AuthConfig;
@@ -54,22 +53,23 @@
       throw new AuthException("not allowed to get external IDs");
     }
 
-    Collection<AccountExternalId> ids =
-        db.get().accountExternalIds().byAccount(resource.getUser().getAccountId()).toList();
+    Collection<ExternalId> ids =
+        ExternalId.from(
+            db.get().accountExternalIds().byAccount(resource.getUser().getAccountId()).toList());
     if (ids.isEmpty()) {
       return ImmutableList.of();
     }
     List<AccountExternalIdInfo> result = Lists.newArrayListWithCapacity(ids.size());
-    for (AccountExternalId id : ids) {
+    for (ExternalId id : ids) {
       AccountExternalIdInfo info = new AccountExternalIdInfo();
-      info.identity = id.getExternalId();
-      info.emailAddress = id.getEmailAddress();
+      info.identity = id.key().get();
+      info.emailAddress = id.email();
       info.trusted = toBoolean(authConfig.isIdentityTrustable(Collections.singleton(id)));
       // The identity can be deleted only if its not the one used to
       // establish this web session, and if only if an identity was
       // actually used to establish this web session.
       if (!id.isScheme(SCHEME_USERNAME)) {
-        AccountExternalId.Key last = resource.getUser().getLastLoginExternalIdKey();
+        ExternalId.Key last = resource.getUser().getLastLoginExternalIdKey();
         info.canDelete = toBoolean(last == null || !last.get().equals(info.identity));
       }
       result.add(info);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetHttpPassword.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetHttpPassword.java
deleted file mode 100644
index 135cdf6..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetHttpPassword.java
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetHttpPassword implements RestReadView<AccountResource> {
-
-  private final Provider<CurrentUser> self;
-
-  @Inject
-  GetHttpPassword(Provider<CurrentUser> self) {
-    this.self = self;
-  }
-
-  @Override
-  public String apply(AccountResource rsrc) throws AuthException, ResourceNotFoundException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("not allowed to get http password");
-    }
-    AccountState s = rsrc.getUser().state();
-    if (s.getUserName() == null) {
-      throw new ResourceNotFoundException();
-    }
-    String p = s.getPassword(s.getUserName());
-    if (p == null) {
-      throw new ResourceNotFoundException();
-    }
-    return p;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/HashedPassword.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/HashedPassword.java
new file mode 100644
index 0000000..0323f4e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/HashedPassword.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.common.base.Preconditions;
+import com.google.common.io.BaseEncoding;
+import com.google.common.primitives.Ints;
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import org.apache.commons.codec.DecoderException;
+import org.bouncycastle.crypto.generators.BCrypt;
+import org.bouncycastle.util.Arrays;
+
+/**
+ * Holds logic for salted, hashed passwords. It uses BCrypt from BouncyCastle, which truncates
+ * passwords at 72 bytes.
+ */
+public class HashedPassword {
+  private static final String ALGORITHM_PREFIX = "bcrypt:";
+  private static final SecureRandom secureRandom = new SecureRandom();
+  private static final BaseEncoding codec = BaseEncoding.base64();
+
+  // bcrypt uses 2^cost rounds. Since we use a generated random password, no need
+  // for a high cost.
+  private static final int DEFAULT_COST = 4;
+
+  /**
+   * decodes a hashed password encoded with {@link #encode}.
+   *
+   * @throws DecoderException if input is malformed.
+   */
+  public static HashedPassword decode(String encoded) throws DecoderException {
+    if (!encoded.startsWith(ALGORITHM_PREFIX)) {
+      throw new DecoderException("unrecognized algorithm");
+    }
+
+    String[] fields = encoded.split(":");
+    if (fields.length != 4) {
+      throw new DecoderException("want 4 fields");
+    }
+
+    Integer cost = Ints.tryParse(fields[1]);
+    if (cost == null) {
+      throw new DecoderException("cost parse failed");
+    }
+
+    if (!(cost >= 4 && cost < 32)) {
+      throw new DecoderException("cost should be 4..31 inclusive, got " + cost);
+    }
+
+    byte[] salt = codec.decode(fields[2]);
+    if (salt.length != 16) {
+      throw new DecoderException("salt should be 16 bytes, got " + salt.length);
+    }
+    return new HashedPassword(codec.decode(fields[3]), salt, cost);
+  }
+
+  private static byte[] hashPassword(String password, byte[] salt, int cost) {
+    byte[] pwBytes = password.getBytes(StandardCharsets.UTF_8);
+
+    return BCrypt.generate(pwBytes, salt, cost);
+  }
+
+  public static HashedPassword fromPassword(String password) {
+    byte[] salt = newSalt();
+
+    return new HashedPassword(hashPassword(password, salt, DEFAULT_COST), salt, DEFAULT_COST);
+  }
+
+  private static byte[] newSalt() {
+    byte[] bytes = new byte[16];
+    secureRandom.nextBytes(bytes);
+    return bytes;
+  }
+
+  private byte[] salt;
+  private byte[] hashed;
+  private int cost;
+
+  private HashedPassword(byte[] hashed, byte[] salt, int cost) {
+    this.salt = salt;
+    this.hashed = hashed;
+    this.cost = cost;
+
+    Preconditions.checkState(cost >= 4 && cost < 32);
+
+    // salt must be 128 bit.
+    Preconditions.checkState(salt.length == 16);
+  }
+
+  /**
+   * Serialize the hashed password and its parameters for persistent storage.
+   *
+   * @return one-line string encoding the hash and salt.
+   */
+  public String encode() {
+    return ALGORITHM_PREFIX + cost + ":" + codec.encode(salt) + ":" + codec.encode(hashed);
+  }
+
+  public boolean checkPassword(String password) {
+    // Constant-time comparison, because we're paranoid.
+    return Arrays.areEqual(hashPassword(password, salt, cost), hashed);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java
index 88eb8fa..7791a2e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.extensions.common.AvatarInfo;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.avatar.AvatarProvider;
 import com.google.inject.AbstractModule;
@@ -74,7 +73,7 @@
   private void fill(
       AccountInfo info,
       Account account,
-      @Nullable Collection<AccountExternalId> externalIds,
+      @Nullable Collection<ExternalId> externalIds,
       Set<FillOptions> options) {
     if (options.contains(FillOptions.ID)) {
       info._accountId = account.getId().get();
@@ -124,8 +123,7 @@
     }
   }
 
-  public List<String> getSecondaryEmails(
-      Account account, Collection<AccountExternalId> externalIds) {
+  public List<String> getSecondaryEmails(Account account, Collection<ExternalId> externalIds) {
     List<String> emails = new ArrayList<>(AccountState.getEmails(externalIds));
     if (account.getPreferredEmail() != null) {
       emails.remove(account.getPreferredEmail());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java
index 0080e34..775ce6d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java
@@ -56,7 +56,6 @@
     put(EMAIL_KIND).to(PutEmail.class);
     delete(EMAIL_KIND).to(DeleteEmail.class);
     put(EMAIL_KIND, "preferred").to(PutPreferred.class);
-    get(ACCOUNT_KIND, "password.http").to(GetHttpPassword.class);
     put(ACCOUNT_KIND, "password.http").to(PutHttpPassword.class);
     delete(ACCOUNT_KIND, "password.http").to(PutHttpPassword.class);
     child(ACCOUNT_KIND, "sshkeys").to(SshKeys.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
index 311c12b..435671f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -22,7 +22,6 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -30,14 +29,12 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
 import java.io.IOException;
 import java.security.NoSuchAlgorithmException;
 import java.security.SecureRandom;
-import java.util.Collections;
 import org.apache.commons.codec.binary.Base64;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
-@Singleton
 public class PutHttpPassword implements RestModifyView<AccountResource, Input> {
   public static class Input {
     public String httpPassword;
@@ -58,19 +55,24 @@
   private final Provider<CurrentUser> self;
   private final Provider<ReviewDb> dbProvider;
   private final AccountCache accountCache;
+  private final ExternalIdsUpdate.User externalIdsUpdate;
 
   @Inject
   PutHttpPassword(
-      Provider<CurrentUser> self, Provider<ReviewDb> dbProvider, AccountCache accountCache) {
+      Provider<CurrentUser> self,
+      Provider<ReviewDb> dbProvider,
+      AccountCache accountCache,
+      ExternalIdsUpdate.User externalIdsUpdate) {
     this.self = self;
     this.dbProvider = dbProvider;
     this.accountCache = accountCache;
+    this.externalIdsUpdate = externalIdsUpdate;
   }
 
   @Override
   public Response<String> apply(AccountResource rsrc, Input input)
       throws AuthException, ResourceNotFoundException, ResourceConflictException, OrmException,
-          IOException {
+          IOException, ConfigInvalidException {
     if (input == null) {
       input = new Input();
     }
@@ -100,21 +102,26 @@
   }
 
   public Response<String> apply(IdentifiedUser user, String newPassword)
-      throws ResourceNotFoundException, ResourceConflictException, OrmException, IOException {
+      throws ResourceNotFoundException, ResourceConflictException, OrmException, IOException,
+          ConfigInvalidException {
     if (user.getUserName() == null) {
       throw new ResourceConflictException("username must be set");
     }
 
-    AccountExternalId id =
-        dbProvider
-            .get()
-            .accountExternalIds()
-            .get(new AccountExternalId.Key(SCHEME_USERNAME, user.getUserName()));
-    if (id == null) {
+    ExternalId extId =
+        ExternalId.from(
+            dbProvider
+                .get()
+                .accountExternalIds()
+                .get(
+                    ExternalId.Key.create(SCHEME_USERNAME, user.getUserName())
+                        .asAccountExternalIdKey()));
+    if (extId == null) {
       throw new ResourceNotFoundException();
     }
-    id.setPassword(newPassword);
-    dbProvider.get().accountExternalIds().update(Collections.singleton(id));
+    ExternalId newExtId =
+        ExternalId.createWithPassword(extId.key(), extId.accountId(), extId.email(), newPassword);
+    externalIdsUpdate.create().upsert(dbProvider.get(), newExtId);
     accountCache.evict(user.getAccountId());
 
     return Strings.isNullOrEmpty(newPassword) ? Response.<String>none() : Response.ok(newPassword);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
index 9be57d9..e3a3c12 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
@@ -30,6 +30,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class PutUsername implements RestModifyView<AccountResource, Input> {
@@ -57,7 +58,7 @@
   @Override
   public String apply(AccountResource rsrc, Input input)
       throws AuthException, MethodNotAllowedException, UnprocessableEntityException,
-          ResourceConflictException, OrmException, IOException {
+          ResourceConflictException, OrmException, IOException, ConfigInvalidException {
     if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
       throw new AuthException("not allowed to set username");
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index 7b8637c..430b6b7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -372,7 +372,7 @@
     AccountResource.Email rsrc = new AccountResource.Email(account.getUser(), input.email);
     try {
       createEmailFactory.create(input.email).apply(rsrc, input);
-    } catch (EmailException | OrmException | IOException e) {
+    } catch (EmailException | OrmException | IOException | ConfigInvalidException e) {
       throw new RestApiException("Cannot add email", e);
     }
   }
@@ -382,7 +382,7 @@
     AccountResource.Email rsrc = new AccountResource.Email(account.getUser(), email);
     try {
       deleteEmail.apply(rsrc, null);
-    } catch (OrmException | IOException e) {
+    } catch (OrmException | IOException | ConfigInvalidException e) {
       throw new RestApiException("Cannot delete email", e);
     }
   }
@@ -494,7 +494,7 @@
   public void deleteExternalIds(List<String> externalIds) throws RestApiException {
     try {
       deleteExternalIds.apply(account, externalIds);
-    } catch (IOException | OrmException e) {
+    } catch (IOException | OrmException | ConfigInvalidException e) {
       throw new RestApiException("Cannot delete external IDs", e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java
index 8be7c4d..2d90853 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.api.accounts;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gerrit.server.account.ExternalId;
 import java.util.List;
 
 public interface AccountExternalIdCreator {
@@ -28,5 +28,5 @@
    * @param email an optional email address to assign to the external identifiers, or {@code null}.
    * @return a list of external identifiers, or an empty list.
    */
-  List<AccountExternalId> create(Account.Id id, String username, String email);
+  List<ExternalId> create(Account.Id id, String username, String email);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthRequest.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthRequest.java
index c933190..71c5d26 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthRequest.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthRequest.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.auth;
 
 import com.google.gerrit.common.Nullable;
-import java.util.Objects;
 
 /** Defines an abstract request for user authentication to Gerrit. */
 public abstract class AuthRequest {
@@ -46,10 +45,4 @@
   public final String getPassword() {
     return password;
   }
-
-  public void checkPassword(String pwd) throws AuthException {
-    if (!Objects.equals(getPassword(), pwd)) {
-      throw new InvalidCredentialsException();
-    }
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/InternalAuthBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/InternalAuthBackend.java
index 3f2938f..508bf31 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/InternalAuthBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/InternalAuthBackend.java
@@ -38,6 +38,7 @@
     return "gerrit";
   }
 
+  // TODO(gerritcodereview-team): This function has no coverage.
   @Override
   public AuthUser authenticate(AuthRequest req)
       throws MissingCredentialsException, InvalidCredentialsException, UnknownUserException,
@@ -63,7 +64,9 @@
               + ": account inactive or not provisioned in Gerrit");
     }
 
-    req.checkPassword(who.getPassword(username));
+    if (!who.checkPassword(req.getPassword(), username)) {
+      throw new InvalidCredentialsException();
+    }
     return new AuthUser(AuthUser.UUID.create(username), username);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
index 8511318..7feb745 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.auth.ldap;
 
+import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
 import static com.google.gerrit.server.account.GroupBackends.GROUP_REF_NAME_COMPARATOR;
 import static com.google.gerrit.server.auth.ldap.Helper.LDAP_UUID;
 import static com.google.gerrit.server.auth.ldap.LdapModule.GROUP_CACHE;
@@ -25,10 +26,10 @@
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.ParameterizedString;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.auth.ldap.Helper.LdapSchema;
@@ -180,10 +181,10 @@
     return new LdapGroupMembership(membershipCache, projectCache, id);
   }
 
-  private static String findId(final Collection<AccountExternalId> ids) {
-    for (final AccountExternalId i : ids) {
-      if (i.isScheme(AccountExternalId.SCHEME_GERRIT)) {
-        return i.getSchemeRest();
+  private static String findId(Collection<ExternalId> extIds) {
+    for (ExternalId extId : extIds) {
+      if (extId.isScheme(SCHEME_GERRIT)) {
+        return extId.key().id();
       }
     }
     return null;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
index 0e05330..66b279f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.auth.ldap;
 
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
 
 import com.google.common.base.Strings;
 import com.google.common.cache.CacheLoader;
@@ -24,13 +24,13 @@
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AbstractRealm;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.EmailExpander;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.auth.AuthenticationUnavailableException;
 import com.google.gerrit.server.config.AuthConfig;
@@ -329,8 +329,12 @@
     public Optional<Account.Id> load(String username) throws Exception {
       try (ReviewDb db = schema.open()) {
         return Optional.ofNullable(
-                db.accountExternalIds().get(new AccountExternalId.Key(SCHEME_GERRIT, username)))
-            .map(AccountExternalId::getAccountId);
+                ExternalId.from(
+                    db.accountExternalIds()
+                        .get(
+                            ExternalId.Key.create(SCHEME_GERRIT, username)
+                                .asAccountExternalIdKey())))
+            .map(ExternalId::accountId);
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java
index 4350037..d30e667 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.auth.openid;
 
-import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gerrit.server.account.ExternalId;
 
 public class OpenIdProviderPattern {
   public static OpenIdProviderPattern create(String pattern) {
@@ -33,8 +33,8 @@
     return regex ? id.matches(pattern) : id.startsWith(pattern);
   }
 
-  public boolean matches(AccountExternalId id) {
-    return matches(id.getExternalId());
+  public boolean matches(ExternalId extId) {
+    return matches(extId.key().get());
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
index db7d567..cc9133e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
@@ -14,9 +14,13 @@
 
 package com.google.gerrit.server.config;
 
+import static com.google.gerrit.server.account.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_UUID;
+
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.auth.openid.OpenIdProviderPattern;
 import com.google.gwtjsonrpc.server.SignedToken;
 import com.google.gwtjsonrpc.server.XsrfException;
@@ -44,7 +48,6 @@
   private final boolean trustContainerAuth;
   private final boolean enableRunAs;
   private final boolean userNameToLowerCase;
-  private final boolean gitBasicAuth;
   private final boolean useContributorAgreements;
   private final String loginUrl;
   private final String loginText;
@@ -88,7 +91,6 @@
     cookieSecure = cfg.getBoolean("auth", "cookiesecure", false);
     trustContainerAuth = cfg.getBoolean("auth", "trustContainerAuth", false);
     enableRunAs = cfg.getBoolean("auth", null, "enableRunAs", true);
-    gitBasicAuth = cfg.getBoolean("auth", "gitBasicAuth", false);
     gitBasicAuthPolicy = getBasicAuthPolicy(cfg);
     useContributorAgreements = cfg.getBoolean("auth", "contributoragreements", false);
     userNameToLowerCase = cfg.getBoolean("auth", "userNameToLowerCase", false);
@@ -223,11 +225,6 @@
     return userNameToLowerCase;
   }
 
-  /** Whether git-over-http should use Gerrit basic authentication scheme. */
-  public boolean isGitBasicAuth() {
-    return gitBasicAuth;
-  }
-
   public GitBasicAuthPolicy getGitBasicAuthPolicy() {
     return gitBasicAuthPolicy;
   }
@@ -237,7 +234,7 @@
     return useContributorAgreements;
   }
 
-  public boolean isIdentityTrustable(final Collection<AccountExternalId> ids) {
+  public boolean isIdentityTrustable(Collection<ExternalId> ids) {
     switch (getAuthType()) {
       case DEVELOPMENT_BECOME_ANY_ACCOUNT:
       case HTTP:
@@ -258,7 +255,7 @@
       case OPENID:
         // All identities must be trusted in order to trust the account.
         //
-        for (final AccountExternalId e : ids) {
+        for (ExternalId e : ids) {
           if (!isTrusted(e)) {
             return false;
           }
@@ -272,8 +269,8 @@
     }
   }
 
-  private boolean isTrusted(final AccountExternalId id) {
-    if (id.isScheme(AccountExternalId.SCHEME_MAILTO)) {
+  private boolean isTrusted(ExternalId id) {
+    if (id.isScheme(SCHEME_MAILTO)) {
       // mailto identities are created by sending a unique validation
       // token to the address and asking them to come back to the site
       // with that token.
@@ -281,20 +278,20 @@
       return true;
     }
 
-    if (id.isScheme(AccountExternalId.SCHEME_UUID)) {
+    if (id.isScheme(SCHEME_UUID)) {
       // UUID identities are absolutely meaningless and cannot be
       // constructed through any normal login process we use.
       //
       return true;
     }
 
-    if (id.isScheme(AccountExternalId.SCHEME_USERNAME)) {
+    if (id.isScheme(SCHEME_USERNAME)) {
       // We can trust their username, its local to our server only.
       //
       return true;
     }
 
-    for (final OpenIdProviderPattern p : trustedOpenIDs) {
+    for (OpenIdProviderPattern p : trustedOpenIDs) {
       if (p.matches(id)) {
         return true;
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfirmEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfirmEmail.java
index 35c9012..1044bbb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfirmEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfirmEmail.java
@@ -30,6 +30,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class ConfirmEmail implements RestModifyView<ConfigResource, Input> {
@@ -54,7 +55,7 @@
   @Override
   public Response<?> apply(ConfigResource rsrc, Input input)
       throws AuthException, UnprocessableEntityException, AccountException, OrmException,
-          IOException {
+          IOException, ConfigInvalidException {
     CurrentUser user = self.get();
     if (!user.isIdentifiedUser()) {
       throw new AuthException("Authentication required");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java
index 2ecbb54..2f68140 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java
@@ -156,7 +156,6 @@
     info.useContributorAgreements = toBoolean(cfg.isUseContributorAgreements());
     info.editableAccountFields = new ArrayList<>(realm.getEditableFields());
     info.switchAccountUrl = cfg.getSwitchAccountUrl();
-    info.isGitBasicAuth = toBoolean(cfg.isGitBasicAuth());
     info.gitBasicAuthPolicy = cfg.getGitBasicAuthPolicy();
 
     if (info.useContributorAgreements != null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java
index e55deda..47d416c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java
@@ -110,7 +110,8 @@
       String name = ref.getName();
       Change.Id changeId;
       Account.Id accountId;
-      if (name.startsWith(REFS_CACHE_AUTOMERGE) || (!showMetadata && isMetadata(name))) {
+      if (name.startsWith(REFS_CACHE_AUTOMERGE)
+          || (!showMetadata && isMetadata(projectCtl, name))) {
         continue;
       } else if (RefNames.isRefsEdit(name)) {
         // Edits are visible only to the owning user, if change is visible.
@@ -138,6 +139,12 @@
         if (viewMetadata) {
           result.put(name, ref);
         }
+      } else if (projectCtl.getProjectState().isAllUsers()
+          && name.equals(RefNames.REFS_EXTERNAL_IDS)) {
+        // The notes branch with the external IDs of all users must not be exposed to normal users.
+        if (viewMetadata) {
+          result.put(name, ref);
+        }
       } else if (projectCtl.controlForRef(ref.getLeaf().getName()).isVisible()) {
         // Use the leaf to lookup the control data. If the reference is
         // symbolic we want the control around the final target. If its
@@ -264,8 +271,10 @@
     }
   }
 
-  private static boolean isMetadata(String name) {
-    return name.startsWith(REFS_CHANGES) || RefNames.isRefsEdit(name);
+  private static boolean isMetadata(ProjectControl projectCtl, String name) {
+    return name.startsWith(REFS_CHANGES)
+        || RefNames.isRefsEdit(name)
+        || (projectCtl.getProjectState().isAllUsers() && name.equals(RefNames.REFS_EXTERNAL_IDS));
   }
 
   private static boolean isTag(Ref ref) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 1dd025f..630dd32 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -134,7 +134,8 @@
                     refControl, canonicalWebUrl, installCommitMsgHookCommand, sshInfo),
                 new ConfigValidator(refControl, repo, allUsers),
                 new BannedCommitsValidator(rejectCommits),
-                new PluginCommitValidationListener(pluginValidators)));
+                new PluginCommitValidationListener(pluginValidators),
+                new BlockExternalIdUpdateListener(allUsers)));
       }
     }
 
@@ -149,7 +150,8 @@
               new ChangeIdValidator(
                   refControl, canonicalWebUrl, installCommitMsgHookCommand, sshInfo),
               new ConfigValidator(refControl, repo, allUsers),
-              new PluginCommitValidationListener(pluginValidators)));
+              new PluginCommitValidationListener(pluginValidators),
+              new BlockExternalIdUpdateListener(allUsers)));
     }
 
     private CommitValidators forMergedCommits(RefControl refControl) {
@@ -617,6 +619,25 @@
     }
   }
 
+  /** Blocks any update to refs/meta/external-ids */
+  public static class BlockExternalIdUpdateListener implements CommitValidationListener {
+    private final AllUsersName allUsers;
+
+    public BlockExternalIdUpdateListener(AllUsersName allUsers) {
+      this.allUsers = allUsers;
+    }
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      if (allUsers.equals(receiveEvent.project.getNameKey())
+          && RefNames.REFS_EXTERNAL_IDS.equals(receiveEvent.refName)) {
+        throw new CommitValidationException("not allowed to update " + RefNames.REFS_EXTERNAL_IDS);
+      }
+      return Collections.emptyList();
+    }
+  }
+
   private static CommitValidationMessage getInvalidEmailError(
       RevCommit c,
       String type,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
index b9d78e0..e3de170 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
@@ -18,8 +18,8 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.Iterables;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.index.FieldDef;
 import com.google.gerrit.server.index.FieldType;
 import com.google.gerrit.server.index.SchemaUtil;
@@ -42,7 +42,7 @@
       new FieldDef.Repeatable<AccountState, String>("external_id", FieldType.EXACT, false) {
         @Override
         public Iterable<String> get(AccountState input, FillArgs args) {
-          return Iterables.transform(input.getExternalIds(), id -> id.getKey().get());
+          return Iterables.transform(input.getExternalIds(), id -> id.key().get());
         }
       };
 
@@ -54,8 +54,7 @@
           String fullName = input.getAccount().getFullName();
           Set<String> parts =
               SchemaUtil.getNameParts(
-                  fullName,
-                  Iterables.transform(input.getExternalIds(), AccountExternalId::getEmailAddress));
+                  fullName, Iterables.transform(input.getExternalIds(), ExternalId::email));
 
           // Additional values not currently added by getPersonParts.
           // TODO(dborowitz): Move to getPersonParts and remove this hack.
@@ -87,7 +86,7 @@
         @Override
         public Iterable<String> get(AccountState input, FillArgs args) {
           return FluentIterable.from(input.getExternalIds())
-              .transform(AccountExternalId::getEmailAddress)
+              .transform(ExternalId::email)
               .append(Collections.singleton(input.getAccount().getPreferredEmail()))
               .filter(Predicates.notNull())
               .transform(String::toLowerCase)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
index 1c336d4..c2b92aa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.index.IndexConfig;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.query.InternalQuery;
@@ -67,11 +68,27 @@
     return query(AccountPredicates.defaultPredicate(query));
   }
 
-  public List<AccountState> byExternalId(String externalId) throws OrmException {
-    return query(AccountPredicates.externalId(externalId));
+  public List<AccountState> byEmailPrefix(String emailPrefix) throws OrmException {
+    return query(AccountPredicates.email(emailPrefix));
+  }
+
+  public List<AccountState> byExternalId(String scheme, String id) throws OrmException {
+    return byExternalId(ExternalId.Key.create(scheme, id));
+  }
+
+  public List<AccountState> byExternalId(ExternalId.Key externalId) throws OrmException {
+    return query(AccountPredicates.externalId(externalId.toString()));
   }
 
   public AccountState oneByExternalId(String externalId) throws OrmException {
+    return oneByExternalId(ExternalId.Key.parse(externalId));
+  }
+
+  public AccountState oneByExternalId(String scheme, String id) throws OrmException {
+    return oneByExternalId(ExternalId.Key.create(scheme, id));
+  }
+
+  public AccountState oneByExternalId(ExternalId.Key externalId) throws OrmException {
     List<AccountState> accountStates = byExternalId(externalId);
     if (accountStates.size() == 1) {
       return accountStates.get(0);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/AclUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/AclUtil.java
index 97b4e51..3b87fb6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/AclUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/AclUtil.java
@@ -56,6 +56,18 @@
     }
   }
 
+  public static void block(
+      ProjectConfig config, AccessSection section, String permission, GroupReference... groupList) {
+    Permission p = section.getPermission(permission, true);
+    for (GroupReference group : groupList) {
+      if (group != null) {
+        PermissionRule r = rule(config, group);
+        r.setBlock();
+        p.add(r);
+      }
+    }
+  }
+
   public static void grant(
       ProjectConfig config,
       AccessSection section,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
index e2d16d8..a67a8a9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
@@ -35,7 +35,7 @@
 /** A version of the database schema. */
 public abstract class SchemaVersion {
   /** The current schema version. */
-  public static final Class<Schema_141> C = Schema_141.class;
+  public static final Class<Schema_142> C = Schema_142.class;
 
   public static int getBinaryVersion() {
     return guessVersion(C);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_142.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_142.java
new file mode 100644
index 0000000..df808df
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_142.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.HashedPassword;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.sql.SQLException;
+import java.util.List;
+
+public class Schema_142 extends SchemaVersion {
+  @Inject
+  Schema_142(Provider<Schema_141> prior) {
+    super(prior);
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    List<AccountExternalId> newIds = db.accountExternalIds().all().toList();
+    for (AccountExternalId id : newIds) {
+      if (!id.isScheme(AccountExternalId.SCHEME_USERNAME)) {
+        continue;
+      }
+
+      String password = id.getPassword();
+      if (password != null) {
+        HashedPassword hashed = HashedPassword.fromPassword(password);
+        id.setPassword(hashed.encode());
+      }
+    }
+
+    db.accountExternalIds().upsert(newIds);
+  }
+}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/scripts/preview_submit_test.sh b/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/scripts/preview_submit_test.sh
index d76c239..0d8b97f 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/scripts/preview_submit_test.sh
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/scripts/preview_submit_test.sh
@@ -36,7 +36,7 @@
         exit 1
 fi
 
-curl --digest -u $gerrituser -w '%{http_code}' -o preview \
+curl -u $gerrituser -w '%{http_code}' -o preview \
     $server/a/changes/$changeId/revisions/current/preview_submit?format=tgz >http_code
 if ! grep 200 http_code >/dev/null
 then
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/account/HashedPasswordTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/account/HashedPasswordTest.java
new file mode 100644
index 0000000..4955c06
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/account/HashedPasswordTest.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.base.Strings;
+import org.apache.commons.codec.DecoderException;
+import org.junit.Test;
+
+public class HashedPasswordTest {
+
+  @Test
+  public void encodeOneLine() throws Exception {
+    String password = "secret";
+    HashedPassword hashed = HashedPassword.fromPassword(password);
+    assertThat(hashed.encode()).doesNotContain("\n");
+    assertThat(hashed.encode()).doesNotContain("\r");
+  }
+
+  @Test
+  public void encodeDecode() throws Exception {
+    String password = "secret";
+    HashedPassword hashed = HashedPassword.fromPassword(password);
+    HashedPassword roundtrip = HashedPassword.decode(hashed.encode());
+    assertThat(hashed.encode()).isEqualTo(roundtrip.encode());
+    assertThat(roundtrip.checkPassword(password)).isTrue();
+    assertThat(roundtrip.checkPassword("not the password")).isFalse();
+  }
+
+  @Test(expected = DecoderException.class)
+  public void invalidDecode() throws Exception {
+    HashedPassword.decode("invalid");
+  }
+
+  @Test
+  public void lengthLimit() throws Exception {
+    String password = Strings.repeat("1", 72);
+
+    // make sure it fits in varchar(255).
+    assertThat(HashedPassword.fromPassword(password).encode().length()).isLessThan(255);
+  }
+
+  @Test
+  public void basicFunctionality() throws Exception {
+    String password = "secret";
+    HashedPassword hashed = HashedPassword.fromPassword(password);
+
+    assertThat(hashed.checkPassword("false")).isFalse();
+    assertThat(hashed.checkPassword(password)).isTrue();
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
index ca8dc0c6..a7b37a8 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
@@ -23,18 +23,13 @@
 
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.WatchConfig.NotifyType;
-import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
 import com.google.gerrit.server.mail.Address;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
-import java.util.Set;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.junit.Before;
@@ -388,9 +383,6 @@
     account.setFullName(name);
     account.setPreferredEmail(email);
     return new AccountState(
-        account,
-        Collections.<AccountGroup.UUID>emptySet(),
-        Collections.<AccountExternalId>emptySet(),
-        new HashMap<ProjectWatchKey, Set<NotifyType>>());
+        account, Collections.emptySet(), Collections.emptySet(), new HashMap<>());
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java
index d495c77..d81d441 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java
@@ -17,15 +17,10 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.WatchConfig.NotifyType;
-import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
 import java.util.HashMap;
 import java.util.Map;
-import java.util.Set;
 
 /** Fake implementation of {@link AccountCache} for testing. */
 public class FakeAccountCache implements AccountCache {
@@ -81,10 +76,6 @@
   }
 
   private static AccountState newState(Account account) {
-    return new AccountState(
-        account,
-        ImmutableSet.<AccountGroup.UUID>of(),
-        ImmutableSet.<AccountExternalId>of(),
-        new HashMap<ProjectWatchKey, Set<NotifyType>>());
+    return new AccountState(account, ImmutableSet.of(), ImmutableSet.of(), new HashMap<>());
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
index 45193f3..837865e 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.sshd;
 
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.ssh.SshKeyCache;
@@ -103,14 +103,17 @@
     @Override
     public Iterable<SshKeyCacheEntry> load(String username) throws Exception {
       try (ReviewDb db = schema.open()) {
-        AccountExternalId.Key key = new AccountExternalId.Key(SCHEME_USERNAME, username);
-        AccountExternalId user = db.accountExternalIds().get(key);
+        ExternalId user =
+            ExternalId.from(
+                db.accountExternalIds()
+                    .get(
+                        ExternalId.Key.create(SCHEME_USERNAME, username).asAccountExternalIdKey()));
         if (user == null) {
           return NO_SUCH_USER;
         }
 
         List<SshKeyCacheEntry> kl = new ArrayList<>(4);
-        for (AccountSshKey k : authorizedKeys.getKeys(user.getAccountId())) {
+        for (AccountSshKey k : authorizedKeys.getKeys(user.accountId())) {
           if (k.isValid()) {
             add(kl, k);
           }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
index 5fccb81..21591dd 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
@@ -263,7 +263,7 @@
   }
 
   private void addEmail(String email)
-      throws UnloggedFailure, RestApiException, OrmException, IOException {
+      throws UnloggedFailure, RestApiException, OrmException, IOException, ConfigInvalidException {
     EmailInput in = new EmailInput();
     in.email = email;
     in.noConfirmation = true;
@@ -274,7 +274,8 @@
     }
   }
 
-  private void deleteEmail(String email) throws RestApiException, OrmException, IOException {
+  private void deleteEmail(String email)
+      throws RestApiException, OrmException, IOException, ConfigInvalidException {
     if (email.equals("ALL")) {
       List<EmailInfo> emails = getEmails.apply(rsrc);
       for (EmailInfo e : emails) {
diff --git a/plugins/cookbook-plugin b/plugins/cookbook-plugin
index 85083bc..e6d7594 160000
--- a/plugins/cookbook-plugin
+++ b/plugins/cookbook-plugin
@@ -1 +1 @@
-Subproject commit 85083bc964ca00437e8695ec7335df9b87f28465
+Subproject commit e6d7594621d87859a0e6af361cac1fc3173c3588