diff --git a/core/modules/user/config/install/user.mail.yml b/core/modules/user/config/install/user.mail.yml
index b8efa9e91d..ca91e95b9b 100644
--- a/core/modules/user/config/install/user.mail.yml
+++ b/core/modules/user/config/install/user.mail.yml
@@ -4,6 +4,12 @@ cancel_confirm:
 password_reset:
   body: "[user:display-name],\n\nA request to reset the password for your account has been made at [site:name].\n\nYou may now log in by clicking this link or copying and pasting it into your browser:\n\n[user:one-time-login-url]\n\nThis link can only be used once to log in and will lead you to a page where you can set your password. It expires after one day and nothing will happen if it's not used.\n\n--  [site:name] team"
   subject: 'Replacement login information for [user:display-name] at [site:name]'
+mail_change_notification:
+  body: "[user:display-name],\n\nA request to change your email address has been made at [site:name]. In order to complete the change you will need to follow the instructions sent to your new email address within one day."
+  subject: 'Email change information for [user:display-name] at [site:name]'
+mail_change_verification:
+  body: "[user:display-name],\n\nA request to change your email address has been made at [site:name]. You need to verify the change by clicking on the link below or copying and pasting it in your browser:\n\n[user:mail-change-url]\n\nThis is a one-time URL, so it can be used only once. It expires after one day. If not used, your email address at [site:name] will not change."
+  subject: 'Email change information for [user:display-name] at [site:name]'
 register_admin_created:
   body: "[user:display-name],\n\nA site administrator at [site:name] has created an account for you. You may now log in by clicking this link or copying and pasting it into your browser:\n\n[user:one-time-login-url]\n\nThis link can only be used once to log in and will lead you to a page where you can set your password.\n\nAfter setting your password, you will be able to log in at [site:login-url] in the future using:\n\nusername: [user:name]\npassword: Your password\n\n--  [site:name] team"
   subject: 'An administrator created an account for you at [site:name]'
diff --git a/core/modules/user/config/install/user.settings.yml b/core/modules/user/config/install/user.settings.yml
index 8372ccdb73..ca46109fdd 100644
--- a/core/modules/user/config/install/user.settings.yml
+++ b/core/modules/user/config/install/user.settings.yml
@@ -9,8 +9,11 @@ notify:
   register_admin_created: true
   register_no_approval_required: true
   register_pending_approval: true
+  mail_change_notification: true
+  mail_change_verification: true
 register: visitors
 cancel_method: user_cancel_block
 password_reset_timeout: 86400
+mail_change_timeout: 86400
 password_strength: true
 langcode: en
diff --git a/core/modules/user/config/schema/user.schema.yml b/core/modules/user/config/schema/user.schema.yml
index 2f9bda44f2..ef42f6e6c4 100644
--- a/core/modules/user/config/schema/user.schema.yml
+++ b/core/modules/user/config/schema/user.schema.yml
@@ -38,6 +38,12 @@ user.settings:
         register_pending_approval:
           type: boolean
           label: 'Welcome (awaiting approval)'
+        mail_change_notification:
+          type: boolean
+          label: 'Notify user when email changes'
+        mail_change_verification:
+          type: boolean
+          label: 'Require email verification when a user changes their email address'
     register:
       type: string
       label: 'Who can register accounts?'
@@ -47,6 +53,9 @@ user.settings:
     password_reset_timeout:
       type: integer
       label: 'Password reset timeout'
+    mail_change_timeout:
+      type: integer
+      label: 'Mail change timeout'
     password_strength:
       type: boolean
       label: 'Enable password strength indicator'
@@ -61,6 +70,12 @@ user.mail:
   password_reset:
     type: mail
     label: 'Password recovery'
+  mail_change_notification:
+    type: mail
+    label: 'Change mail notification'
+  mail_change_verification:
+    type: mail
+    label: 'Change mail verification'
   register_admin_created:
     type: mail
     label: 'Account created by administrator'
diff --git a/core/modules/user/src/AccountForm.php b/core/modules/user/src/AccountForm.php
index 3624a9c934..77c67a0600 100644
--- a/core/modules/user/src/AccountForm.php
+++ b/core/modules/user/src/AccountForm.php
@@ -378,13 +378,30 @@ protected function flagViolations(EntityConstraintViolationListInterface $violat
    * {@inheritdoc}
    */
   public function submitForm(array &$form, FormStateInterface $form_state) {
+    /** @var \Drupal\user\UserInterface $account */
+    $account = $this->getEntity();
+    $new_mail = $form_state->getValue('mail');
+    $old_mail = $account->getEmail();
+
+    if (!$account->isNew() && ($old_mail !== $new_mail) && !$this->currentUser()->hasPermission('administer users')) {
+      // Send a verification to the new email address.
+      $account_cloned = clone $account;
+      $account_cloned->setEmail($new_mail);
+      if (_user_mail_notify('mail_change_verification', $account_cloned) !== NULL) {
+        // Send notification email to the old email address.
+        _user_mail_notify('mail_change_notification', $account);
+        // The user's mail address will be updated only after verification.
+        $form_state->setValue('mail', $old_mail);
+        $this->messenger()->addWarning($this->t('Your updated email address needs to be validated. Further instructions have been sent to your new email address.'));
+      }
+    }
+
     parent::submitForm($form, $form_state);
 
-    $user = $this->getEntity($form_state);
     // If there's a session set to the users id, remove the password reset tag
     // since a new password was saved.
-    if (isset($_SESSION['pass_reset_' . $user->id()])) {
-      unset($_SESSION['pass_reset_' . $user->id()]);
+    if (isset($_SESSION['pass_reset_' . $account->id()])) {
+      unset($_SESSION['pass_reset_' . $account->id()]);
     }
   }
 
diff --git a/core/modules/user/src/AccountSettingsForm.php b/core/modules/user/src/AccountSettingsForm.php
index 4a1b5af4a4..d80b833a16 100644
--- a/core/modules/user/src/AccountSettingsForm.php
+++ b/core/modules/user/src/AccountSettingsForm.php
@@ -198,7 +198,7 @@ public function buildForm(array $form, FormStateInterface $form_state) {
     ];
     // These email tokens are shared for all settings, so just define
     // the list once to help ensure they stay in sync.
-    $email_token_help = $this->t('Available variables are: [site:name], [site:url], [user:display-name], [user:account-name], [user:mail], [site:login-url], [site:url-brief], [user:edit-url], [user:one-time-login-url], [user:cancel-url].');
+    $email_token_help = $this->t('Available variables are: [site:name], [site:url], [user:display-name], [user:account-name], [user:mail], [site:login-url], [site:url-brief], [user:edit-url], [user:one-time-login-url], [user:mail-change-url], [user:cancel-url].');
 
     $form['email_admin_created'] = [
       '#type' => 'details',
@@ -300,6 +300,77 @@ public function buildForm(array $form, FormStateInterface $form_state) {
       '#rows' => 12,
     ];
 
+    $form['mail_change'] = [
+      '#type' => 'details',
+      '#title' => $this->t('Account email changing'),
+      '#collapsible' => TRUE,
+      '#collapsed' => TRUE,
+      '#group' => 'email',
+      '#weight' => 11,
+    ];
+    $form['mail_change']['mail_change_notification'] = [
+      '#type' => 'details',
+      '#tree' => TRUE,
+      '#title' => $this->t('Notification of old email'),
+      '#description' => $this->t("Edit the email message sent to the user's old email address when the email address is changed.") . ' ' . $email_token_help,
+      '#open' => TRUE,
+    ];
+    $form['mail_change']['mail_change_notification']['enabled'] = [
+      '#type' => 'checkbox',
+      '#title' => $this->t('Notify user when email changes'),
+      '#default_value' => $config->get('notify.mail_change_notification'),
+    ];
+    $states = [
+      'invisible' => [
+        'input[name="mail_change_notification[enabled]"]' => ['checked' => FALSE],
+      ],
+    ];
+    $form['mail_change']['mail_change_notification']['subject'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Subject'),
+      '#default_value' => $mail_config->get('mail_change_notification.subject'),
+      '#maxlength' => 180,
+      '#states' => $states,
+    ];
+    $form['mail_change']['mail_change_notification']['body'] = [
+      '#type' => 'textarea',
+      '#title' => $this->t('Body'),
+      '#default_value' => $mail_config->get('mail_change_notification.body'),
+      '#rows' => 12,
+      '#states' => $states,
+    ];
+    $form['mail_change']['mail_change_verification'] = [
+      '#type' => 'details',
+      '#tree' => TRUE,
+      '#title' => $this->t('Verification of new email'),
+      '#description' => $this->t("Edit the email message sent to user's new email address when the email address is changed.") . ' ' . $email_token_help,
+      '#open' => TRUE,
+    ];
+    $form['mail_change']['mail_change_verification']['enabled'] = [
+      '#type' => 'checkbox',
+      '#title' => $this->t('Require email verification when a user changes their email address'),
+      '#default_value' => $config->get('notify.mail_change_verification'),
+    ];
+    $states = [
+      'invisible' => [
+        'input[name="mail_change_verification[enabled]"]' => ['checked' => FALSE],
+      ],
+    ];
+    $form['mail_change']['mail_change_verification']['subject'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Subject'),
+      '#default_value' => $mail_config->get('mail_change_verification.subject'),
+      '#maxlength' => 180,
+      '#states' => $states,
+    ];
+    $form['mail_change']['mail_change_verification']['body'] = [
+      '#type' => 'textarea',
+      '#title' => $this->t('Body'),
+      '#default_value' => $mail_config->get('mail_change_verification.body'),
+      '#rows' => 12,
+      '#states' => $states,
+    ];
+
     $form['email_activated'] = [
       '#type' => 'details',
       '#title' => $this->t('Account activation'),
@@ -436,12 +507,20 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
       ->set('notify.status_activated', $form_state->getValue('user_mail_status_activated_notify'))
       ->set('notify.status_blocked', $form_state->getValue('user_mail_status_blocked_notify'))
       ->set('notify.status_canceled', $form_state->getValue('user_mail_status_canceled_notify'))
+      ->set('notify.mail_change_notification', $form_state->getValue(['mail_change_notification', 'enabled']))
+      ->set('notify.mail_change_verification', $form_state->getValue(['mail_change_verification', 'enabled']))
       ->save();
+
+    $form_state->unsetValue(['mail_change_notification', 'enabled']);
+    $form_state->unsetValue(['mail_change_verification', 'enabled']);
+
     $this->config('user.mail')
       ->set('cancel_confirm.body', $form_state->getValue('user_mail_cancel_confirm_body'))
       ->set('cancel_confirm.subject', $form_state->getValue('user_mail_cancel_confirm_subject'))
       ->set('password_reset.body', $form_state->getValue('user_mail_password_reset_body'))
       ->set('password_reset.subject', $form_state->getValue('user_mail_password_reset_subject'))
+      ->set('mail_change_notification', $form_state->getValue('mail_change_notification'))
+      ->set('mail_change_verification', $form_state->getValue('mail_change_verification'))
       ->set('register_admin_created.body', $form_state->getValue('user_mail_register_admin_created_body'))
       ->set('register_admin_created.subject', $form_state->getValue('user_mail_register_admin_created_subject'))
       ->set('register_no_approval_required.body', $form_state->getValue('user_mail_register_no_approval_required_body'))
diff --git a/core/modules/user/src/Controller/MailChangeController.php b/core/modules/user/src/Controller/MailChangeController.php
new file mode 100644
index 0000000000..91dc4ff940
--- /dev/null
+++ b/core/modules/user/src/Controller/MailChangeController.php
@@ -0,0 +1,148 @@
+<?php
+
+namespace Drupal\user\Controller;
+
+use Drupal\Component\Datetime\TimeInterface;
+use Drupal\Component\Utility\Crypt;
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\Url;
+use Drupal\user\UserInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides a controller Email change routes.
+ */
+class MailChangeController extends ControllerBase {
+
+  /**
+   * The date-time service.
+   *
+   * @var \Drupal\Component\Datetime\TimeInterface
+   */
+  protected $dateTime;
+
+  /**
+   * Builds a new controller.
+   *
+   * @param \Drupal\Component\Datetime\TimeInterface $date_time
+   *   The date-time service.
+   */
+  public function __construct(TimeInterface $date_time) {
+    $this->dateTime = $date_time;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static($container->get('datetime.time'));
+  }
+
+  /**
+   * Returns the user mail change page.
+   *
+   * In order to never disclose a mail change link via a referrer header this
+   * controller must always return a redirect response.
+   *
+   * @param \Drupal\user\UserInterface $user
+   *   The user account requesting Email change.
+   * @param string $new_mail
+   *   The user's new email address.
+   * @param int $timestamp
+   *   The timestamp when the hash was created.
+   * @param string $hash
+   *   Unique hash.
+   *
+   * @return \Symfony\Component\HttpFoundation\RedirectResponse
+   *   An HTTP response doing a redirect.
+   */
+  public function page(UserInterface $user, $new_mail, $timestamp, $hash) {
+    $timeout = $this->config('user.settings')->get('mail_change_timeout');
+    /** @var \Drupal\Core\Session\AccountProxyInterface $current_user */
+    $current_user = $this->currentUser();
+    $request_time = $this->dateTime->getRequestTime();
+    $messenger = $this->messenger();
+
+    // Other user is authenticated.
+    if ($current_user->isAuthenticated() && $current_user->id() != $user->id()) {
+      $arguments = [
+        '%user' => $current_user->getAccountName(),
+        ':logout' => Url::fromRoute('user.logout')->toString(),
+      ];
+      $messenger->addError($this->t('You are currently logged in as %user, and are attempting to confirm an email address change for another account. Please <a href=":logout">log out</a> and try using the link again.', $arguments));
+    }
+    // The link has expired.
+    elseif ($request_time - $timestamp > $timeout) {
+      $messenger->addError($this->t('You have tried to use an email address change link that has expired. Please visit your account and change your email again.'));
+    }
+    // The link is valid.
+    elseif ($timestamp <= $request_time && $timestamp >= $user->getLastLoginTime() && Crypt::hashEquals($hash, user_pass_rehash($user, $timestamp, $new_mail))) {
+      // Save the new email but refresh also the last login time so that this
+      // mail change link gets expired.
+      $user->setEmail($new_mail)->setLastLoginTime($request_time)->save();
+      /** @var \Drupal\user\UserStorageInterface $user_storage */
+      $user_storage = $this->entityTypeManager()->getStorage('user');
+      $user_storage->updateLastLoginTimestamp($user);
+      // Reflect the changes in the session if the user is logged in.
+      if ($current_user->isAuthenticated() && $current_user->id() == $user->id()) {
+        $current_user->setAccount($user);
+      }
+      $arguments = ['%mail' => $new_mail];
+      $messenger->addStatus($this->t('Your email address has been changed to %mail.', $arguments));
+    }
+    // Timestamp from the link is abnormal (in the future) or user registered a
+    // new login in the meantime or the hash is not valid.
+    else {
+      $messenger->addError($this->t('You have tried to use an email address change link that has either been used or is no longer valid. Please visit your account and change your email again.'));
+    }
+
+    return $this->redirect('<front>');
+  }
+
+  /**
+   * Checks access to change email url.
+   *
+   * @param \Drupal\user\UserInterface $user
+   *   The user account requesting Email change.
+   *
+   * @return \Drupal\Core\Access\AccessResultInterface
+   *   An access result
+   */
+  public function access(UserInterface $user) {
+    return AccessResult::allowedIf($user->isActive());
+  }
+
+  /**
+   * Generates a unique URL for a one time mail change confirmation.
+   *
+   * @param \Drupal\user\UserInterface $account
+   *   An object containing the user account.
+   * @param array $options
+   *   (optional) A keyed array of settings. Supported options are:
+   *   - langcode: A language code to be used when generating locale-sensitive
+   *   URLs. If langcode is NULL the users preferred language is used.
+   * @param int $timestamp
+   *   (optional) The timestamp when hash is created. If missed, the current
+   *   request time is used.
+   * @param string $hash
+   *   (optional) Unique hash. If missed, the hash is computed based on the
+   *   account data and timestamp.
+   *
+   * @return \Drupal\Core\Url
+   *   A unique url that provides a one-time email change confirmation.
+   */
+  public static function getUrl(UserInterface $account, array $options = [], $timestamp = NULL, $hash = NULL) {
+    $timestamp = $timestamp ?: \Drupal::time()->getRequestTime();
+    $langcode = isset($options['langcode']) ? $options['langcode'] : $account->getPreferredLangcode();
+    $hash = empty($hash) ? user_pass_rehash($account, $timestamp) : $hash;
+    $url_options = ['absolute' => TRUE, 'language' => \Drupal::service('language_manager')->getLanguage($langcode)];
+    return Url::fromRoute('user.mail_change', [
+      'user' => $account->id(),
+      'timestamp' => $timestamp,
+      'new_mail' => $account->getEmail(),
+      'hash' => $hash,
+    ], $url_options);
+  }
+
+}
diff --git a/core/modules/user/tests/src/Functional/Update/UpdateMailChangeTest.php b/core/modules/user/tests/src/Functional/Update/UpdateMailChangeTest.php
new file mode 100644
index 0000000000..8c05593979
--- /dev/null
+++ b/core/modules/user/tests/src/Functional/Update/UpdateMailChangeTest.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace Drupal\Tests\user\Functional\Update;
+
+use Drupal\FunctionalTests\Update\UpdatePathTestBase;
+use Symfony\Component\Yaml\Yaml;
+
+/**
+ * Tests update of user mail change configurations.
+ *
+ * @group user
+ * @group legacy
+ */
+class UpdateMailChangeTest extends UpdatePathTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setDatabaseDumpFiles() {
+    $this->databaseDumpFiles = [
+      __DIR__ . '/../../../../../system/tests/fixtures/update/drupal-8.4.0.bare.standard.php.gz',
+    ];
+  }
+
+  /**
+   * Tests user_post_update_mail_change().
+   *
+   * @see user_post_update_mail_change()
+   */
+  public function testMailChangeUpdate() {
+    $user_settings = $this->config('user.settings');
+    $user_mail = $this->config('user.mail');
+
+    // Check that mail change notifications settings are not set.
+    $this->assertNull($user_settings->get('notify.mail_change_notification'));
+    $this->assertNull($user_settings->get('notify.mail_change_verification'));
+    $this->assertNull($user_settings->get('mail_change_timeout'));
+
+    // Check that mail change configurations are not set.
+    $this->assertNull($user_mail->get('mail_change_notification'));
+    $this->assertNull($user_mail->get('mail_change_verification'));
+
+    $this->runUpdates();
+
+    $user_settings = $this->config('user.settings');
+    $user_mail = $this->config('user.mail');
+
+    // Check that mail change notifications were set to FALSE.
+    $this->assertFalse($user_settings->get('notify.mail_change_notification'));
+    $this->assertFalse($user_settings->get('notify.mail_change_verification'));
+
+    $config = Yaml::parse(file_get_contents(__DIR__ . '/../../../../config/install/user.mail.yml'));
+
+    // Check that mail change configurations were set to default values.
+    $this->assertSame($config['mail_change_notification'], $user_mail->get('mail_change_notification'));
+    $this->assertSame($config['mail_change_verification'], $user_mail->get('mail_change_verification'));
+    // Check that mail change timeout was set.
+    $this->assertEquals(86400, $user_settings->get('mail_change_timeout'));
+  }
+
+}
diff --git a/core/modules/user/tests/src/Functional/UserMailChangeTest.php b/core/modules/user/tests/src/Functional/UserMailChangeTest.php
new file mode 100644
index 0000000000..bd5d0e6aad
--- /dev/null
+++ b/core/modules/user/tests/src/Functional/UserMailChangeTest.php
@@ -0,0 +1,220 @@
+<?php
+
+namespace Drupal\Tests\user\Functional;
+
+use Drupal\Component\Render\FormattableMarkup;
+use Drupal\Core\Test\AssertMailTrait;
+use Drupal\Core\Url;
+use Drupal\Tests\BrowserTestBase;
+use Drupal\user\Controller\MailChangeController;
+use Drupal\user\Entity\User;
+
+/**
+ * Ensures that email change works as expected.
+ *
+ * @group user
+ */
+class UserMailChangeTest extends BrowserTestBase {
+
+  use AssertMailTrait;
+
+  /**
+   * The user object to test password resetting for.
+   *
+   * @var \Drupal\user\UserInterface
+   */
+  protected $account;
+
+  /**
+   * The date/time service.
+   *
+   * @var \Drupal\Component\Datetime\TimeInterface
+   */
+  protected $time;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    // Create a user.
+    $this->account = $this->drupalCreateUser();
+    $this->time = $this->container->get('datetime.time');
+  }
+
+  /**
+   * Tests email change functionality.
+   */
+  public function testMailChange() {
+    $this->drupalLogin($this->account);
+
+    // Ensure a time between the user last login and the time the account edit
+    // is posted. A human cannot login, edit the account and post the changes
+    // within the same second. But tests occasionally are running all steps in
+    // the same timestamp, so that the mail change URL timestamp equals the user
+    // last login timestamp. Later, in this test, when the user tries to reuse
+    // the expired link, the test is still within the timestamp when the user
+    // has logged in and a time difference cannot be experienced a because the
+    // user last login time has seconds as granularity.
+    sleep(1);
+
+    // Change the user email address.
+    $new_mail = $this->getRandomEmailAddress();
+    $edit = [
+      'mail' => $new_mail,
+      'current_pass' => $this->account->pass_raw,
+    ];
+    $this->drupalPostForm($this->account->toUrl('edit-form'), $edit, 'Save');
+
+    // Check that the validation status message has been displayed.
+    $this->assertSession()->pageTextContains('Your updated email address needs to be validated. Further instructions have been sent to your new email address.');
+
+    $user_mail = $this->config('user.mail');
+
+    /** @var \Drupal\Core\Utility\Token $token_service */
+    $token_service = $this->container->get('token');
+
+    // Check that a notification mail has been sent.
+    $this->assertMail('to', $this->account->getEmail());
+    $subject = $token_service->replace($user_mail->get('mail_change_notification.subject'), ['user' => $this->account]);
+    $this->assertMail('subject', $subject);
+
+    // Check that a verification mail has been sent.
+    $this->assertMailString('to', $new_mail, 2);
+    $subject = $token_service->replace($user_mail->get('mail_change_verification.subject'), ['user' => $this->account]);
+    $this->assertMailString('subject', $subject, 2);
+
+    $sent_mail_change_url = $this->extractUrlFromMail('user_mail_change_verification');
+
+    // Check that the email has been successfully updated.
+    $this->drupalGet($sent_mail_change_url);
+    $this->assertSession()->responseContains(new FormattableMarkup('Your email address has been changed to %mail.', ['%mail' => $new_mail]));
+
+    // Check that the change mail URL is not cached and expires after first use.
+    $this->drupalGet($sent_mail_change_url);
+    $this->assertNull($this->drupalGetHeader('X-Drupal-Cache'));
+    $this->assertSession()->responseContains('You have tried to use an email address change link that has either been used or is no longer valid. Please visit your account and change your email again.');
+
+    // Check that the user mail has been changed.
+    self::assertSame(User::load($this->account->id())->getEmail(), $new_mail);
+  }
+
+  /**
+   * Tests email change functionality when E-mail change verification is off.
+   */
+  public function testMailChangeNoVerification() {
+    // Disable mail change verification.
+    $this->config('user.settings')
+      ->set('notify.mail_change_verification', FALSE)
+      ->save();
+    $this->drupalLogin($this->account);
+
+    // Change the user email address.
+    $new_mail = $this->getRandomEmailAddress();
+    $edit = [
+      'mail' => $new_mail,
+      'current_pass' => $this->account->pass_raw,
+    ];
+    $this->drupalPostForm($this->account->toUrl('edit-form'), $edit, 'Save');
+
+    // Check that the validation status message has not been displayed.
+    $this->assertSession()->pageTextNotContains('Your updated email address needs to be validated. Further instructions have been sent to your new email address.');
+
+    // Check that no E-mail was sent to the old or to the new address.
+    $this->assertEmpty($this->getMails());
+
+    // Check that the user's E-mail was changed instantly.
+    self::assertSame($new_mail, User::load($this->account->id())->getEmail());
+  }
+
+  /**
+   * Tests change of email for blocked users.
+   */
+  public function testBlockedUser() {
+    $timestamp = $this->time->getRequestTime() - 1;
+    $account_cloned = clone $this->account;
+    $account_cloned->block()->save();
+    $this->drupalGet(MailChangeController::getUrl($account_cloned, [], $timestamp)->getInternalPath());
+    $this->assertSession()->statusCodeEquals(403);
+  }
+
+  /**
+   * Tests change of email for expired timestamp.
+   */
+  public function testExpiredTimestamp() {
+    $timestamp = $this->time->getRequestTime() - (24 * 60 * 60 + 1);
+    $this->drupalGet(MailChangeController::getUrl($this->account, [], $timestamp)->getInternalPath());
+    $this->assertSession()->responseContains('You have tried to use an email address change link that has expired. Please visit your account and change your email again.');
+  }
+
+  /**
+   * Tests change of email when other user is logged in.
+   */
+  public function testOtherUserLoggedIn() {
+    $timestamp = $this->time->getRequestTime() - 1;
+    // Create other account and login with it.
+    $current_account = $this->drupalCreateUser();
+    $this->drupalLogin($current_account);
+    // Try to change the email for the first account when the other account is
+    // logged in.
+    $new_mail = $this->getRandomEmailAddress();
+    $this->account->setEmail($new_mail);
+    $path = MailChangeController::getUrl($this->account, [], $timestamp)->getInternalPath();
+    $this->drupalGet($path);
+    $this->assertSession()->responseContains(new FormattableMarkup('You are currently logged in as %user, and are attempting to confirm an email address change for another account. Please <a href=":logout">log out</a> and try using the link again.', ['%user' => $current_account->getAccountName(), ':logout' => Url::fromRoute('user.logout')->toString()]));
+
+    // Retry as anonymous.
+    $this->drupalLogout();
+    $this->drupalGet($path);
+    $this->assertSession()->responseContains(new FormattableMarkup('Your email address has been changed to %mail.', ['%mail' => $new_mail]));
+  }
+
+  /**
+   * Tests change of email for timestamp in the future.
+   */
+  public function testFutureTimestamp() {
+    $timestamp = $this->time->getRequestTime() + 60 * 60;
+    $this->drupalGet(MailChangeController::getUrl($this->account, [], $timestamp)->getInternalPath());
+    $this->assertSession()->responseContains('You have tried to use an email address change link that has either been used or is no longer valid. Please visit your account and change your email again.');
+  }
+
+  /**
+   * Tests change of email with the wrong hash.
+   */
+  public function testWrongHash() {
+    $timestamp = $this->time->getRequestTime() - 1;
+    // Generate the hash for other user.
+    $other_account = $this->drupalCreateUser();
+    $hash = user_pass_rehash($other_account, $timestamp);
+    $this->drupalGet(MailChangeController::getUrl($this->account, [], $timestamp, $hash)->getInternalPath());
+    $this->assertSession()->responseContains('You have tried to use an email address change link that has either been used or is no longer valid. Please visit your account and change your email again.');
+  }
+
+  /**
+   * Retrieves the change email and extracts the link.
+   *
+   * @param string $mail_id
+   *   Unique mail ID.
+   *
+   * @return string
+   *   An URL.
+   */
+  protected function extractUrlFromMail($mail_id) {
+    // Assume the most recent email.
+    $email = $this->getMails(['id' => $mail_id]);
+    $email = end($email);
+    preg_match('#.+user\/mail\-change\/.+#', $email['body'], $urls);
+    return $urls[0];
+  }
+
+  /**
+   * Generates a random email address.
+   *
+   * @return string
+   *   A random email address.
+   */
+  protected function getRandomEmailAddress() {
+    return mb_strtolower($this->randomMachineName()) . '@example.com';
+  }
+
+}
diff --git a/core/modules/user/tests/src/Functional/UserTokenReplaceTest.php b/core/modules/user/tests/src/Functional/UserTokenReplaceTest.php
index e272b60732..b70ac4c1fd 100644
--- a/core/modules/user/tests/src/Functional/UserTokenReplaceTest.php
+++ b/core/modules/user/tests/src/Functional/UserTokenReplaceTest.php
@@ -6,6 +6,7 @@
 use Drupal\Core\Render\BubbleableMetadata;
 use Drupal\language\Entity\ConfigurableLanguage;
 use Drupal\Tests\BrowserTestBase;
+use Drupal\user\Controller\MailChangeController;
 use Drupal\user\Entity\User;
 
 /**
@@ -129,6 +130,7 @@ public function testUserTokenReplacement() {
     // Generate login and cancel link.
     $tests = [];
     $tests['[user:one-time-login-url]'] = user_pass_reset_url($account);
+    $tests['[user:mail-change-url]'] = MailChangeController::getUrl($account)->toString();
     $tests['[user:cancel-url]'] = user_cancel_url($account);
 
     // Generate tokens with interface language.
diff --git a/core/modules/user/user.module b/core/modules/user/user.module
index b18408ab5f..86604f53f1 100644
--- a/core/modules/user/user.module
+++ b/core/modules/user/user.module
@@ -18,6 +18,7 @@
 use Drupal\Core\Site\Settings;
 use Drupal\Core\Url;
 use Drupal\system\Entity\Action;
+use Drupal\user\Controller\MailChangeController;
 use Drupal\user\Entity\Role;
 use Drupal\user\Entity\User;
 use Drupal\user\RoleInterface;
@@ -658,15 +659,19 @@ function user_cancel_url(UserInterface $account, $options = []) {
  *   An object containing the user account.
  * @param int $timestamp
  *   A UNIX timestamp, typically REQUEST_TIME.
+ * @param string $mail
+ *   (optional) If passed, this value will be used to compute the hash instead
+ *   of that stored into $account.
  *
  * @return string
  *   A string that is safe for use in URLs and SQL statements.
  */
-function user_pass_rehash(UserInterface $account, $timestamp) {
+function user_pass_rehash(UserInterface $account, $timestamp, $mail = NULL) {
+  $mail = $mail ?: $account->getEmail();
   $data = $timestamp;
   $data .= $account->getLastLoginTime();
   $data .= $account->id();
-  $data .= $account->getEmail();
+  $data .= $mail;
   return Crypt::hmacBase64($data, Settings::getHashSalt() . $account->getPassword());
 }
 
@@ -963,6 +968,7 @@ function user_mail($key, &$message, $params) {
  *   properties:
  *   - login: The UNIX timestamp of the user's last login.
  *   - pass: The hashed account login password.
+ *   - email: The user's email address.
  * @param array $options
  *   A keyed array of settings and flags to control the token replacement
  *   process. See \Drupal\Core\Utility\Token::replace().
@@ -970,6 +976,7 @@ function user_mail($key, &$message, $params) {
 function user_mail_tokens(&$replacements, $data, $options) {
   if (isset($data['user'])) {
     $replacements['[user:one-time-login-url]'] = user_pass_reset_url($data['user'], $options);
+    $replacements['[user:mail-change-url]'] = MailChangeController::getUrl($data['user'], $options)->toString();
     $replacements['[user:cancel-url]'] = user_cancel_url($data['user'], $options);
   }
 }
@@ -1199,6 +1206,8 @@ function user_role_revoke_permissions($rid, array $permissions = []) {
  *   - 'register_pending_approval': Welcome message, user pending admin
  *     approval.
  *   - 'password_reset': Password recovery request.
+ *   - 'mail_change_notification': Email change notification.
+ *   - 'mail_change_verification': Email change verification.
  *   - 'status_activated': Account activated.
  *   - 'status_blocked': Account blocked.
  *   - 'cancel_confirm': Account cancellation request.
@@ -1211,11 +1220,13 @@ function user_role_revoke_permissions($rid, array $permissions = []) {
  *   (optional) Language code to use for the notification, overriding account
  *   language.
  *
- * @return array
- *   An array containing various information about the message.
- *   See \Drupal\Core\Mail\MailManagerInterface::mail() for details.
+ * @return array|null
+ *   An array containing various information about the message, as they are
+ *   returned by \Drupal\Core\Mail\MailManagerInterface::mail(), or NULL if the
+ *   notifications for the required operation are disabled.
  *
  * @see user_mail_tokens()
+ * @see \Drupal\Core\Mail\MailManagerInterface::mail()
  */
 function _user_mail_notify($op, $account, $langcode = NULL) {
   if (\Drupal::config('user.settings')->get('notify.' . $op)) {
diff --git a/core/modules/user/user.post_update.php b/core/modules/user/user.post_update.php
index 3f19b0c16f..2532a4059d 100644
--- a/core/modules/user/user.post_update.php
+++ b/core/modules/user/user.post_update.php
@@ -20,3 +20,30 @@ function user_post_update_enforce_order_of_permissions() {
   };
   array_map($entity_save, Role::loadMultiple());
 }
+
+/**
+ * Update config for change mail notifications.
+ */
+function user_post_update_mail_change() {
+  $config_factory = \Drupal::service('config.factory');
+
+  $config_factory->getEditable('user.settings')
+    ->set('notify.mail_change_notification', FALSE)
+    ->set('notify.mail_change_verification', FALSE)
+    ->set('mail_change_timeout', 86400)
+    ->save();
+
+  $mail_change_notification = [
+    'body' => "[user:display-name],\n\nA request to change your email address has been made at [site:name]. In order to complete the change you will need to follow the instructions sent to your new email address within one day.",
+    'subject' => 'Email change information for [user:display-name] at [site:name]',
+  ];
+  $mail_change_verification = [
+    'body' => "[user:display-name],\n\nA request to change your email address has been made at [site:name]. You need to verify the change by clicking on the link below or copying and pasting it in your browser:\n\n[user:mail-change-url]\n\nThis is a one-time URL, so it can be used only once. It expires after one day. If not used, your email address at [site:name] will not change.",
+    'subject' => 'Email change information for [user:display-name] at [site:name]',
+  ];
+
+  $config_factory->getEditable('user.mail')
+    ->set('mail_change_notification', $mail_change_notification)
+    ->set('mail_change_verification', $mail_change_verification)
+    ->save();
+}
diff --git a/core/modules/user/user.routing.yml b/core/modules/user/user.routing.yml
index e38bb7acad..cf6e99c06c 100644
--- a/core/modules/user/user.routing.yml
+++ b/core/modules/user/user.routing.yml
@@ -209,3 +209,15 @@ user.reset.form:
   options:
     _maintenance_access: TRUE
     no_cache: TRUE
+
+user.mail_change:
+  path: '/user/mail-change/{user}/{new_mail}/{timestamp}/{hash}'
+  defaults:
+    _controller: '\Drupal\user\Controller\MailChangeController::page'
+    _title: 'Change email address'
+  requirements:
+    _custom_access: '\Drupal\user\Controller\MailChangeController::access'
+    user: \d+
+    timestamp: \d+
+  options:
+    no_cache: TRUE
