diff --git a/core/lib/Drupal/Core/EventSubscriber/ExceptionJsonSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/ExceptionJsonSubscriber.php
index 01f3620..34eda1f 100644
--- a/core/lib/Drupal/Core/EventSubscriber/ExceptionJsonSubscriber.php
+++ b/core/lib/Drupal/Core/EventSubscriber/ExceptionJsonSubscriber.php
@@ -28,6 +28,17 @@ protected static function getPriority() {
   }
 
   /**
+   * Handles a 400 error for JSON.
+   *
+   * @param \Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent $event
+   *   The event to process.
+   */
+  public function on400(GetResponseForExceptionEvent $event) {
+    $response = new JsonResponse(array('message' => $event->getException()->getMessage()), Response::HTTP_BAD_REQUEST);
+    $event->setResponse($response);
+  }
+
+  /**
    * Handles a 403 error for JSON.
    *
    * @param \Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent $event
diff --git a/core/modules/serialization/serialization.services.yml b/core/modules/serialization/serialization.services.yml
index c510ab1..8b570c0 100644
--- a/core/modules/serialization/serialization.services.yml
+++ b/core/modules/serialization/serialization.services.yml
@@ -64,3 +64,13 @@ services:
     class: Drupal\serialization\EntityResolver\TargetIdResolver
     tags:
       - { name: entity_resolver}
+  serialization.exception.default:
+    class: Drupal\serialization\EventSubscriber\DefaultExceptionSubscriber
+    tags:
+      - { name: event_subscriber }
+    arguments: ['@serializer', '%serializer.formats%']
+  serialization.user_route_alter_subscriber:
+    class: Drupal\serialization\EventSubscriber\UserRouteAlterSubscriber
+    tags:
+      - { name: event_subscriber }
+    arguments: ['@serializer', '%serializer.formats%']
diff --git a/core/modules/serialization/src/EventSubscriber/DefaultExceptionSubscriber.php b/core/modules/serialization/src/EventSubscriber/DefaultExceptionSubscriber.php
new file mode 100644
index 0000000..bc1210a
--- /dev/null
+++ b/core/modules/serialization/src/EventSubscriber/DefaultExceptionSubscriber.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace Drupal\serialization\EventSubscriber;
+
+use Drupal\Core\EventSubscriber\HttpExceptionSubscriberBase;
+use Drupal\Core\EventSubscriber\MainContentViewSubscriber;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;
+use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
+use Symfony\Component\Serializer\SerializerInterface;
+
+/**
+ * Exception subscriber for handling default  error responses in serialization formats.
+ */
+class DefaultExceptionSubscriber extends HttpExceptionSubscriberBase {
+
+  /**
+   * The serializer.
+   *
+   * @var \Symfony\Component\Serializer\Serializer
+   */
+  protected $serializer;
+
+  /**
+   * The available serialization formats.
+   *
+   * @var array
+   */
+  protected $serializerFormats = [];
+
+  /**
+   * DefaultExceptionSubscriber constructor.
+   *
+   * @param \Symfony\Component\Serializer\SerializerInterface $serializer
+   *   The serializer service.
+   * @param array $serializer_formats
+   *   The available serialization formats.
+   */
+  public function __construct(SerializerInterface $serializer, array $serializer_formats) {
+    $this->serializer = $serializer;
+    $this->serializerFormats = $serializer_formats;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getHandledFormats() {
+    return $this->serializerFormats;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static function getPriority() {
+    // This will fire after the most common HTML handler, since HTML requests
+    // are still more common than HTTP requests.
+    return -75;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function onException(GetResponseForExceptionEvent $event) {
+    $exception = $event->getException();
+
+    // Make the exception available for example when rendering a block.
+    $request = $event->getRequest();
+    $request->attributes->set('exception', $exception);
+
+    $handled_formats = $this->getHandledFormats();
+
+    $format = $request->query->get(MainContentViewSubscriber::WRAPPER_FORMAT, $request->getRequestFormat());
+
+    if ($exception instanceof HttpExceptionInterface && in_array($format, $handled_formats)) {
+      $format = $event->getRequest()->getRequestFormat();
+      $content = ['message' => $event->getException()->getMessage()];
+      $encoded_content = $this->serializer->serialize($content, $format);
+      $response = new Response($encoded_content, $exception->getStatusCode());
+      $event->setResponse($response);
+    }
+  }
+
+}
diff --git a/core/modules/serialization/src/EventSubscriber/UserRouteAlterSubscriber.php b/core/modules/serialization/src/EventSubscriber/UserRouteAlterSubscriber.php
new file mode 100644
index 0000000..d8002d2
--- /dev/null
+++ b/core/modules/serialization/src/EventSubscriber/UserRouteAlterSubscriber.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace Drupal\serialization\EventSubscriber;
+
+
+use Drupal\Core\Routing\RouteBuildEvent;
+use Drupal\Core\Routing\RoutingEvents;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\Serializer\SerializerInterface;
+
+/**
+ * Alters user authentication routes to support additional serialization formats.
+ */
+class UserRouteAlterSubscriber implements EventSubscriberInterface {
+
+  /**
+   * The serializer.
+   *
+   * @var \Symfony\Component\Serializer\Serializer
+   */
+  protected $serializer;
+
+  /**
+   * The available serialization formats.
+   *
+   * @var array
+   */
+  protected $serializerFormats = [];
+
+  /**
+   * UserRouteAlterSubscriber constructor.
+   *
+   * @param \Symfony\Component\Serializer\SerializerInterface $serializer
+   *   The serializer service.
+   * @param array $serializer_formats
+   *   The available serializer formats.
+   */
+  public function __construct(SerializerInterface $serializer, array $serializer_formats) {
+    $this->serializer = $serializer;
+    $this->serializerFormats = $serializer_formats;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    $events[RoutingEvents::ALTER][] = 'onRoutingAlterAddFormats';
+    return $events;
+  }
+
+  /**
+   * Adds supported formats to the user authentication HTTP routes.
+   *
+   * @param \Drupal\Core\Routing\RouteBuildEvent $event
+   *   The event to process.
+   */
+  public function onRoutingAlterAddFormats(RouteBuildEvent $event) {
+    $route_names = [
+      'user.login_status.http',
+      'user.login.http',
+      'user.logout.http',
+    ];
+    $routes = $event->getRouteCollection();
+    foreach ($route_names as $route_name) {
+      if ($route = $routes->get($route_name)) {
+        $formats = explode('|', $route->getRequirement('_format'));
+        $formats = array_unique($formats + $this->serializerFormats);
+        $route->setRequirement('_format', implode('|', $formats));
+      }
+    }
+  }
+
+}
diff --git a/core/modules/user/src/Controller/UserAuthenticationController.php b/core/modules/user/src/Controller/UserAuthenticationController.php
new file mode 100644
index 0000000..e5a960e
--- /dev/null
+++ b/core/modules/user/src/Controller/UserAuthenticationController.php
@@ -0,0 +1,329 @@
+<?php
+
+namespace Drupal\user\Controller;
+
+use Drupal\Core\Access\CsrfTokenGenerator;
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Flood\FloodInterface;
+use Drupal\user\UserAuthInterface;
+use Drupal\user\UserInterface;
+use Drupal\user\UserStorageInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+use Symfony\Component\Serializer\Encoder\JsonEncoder;
+use Symfony\Component\Serializer\Serializer;
+
+/**
+ * Provides controllers for login, login status and logout.
+ */
+class UserAuthenticationController extends ControllerBase implements ContainerInjectionInterface {
+
+  /**
+   * String sent in responses, to describe the user as being logged in.
+   *
+   * @var string
+   */
+  const LOGGED_IN = 1;
+
+  /**
+   * String sent in responses, to describe the user as being logged out.
+   *
+   * @var string
+   */
+  const LOGGED_OUT = 0;
+
+  /**
+   * The flood controller.
+   *
+   * @var \Drupal\Core\Flood\FloodInterface
+   */
+  protected $flood;
+
+  /**
+   * The user storage.
+   *
+   * @var \Drupal\user\UserStorageInterface
+   */
+  protected $userStorage;
+
+  /**
+   * The CSRF token generator.
+   *
+   * @var \Drupal\Core\Access\CsrfTokenGenerator
+   */
+  protected $csrfToken;
+
+  /**
+   * The user authentication.
+   *
+   * @var \Drupal\user\UserAuthInterface
+   */
+  protected $userAuth;
+
+  /**
+   * The serializer.
+   *
+   * @var \Symfony\Component\Serializer\Serializer
+   */
+  protected $serializer;
+
+  /**
+   * The available serialization formats.
+   *
+   * @var array
+   */
+  protected $serializerFormats = array();
+
+  /**
+   * Constructs a new UserAuthenticationController object.
+   *
+   * @param \Drupal\Core\Flood\FloodInterface $flood
+   *   The flood controller.
+   * @param \Drupal\user\UserStorageInterface $user_storage
+   *   The user storage.
+   * @param \Drupal\Core\Access\CsrfTokenGenerator $csrf_token
+   *   The CSRF token generator.
+   * @param \Drupal\user\UserAuthInterface $user_auth
+   *   The user authentication.
+   * @param \Symfony\Component\Serializer\Serializer $serializer
+   *   The serializer.
+   * @param array $serializer_formats
+   *   The available serialization formats.
+   */
+  public function __construct(FloodInterface $flood, UserStorageInterface $user_storage, CsrfTokenGenerator $csrf_token, UserAuthInterface $user_auth, Serializer $serializer, array $serializer_formats) {
+    $this->flood = $flood;
+    $this->userStorage = $user_storage;
+    $this->csrfToken = $csrf_token;
+    $this->userAuth = $user_auth;
+    $this->serializer = $serializer;
+    $this->serializerFormats = $serializer_formats;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    if ($container->hasParameter('serializer.formats') && $container->has('serializer')) {
+      $serializer = $container->get('serializer');
+      $formats = $container->getParameter('serializer.formats');
+    }
+    else {
+      $formats = ['json'];
+      $encoders = [new JsonEncoder()];
+      $serializer = new Serializer([], $encoders);
+    }
+
+    return new static(
+      $container->get('flood'),
+      $container->get('entity_type.manager')->getStorage('user'),
+      $container->get('csrf_token'),
+      $container->get('user.auth'),
+      $serializer,
+      $formats
+    );
+  }
+
+  /**
+   * Logs in a user.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request.
+   *
+   * @return \Symfony\Component\HttpFoundation\Response
+   *   A response which contains the ID and CSRF token.
+   */
+  public function login(Request $request) {
+    $format = $this->getRequestFormat($request);
+
+    $content = $request->getContent();
+    $credentials = $this->serializer->decode($content, $format);
+    if (!isset($credentials['name']) && !isset($credentials['pass'])) {
+      throw new BadRequestHttpException('Missing credentials.');
+    }
+
+    if (!isset($credentials['name'])) {
+      throw new BadRequestHttpException('Missing credentials.name.');
+    }
+    if (!isset($credentials['pass'])) {
+      throw new BadRequestHttpException('Missing credentials.pass.');
+    }
+
+    $this->floodControl($request, $credentials['name']);
+
+    if ($this->userIsBlocked($credentials['name'])) {
+      throw new BadRequestHttpException('The user has not been activated or is blocked.');
+    }
+
+    if ($uid = $this->userAuth->authenticate($credentials['name'], $credentials['pass'])) {
+      $this->flood->clear('user.http_login', $this->getLoginFloodIdentifier($request, $credentials['name']));
+      /** @var \Drupal\user\UserInterface $user */
+      $user = $this->userStorage->load($uid);
+      $this->userLoginFinalize($user);
+
+      // Send basic metadata about the logged in user.
+      $response_data = [];
+      if ($user->get('uid')->access('view')) {
+        $response_data['current_user']['uid'] = $user->id();
+      }
+      if ($user->get('roles')->access('view')) {
+        $response_data['current_user']['roles'] = $user->getRoles();
+      }
+      if ($user->get('name')->access('view')) {
+        $response_data['current_user']['name'] = $user->getAccountName();
+      }
+      $response_data['csrf_token'] = $this->csrfToken->get('rest');
+
+      $encoded_response_data = $this->serializer->encode($response_data, $format);
+      return new Response($encoded_response_data);
+    }
+
+    $flood_config = $this->config('user.flood');
+    if ($identifier = $this->getLoginFloodIdentifier($request, $credentials['name'])) {
+      $this->flood->register('user.http_login', $flood_config->get('user_window'), $identifier);
+    }
+    // Always register an IP-based failed login event.
+    $this->flood->register('user.failed_login_ip', $flood_config->get('ip_window'));
+    throw new BadRequestHttpException('Sorry, unrecognized username or password.');
+  }
+
+  /**
+   * Verifies if the user is blocked.
+   *
+   * @param string $name
+   *   The username.
+   *
+   * @return bool
+   *   TRUE if the user is blocked, otherwise FALSE.
+   */
+  protected function userIsBlocked($name) {
+    return user_is_blocked($name);
+  }
+
+  /**
+   * Finalizes the user login.
+   *
+   * @param \Drupal\user\UserInterface $user
+   *   The user.
+   */
+  protected function userLoginFinalize(UserInterface $user) {
+    user_login_finalize($user);
+  }
+
+  /**
+   * Logs out a user.
+   *
+   * @return \Drupal\rest\ResourceResponse
+   *   The response object.
+   */
+  public function logout() {
+    $this->userLogout();
+    return new Response(NULL, 204);
+  }
+
+  /**
+   * Logs the user out.
+   */
+  protected function userLogout() {
+    user_logout();
+  }
+
+  /**
+   * Checks whether a user is logged in or not.
+   *
+   * @return \Symfony\Component\HttpFoundation\Response
+   *   The response.
+   */
+  public function loginStatus() {
+    if ($this->currentUser()->isAuthenticated()) {
+      $response = new Response(self::LOGGED_IN);
+    }
+    else {
+      $response = new Response(self::LOGGED_OUT);
+    }
+    $response->headers->set('Content-Type', 'text/plain');
+    return $response;
+  }
+
+  /**
+   * Gets the format of the current request.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The current request.
+   *
+   * @return string
+   *   The format of the request.
+   */
+  protected function getRequestFormat(Request $request) {
+    $format = $request->getRequestFormat();
+    if (!in_array($format, $this->serializerFormats)) {
+      throw new BadRequestHttpException("Unrecognized format: $format.");
+    }
+    return $format;
+  }
+
+  /**
+   * Enforces flood control for the current login request.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The current request.
+   * @param string $username
+   *   The user name sent for login credentials.
+   */
+  protected function floodControl(Request $request, $username) {
+    $flood_config = $this->config('user.flood');
+    if (!$this->flood->isAllowed('user.failed_login_ip', $flood_config->get('ip_limit'), $flood_config->get('ip_window'))) {
+      throw new AccessDeniedHttpException('Access is blocked because of IP based flood prevention.', NULL, 403);
+    }
+
+    if ($identifier = $this->getLoginFloodIdentifier($request, $username)) {
+      // Don't allow login if the limit for this user has been reached.
+      // Default is to allow 5 failed attempts every 6 hours.
+      if (!$this->flood->isAllowed('user.http_login', $flood_config->get('user_limit'), $flood_config->get('user_window'), $identifier)) {
+        if ($flood_config->get('uid_only')) {
+          $error_message = $this->formatPlural($flood_config->get('user_limit'), 'There has been more than one failed login attempt for this account. It is temporarily blocked. Try again later or request a new password.', 'There have been more than @count failed login attempts for this account. It is temporarily blocked. Try again later or request a new password.');
+        }
+        else {
+          $error_message = $this->t('Too many failed login attempts from your IP address. This IP address is temporarily blocked.');
+        }
+        throw new AccessDeniedHttpException($error_message, NULL, 403);
+      }
+    }
+  }
+
+  /**
+   * Gets the login identifier for user login flood control.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The current request.
+   * @param string $username
+   *   The username supplied in login credentials.
+   *
+   * @return string
+   *   The login identifier or if the user does not exist an empty string.
+   */
+  protected function getLoginFloodIdentifier(Request $request, $username) {
+    $flood_config = $this->config('user.flood');
+    $accounts = $this->userStorage->loadByProperties(['name' => $username, 'status' => 1]);
+    if ($account = reset($accounts)) {
+      if ($flood_config->get('uid_only')) {
+        // Register flood events based on the uid only, so they apply for any
+        // IP address. This is the most secure option.
+        $identifier = $account->id();
+        return $identifier;
+      }
+      else {
+        // The default identifier is a combination of uid and IP address. This
+        // is less secure but more resistant to denial-of-service attacks that
+        // could lock out all users with public user names.
+        $identifier = $account->id() . '-' . $request->getClientIp();
+        return $identifier;
+      }
+    }
+    return '';
+  }
+
+}
diff --git a/core/modules/user/tests/src/Functional/UserLoginHttpTest.php b/core/modules/user/tests/src/Functional/UserLoginHttpTest.php
new file mode 100644
index 0000000..7484b8c
--- /dev/null
+++ b/core/modules/user/tests/src/Functional/UserLoginHttpTest.php
@@ -0,0 +1,395 @@
+<?php
+
+namespace Drupal\Tests\user\Functional;
+
+use Drupal\Core\Flood\DatabaseBackend;
+use Drupal\Core\Url;
+use Drupal\Tests\BrowserTestBase;
+use Drupal\user\Controller\UserAuthenticationController;
+use GuzzleHttp\Cookie\CookieJar;
+use Psr\Http\Message\ResponseInterface;
+use Symfony\Component\Serializer\Encoder\JsonEncoder;
+use Symfony\Component\Serializer\Encoder\XmlEncoder;
+use Symfony\Component\Serializer\Serializer;
+
+/**
+ * Tests login via direct HTTP.
+ *
+ * @group user
+ */
+class UserLoginHttpTest extends BrowserTestBase {
+
+  /**
+   * The cookie jar.
+   *
+   * @var \GuzzleHttp\Cookie\CookieJar
+   */
+  protected $cookies;
+
+  /**
+   * The serializer.
+   *
+   * @var \Symfony\Component\Serializer\Serializer
+   */
+  protected $serializer;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->cookies = new CookieJar();
+    $encoders = [new JsonEncoder(), new XmlEncoder()];
+    $this->serializer = new Serializer([], $encoders);
+  }
+
+  /**
+   * Executes a login HTTP request.
+   *
+   * @param string $name
+   *   The username.
+   * @param string $pass
+   *   The user password.
+   * @param string $format
+   *   The format to use to make the request.
+   *
+   * @return \Psr\Http\Message\ResponseInterface The HTTP response.
+   *   The HTTP response.
+   */
+  protected function loginRequest($name, $pass, $format = 'json') {
+    $user_login_url = Url::fromRoute('user.login.http')
+      ->setRouteParameter('_format', $format)
+      ->setAbsolute();
+
+    $request_body = [];
+    if (isset($name)) {
+      $request_body['name'] = $name;
+    }
+    if (isset($pass)) {
+      $request_body['pass'] = $pass;
+    }
+
+    $result = \Drupal::httpClient()->post($user_login_url->toString(), [
+      'body' => $this->encode($request_body, $format),
+      'headers' => [
+        'Accept' => "application/$format",
+      ],
+      'http_errors' => FALSE,
+      'cookies' => $this->cookies,
+    ]);
+    return $result;
+  }
+
+  /**
+   * Tests user session life cycle.
+   */
+  public function testLogin() {
+    $client = \Drupal::httpClient();
+    foreach ([FALSE, TRUE] as $serialization_enabled_option) {
+      if ($serialization_enabled_option) {
+        /** @var \Drupal\Core\Extension\ModuleInstaller $module_installer */
+        $module_installer = \Drupal::service('module_installer');
+        $module_installer->install(['serialization']);
+        $formats = ['json', 'xml'];
+      }
+      else {
+        // Without the serialization module only JSON is supported.
+        $formats = ['json'];
+      }
+      foreach ($formats as $format) {
+        // Create new user for each iteration to reset flood.
+        $account = $this->drupalCreateUser();
+        $name = $account->getUsername();
+        $pass = $account->passRaw;
+
+        $user_login_status_url = Url::fromRoute('user.login_status.http');
+        $user_login_status_url->setRouteParameter('_format', $format);
+        $user_login_status_url->setAbsolute();
+
+        $response = $client->post($user_login_status_url->toString());
+        $this->assertResponse($response, 200, UserAuthenticationController::LOGGED_OUT);
+
+        // Flooded.
+        $this->config('user.flood')
+          ->set('user_limit', 3)
+          ->save();
+
+        $response = $this->loginRequest($name, 'wrong-pass', $format);
+        $this->assertResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
+
+        $response = $this->loginRequest($name, 'wrong-pass', $format);
+        $this->assertResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
+
+        $response = $this->loginRequest($name, 'wrong-pass', $format);
+        $this->assertResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
+
+        $response = $this->loginRequest($name, 'wrong-pass', $format);
+        $this->assertResponseWithMessage($response, 403, 'Too many failed login attempts from your IP address. This IP address is temporarily blocked.', $format);
+
+        // After testing the flood control we can increase the limit.
+        $this->config('user.flood')
+          ->set('user_limit', 100)
+          ->save();
+
+        $response = $this->loginRequest(NULL, NULL, $format);
+        $this->assertResponseWithMessage($response, 400, 'Missing credentials.', $format);
+
+        $response = $this->loginRequest(NULL, $pass, $format);
+        $this->assertResponseWithMessage($response, 400, 'Missing credentials.name.', $format);
+
+        $response = $this->loginRequest($name, NULL, $format);
+        $this->assertResponseWithMessage($response, 400, 'Missing credentials.pass.', $format);
+
+        // Blocked.
+        $account
+          ->block()
+          ->save();
+
+        $response = $this->loginRequest($name, $pass, $format);
+        $this->assertResponseWithMessage($response, 400, 'The user has not been activated or is blocked.', $format);
+
+        $account
+          ->activate()
+          ->save();
+
+        $response = $this->loginRequest($name, 'garbage', $format);
+        $this->assertResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
+
+        $response = $this->loginRequest('garbage', $pass, $format);
+        $this->assertResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
+
+        $response = $this->loginRequest($name, $pass, $format);
+        $this->assertEquals(200, $response->getStatusCode());
+        $result_data = $this->decode($response->getBody(), $format);
+        $this->assertEquals($name, $result_data['current_user']['name']);
+
+        $response = $client->post($user_login_status_url->toString(), ['cookies' => $this->cookies]);
+        $this->assertEquals(200, $response->getStatusCode());
+        $this->assertEquals(UserAuthenticationController::LOGGED_IN, (string) $response->getBody());
+
+        $response = $this->logoutRequest($format);
+        $this->assertEquals(204, $response->getStatusCode());
+
+        $response = $client->post($user_login_status_url->toString(), ['cookies' => $this->cookies]);
+        $this->assertResponse($response, 200, UserAuthenticationController::LOGGED_OUT);
+
+        $this->resetFlood();
+      }
+    }
+  }
+
+  /**
+   * Encodes data for a request into a given format.
+   *
+   * @param mixed $data
+   *   The data to be encoded.
+   * @param string $format
+   *   The format to be encoded into.
+   *
+   * @return mixed
+   *   Encoded data.
+   */
+  protected function encode($data, $format) {
+    return $this->serializer->encode($data, $format);
+  }
+
+  /**
+   * Decodes data for a request from a given format.
+   *
+   * @param mixed $data
+   *   The data to be decoded.
+   * @param string $format
+   *   The format to be decoded from.
+   *
+   * @return mixed
+   *   Decoded data.
+   */
+  protected function decode($data, $format) {
+    return $this->serializer->decode($data, $format);
+  }
+
+  /**
+   * Gets a value for a given key from the response.
+   *
+   * @param \Psr\Http\Message\ResponseInterface $response
+   *   The response object.
+   * @param string $key
+   *   The key for the value.
+   * @param string $format
+   *   The encoded format.
+   *
+   * @return mixed
+   *   The value for the key.
+   */
+  protected function getResultValue(ResponseInterface $response, $key, $format) {
+    $decoded = $this->decode((string) $response->getBody(), $format);
+    if (is_array($decoded)) {
+      return $decoded[$key];
+    }
+    else {
+      return $decoded->{$key};
+    }
+  }
+
+  /**
+   * Resets all flood entries.
+   */
+  protected function resetFlood() {
+    \Drupal::database()->delete(DatabaseBackend::TABLE_NAME)->execute();
+  }
+
+  /**
+   * Tests the global login flood control.
+   *
+   * @see \Drupal\basic_auth\Tests\Authentication\BasicAuthTest::testGlobalLoginFloodControl
+   * @see \Drupal\user\Tests\UserLoginTest::testGlobalLoginFloodControl
+   */
+  public function testGlobalLoginFloodControl() {
+    $this->config('user.flood')
+      ->set('ip_limit', 2)
+      // Set a high per-user limit out so that it is not relevant in the test.
+      ->set('user_limit', 4000)
+      ->save();
+
+    $user = $this->drupalCreateUser(array());
+    $incorrect_user = clone $user;
+    $incorrect_user->passRaw .= 'incorrect';
+
+    // Try 2 failed logins.
+    for ($i = 0; $i < 2; $i++) {
+      $response = $this->loginRequest($incorrect_user->getUsername(), $incorrect_user->passRaw);
+      $this->assertEquals('400', $response->getStatusCode());
+    }
+
+    // IP limit has reached to its limit. Even valid user credentials will fail.
+    $response = $this->loginRequest($user->getUsername(), $user->passRaw);
+    $this->assertResponseWithMessage($response, '403', 'Access is blocked because of IP based flood prevention.');
+  }
+
+  /**
+   * Returns an immutable configuration object for a given name.
+   *
+   * @param string $config_name
+   *   The configuration name.
+   *
+   * @return \Drupal\Core\Config\Config
+   *   The editable configuration object.
+   */
+  protected function config($config_name) {
+    return \Drupal::configFactory()->getEditable($config_name);
+  }
+
+  /**
+   * Checks a response for status code and body.
+   *
+   * @param \Psr\Http\Message\ResponseInterface $response
+   *   The response object.
+   * @param int $expected_code
+   *   The expected status code.
+   * @param mixed $expected_body
+   *   The expected response body.
+   */
+  protected function assertResponse(ResponseInterface $response, $expected_code, $expected_body) {
+    $this->assertEquals($expected_code, $response->getStatusCode());
+    $this->assertEquals($expected_body, (string) $response->getBody());
+  }
+
+  /**
+   * Checks a response for status code and message.
+   *
+   * @param \Psr\Http\Message\ResponseInterface $response
+   *   The response object.
+   * @param int $expected_code
+   *   The expected status code.
+   * @param string $expected_message
+   *   The expected message encoded in response.
+   * @param string $format
+   *   The format that the response is encoded in.
+   */
+  protected function assertResponseWithMessage(ResponseInterface $response, $expected_code, $expected_message, $format = 'json') {
+    $this->assertEquals($expected_code, $response->getStatusCode());
+    $this->assertEquals($expected_message, $this->getResultValue($response, 'message', $format));
+  }
+
+  /**
+   * Test the per-user login flood control.
+   *
+   * @see \Drupal\user\Tests\UserLoginTest::testPerUserLoginFloodControl
+   * @see \Drupal\basic_auth\Tests\Authentication\BasicAuthTest::testPerUserLoginFloodControl
+   */
+  public function testPerUserLoginFloodControl() {
+    foreach ([TRUE, FALSE] as $uid_only_setting) {
+      $this->config('user.flood')
+        // Set a high global limit out so that it is not relevant in the test.
+        ->set('ip_limit', 4000)
+        ->set('user_limit', 3)
+        ->set('uid_only', $uid_only_setting)
+        ->save();
+
+      $user1 = $this->drupalCreateUser(array());
+      $incorrect_user1 = clone $user1;
+      $incorrect_user1->passRaw .= 'incorrect';
+
+      $user2 = $this->drupalCreateUser(array());
+
+      // Try 2 failed logins.
+      for ($i = 0; $i < 2; $i++) {
+        $response = $this->loginRequest($incorrect_user1->getUsername(), $incorrect_user1->passRaw);
+        $this->assertResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.');
+      }
+
+      // A successful login will reset the per-user flood control count.
+      $this->loginRequest($user1->getUsername(), $user1->passRaw);
+      $this->logoutRequest();
+
+      // Try 3 failed logins for user 1, they will not trigger flood control.
+      for ($i = 0; $i < 3; $i++) {
+        $response = $this->loginRequest($incorrect_user1->getUsername(), $incorrect_user1->passRaw);
+        $this->assertResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.');
+      }
+
+      // Try one successful attempt for user 2, it should not trigger any
+      // flood control.
+      $this->drupalLogin($user2);
+      $this->drupalLogout();
+
+      // Try one more attempt for user 1, it should be rejected, even if the
+      // correct password has been used.
+      $response = $this->loginRequest($user1->getUsername(), $user1->passRaw);
+      // Depending on the uid_only setting the error message will be different.
+      if ($uid_only_setting) {
+        $excepted_message = 'There have been more than 3 failed login attempts for this account. It is temporarily blocked. Try again later or request a new password.';
+      }
+      else {
+        $excepted_message = 'Too many failed login attempts from your IP address. This IP address is temporarily blocked.';
+      }
+      $this->assertResponseWithMessage($response, 403, $excepted_message);
+    }
+
+  }
+
+  /**
+   * Executes a logout HTTP request.
+   *
+   * @param string $format
+   *   The format to use to make the request.
+   *
+   * @return \Psr\Http\Message\ResponseInterface The HTTP response.
+   *   The HTTP response.
+   */
+  protected function logoutRequest($format = 'json') {
+    $client = \Drupal::httpClient();
+    $user_logout_url = Url::fromRoute('user.logout.http')
+      ->setRouteParameter('_format', $format)
+      ->setAbsolute();
+    $response = $client->post($user_logout_url->toString(), [
+      'headers' => [
+        'Accept' => "application/$format",
+      ],
+      'http_errors' => FALSE,
+      'cookies' => $this->cookies,
+    ]);
+    return $response;
+  }
+
+}
diff --git a/core/modules/user/user.routing.yml b/core/modules/user/user.routing.yml
index 6eea7ec..09ac772 100644
--- a/core/modules/user/user.routing.yml
+++ b/core/modules/user/user.routing.yml
@@ -129,6 +129,33 @@ user.login:
   options:
     _maintenance_access: TRUE
 
+user.login.http:
+  path: '/user/login'
+  defaults:
+    _controller: \Drupal\user\Controller\UserAuthenticationController::login
+  methods: [POST]
+  requirements:
+    _user_is_logged_in: 'FALSE'
+    _format: 'json'
+
+user.login_status.http:
+  path: '/user/login_status'
+  defaults:
+    _controller: \Drupal\user\Controller\UserAuthenticationController::loginStatus
+  methods: [POST]
+  requirements:
+    _access: 'TRUE'
+    _format: 'json'
+
+user.logout.http:
+  path: '/user/logout'
+  defaults:
+    _controller: \Drupal\user\Controller\UserAuthenticationController::logout
+  methods: [POST]
+  requirements:
+    _user_is_logged_in: 'TRUE'
+    _format: 'json'
+
 user.cancel_confirm:
   path: '/user/{user}/cancel/confirm/{timestamp}/{hashed_pass}'
   defaults:
