diff --git a/config/vufind/config.ini b/config/vufind/config.ini
index 728a9ff30beee58d4317f345b3c953288d60e6c1..518994e61873b5bf99b3f05b1a7e890ddb55cfa3 100644
--- a/config/vufind/config.ini
+++ b/config/vufind/config.ini
@@ -381,6 +381,10 @@ force_first_scheduled_email = false
 ; also supported as the primary authentication mechanism for some ILS drivers (e.g.
 ; Alma). In these cases, ChoiceAuth is not needed, and ILS should be configured as
 ; the Authentication method; see the ILS driver's configuration for possible options.
+;
+; Also note that the Email method stores hashes in your database's auth_hash table.
+; You should run the "php $VUFIND_HOME/public/index.php util expire_auth_hashes"
+; utility periodically to clean out old data in this table.
 [Authentication]
 ;method          = LDAP
 ;method         = ILS
diff --git a/module/VuFind/config/module.config.php b/module/VuFind/config/module.config.php
index fc4f62739148ce618f688d5ab9e098b089a681e2..b0e499d1418fb9ab1934fb13054fcbd98190417b 100644
--- a/module/VuFind/config/module.config.php
+++ b/module/VuFind/config/module.config.php
@@ -430,6 +430,7 @@ $config = [
             'VuFindHttp\HttpService' => 'VuFind\Service\HttpServiceFactory',
             'VuFindSearch\Service' => 'VuFind\Service\SearchServiceFactory',
             'Zend\Db\Adapter\Adapter' => 'VuFind\Db\AdapterFactory',
+            'Zend\Http\PhpEnvironment\RemoteAddress' => 'VuFind\Http\PhpEnvironment\RemoteAddressFactory',
             'Zend\Mvc\I18n\Translator' => 'VuFind\I18n\Translator\TranslatorFactory',
             'Zend\Session\SessionManager' => 'VuFind\Session\ManagerFactory',
         ],
@@ -526,6 +527,7 @@ $config = [
         'config_reader' => [ /* see VuFind\Config\PluginManager for defaults */ ],
         // PostgreSQL sequence mapping
         'pgsql_seq_mapping'  => [
+            'auth_hash'        => ['id', 'auth_hash_id_seq'],
             'comments'         => ['id', 'comments_id_seq'],
             'external_session' => ['id', 'external_session_id_seq'],
             'oai_resumption'   => ['id', 'oai_resumption_id_seq'],
diff --git a/module/VuFind/sql/migrations/pgsql/6.1/004-add-auth-hash-table.sql b/module/VuFind/sql/migrations/pgsql/6.1/004-add-auth-hash-table.sql
new file mode 100644
index 0000000000000000000000000000000000000000..5898a0ebc3eab531d5aa76e1915812090b60de51
--- /dev/null
+++ b/module/VuFind/sql/migrations/pgsql/6.1/004-add-auth-hash-table.sql
@@ -0,0 +1,15 @@
+--
+-- Table structure for table auth_hash
+--
+
+CREATE TABLE auth_hash (
+id BIGSERIAL,
+session_id varchar(128),
+hash varchar(255),
+type varchar(50),
+data text,
+created timestamp NOT NULL default '1970-01-01 00:00:00',
+PRIMARY KEY (id),
+UNIQUE (hash, type)
+);
+CREATE INDEX auth_hash_created_idx on auth_hash(created);
diff --git a/module/VuFind/sql/mysql.sql b/module/VuFind/sql/mysql.sql
index e92f516a87efd8a8fe2e66e505b3d27197fe700b..d4e1340ae7be69e65246a1f6b1cc25e4ab99b1c3 100644
--- a/module/VuFind/sql/mysql.sql
+++ b/module/VuFind/sql/mysql.sql
@@ -322,3 +322,24 @@ CREATE TABLE `record` (
   UNIQUE KEY `record_id_source` (`record_id`, `source`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
 /*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `auth_hash`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!40101 SET character_set_client = utf8 */;
+CREATE TABLE `auth_hash` (
+  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
+  `session_id` varchar(128) DEFAULT NULL,
+  `hash` varchar(255) NOT NULL DEFAULT '',
+  `type` varchar(50) DEFAULT NULL,
+  `data` mediumtext,
+  `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `session_id` (`session_id`),
+  UNIQUE KEY `hash_type` (`hash`, `type`),
+  KEY `created` (`created`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
diff --git a/module/VuFind/sql/pgsql.sql b/module/VuFind/sql/pgsql.sql
index 744cc0f6bb5277cd7c424c7bb6ea9fa0cdd4f1c5..39abd1cbc298764d4e2d35c13e6a6ecd528f37b7 100644
--- a/module/VuFind/sql/pgsql.sql
+++ b/module/VuFind/sql/pgsql.sql
@@ -306,6 +306,24 @@ CONSTRAINT user_card_ibfk_1 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELE
 CREATE INDEX user_card_cat_username_idx ON user_card (cat_username);
 CREATE INDEX user_card_user_id_idx ON user_card (user_id);
 
+--
+-- Table structure for table auth_hash
+--
+
+DROP TABLE IF EXISTS "auth_hash";
+
+CREATE TABLE auth_hash (
+id BIGSERIAL,
+session_id varchar(128),
+hash varchar(255),
+type varchar(50),
+data text,
+created timestamp NOT NULL default '1970-01-01 00:00:00',
+PRIMARY KEY (id),
+UNIQUE (hash, type)
+);
+CREATE INDEX auth_hash_created_idx on auth_hash(created);
+
 -- --------------------------------------------------------
 
 --
diff --git a/module/VuFind/src/VuFind/Auth/ChoiceAuth.php b/module/VuFind/src/VuFind/Auth/ChoiceAuth.php
index 43b5ed0174576e2456b7413f1666d486ee5eb943..adbbeb42d4a04460937777b24ca20daaff796f83 100644
--- a/module/VuFind/src/VuFind/Auth/ChoiceAuth.php
+++ b/module/VuFind/src/VuFind/Auth/ChoiceAuth.php
@@ -337,6 +337,20 @@ class ChoiceAuth extends AbstractBase
         return $this->proxyAuthMethod('getDelegateAuthMethod', func_get_args());
     }
 
+    /**
+     * Is the configured strategy on the list of legal options?
+     *
+     * @return bool
+     */
+    protected function hasLegalStrategy()
+    {
+        // Do a case-insensitive search of the strategy list:
+        return in_array(
+            strtolower($this->strategy),
+            array_map('strtolower', $this->strategies)
+        );
+    }
+
     /**
      * Proxy auth method; a helper function to be called like:
      *   return $this->proxyAuthMethod(METHOD, func_get_args());
@@ -354,7 +368,7 @@ class ChoiceAuth extends AbstractBase
             return false;
         }
 
-        if (!in_array($this->strategy, $this->strategies)) {
+        if (!$this->hasLegalStrategy()) {
             throw new InvalidArgumentException("Illegal setting: {$this->strategy}");
         }
         $authenticator = $this->getPluginManager()->get($this->strategy);
@@ -434,4 +448,21 @@ class ChoiceAuth extends AbstractBase
         }
         return isset($user) && $user instanceof User;
     }
+
+    /**
+     * Whether this authentication method needs CSRF checking for the request.
+     *
+     * @param \Zend\Http\PhpEnvironment\Request $request Request object.
+     *
+     * @return bool
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function needsCsrfCheck($request)
+    {
+        if (!$this->strategy) {
+            return true;
+        }
+        return $this->proxyAuthMethod('needsCsrfCheck', func_get_args());
+    }
 }
diff --git a/module/VuFind/src/VuFind/Auth/Email.php b/module/VuFind/src/VuFind/Auth/Email.php
index 3d5725feb6ec10a7f92f54a6cf0440ab6b79313d..9c9e9a0ee7d04f8af008182e2402be458688a722 100644
--- a/module/VuFind/src/VuFind/Auth/Email.php
+++ b/module/VuFind/src/VuFind/Auth/Email.php
@@ -86,8 +86,11 @@ class Email extends AbstractBase
                 $loginData = [
                     'vufind_id' => $user['id']
                 ];
-                $this->emailAuthenticator
-                    ->sendAuthenticationLink($user['email'], $loginData);
+                $this->emailAuthenticator->sendAuthenticationLink(
+                    $user['email'],
+                    $loginData,
+                    ['auth_method' => 'email']
+                );
             }
             // Don't reveal the result
             throw new \VuFind\Exception\AuthInProgress('email_login_link_sent');
diff --git a/module/VuFind/src/VuFind/Auth/EmailAuthenticator.php b/module/VuFind/src/VuFind/Auth/EmailAuthenticator.php
index 2fe718eb32e918e7de4fdacb28aea06abd8c5896..19f1faa775e9c54f9cb1c593488ab8054c52965f 100644
--- a/module/VuFind/src/VuFind/Auth/EmailAuthenticator.php
+++ b/module/VuFind/src/VuFind/Auth/EmailAuthenticator.php
@@ -27,7 +27,9 @@
  */
 namespace VuFind\Auth;
 
+use VuFind\DB\Table\AuthHash as AuthHashTable;
 use VuFind\Exception\Auth as AuthException;
+use Zend\Http\PhpEnvironment\RemoteAddress;
 
 /**
  * Class for managing email-based authentication.
@@ -74,11 +76,11 @@ class EmailAuthenticator implements \VuFind\I18n\Translator\TranslatorAwareInter
     protected $viewRenderer = null;
 
     /**
-     * Request
+     * Remote address
      *
-     * @var \Zend\Stdlib\RequestInterface
+     * @var RemoteAddress
      */
-    protected $request;
+    protected $remoteAddress;
 
     /**
      * Configuration
@@ -94,6 +96,13 @@ class EmailAuthenticator implements \VuFind\I18n\Translator\TranslatorAwareInter
      */
     protected $loginRequestValidTime = 600;
 
+    /**
+     * Database table for authentication hashes
+     *
+     * @var AuthHashTable
+     */
+    protected $authHashTable;
+
     /**
      * Constructor
      *
@@ -101,21 +110,23 @@ class EmailAuthenticator implements \VuFind\I18n\Translator\TranslatorAwareInter
      * @param \VuFind\Validator\Csrf                $csrf         CSRF Validator
      * @param \VuFind\Mailer\Mailer                 $mailer       Mailer
      * @param \Zend\View\Renderer\RendererInterface $viewRenderer View Renderer
-     * @param \Zend\Stdlib\RequestInterface         $request      Request
+     * @param RemoteAddress                         $remoteAddr   Remote address
      * @param \Zend\Config\Config                   $config       Configuration
+     * @param AuthHashTable                         $authHash     AuthHash Table
      */
     public function __construct(\Zend\Session\SessionManager $session,
         \VuFind\Validator\Csrf $csrf, \VuFind\Mailer\Mailer $mailer,
         \Zend\View\Renderer\RendererInterface $viewRenderer,
-        \Zend\Stdlib\RequestInterface $request,
-        \Zend\Config\Config $config
+        RemoteAddress $remoteAddr,
+        \Zend\Config\Config $config, AuthHashTable $authHash
     ) {
         $this->sessionManager = $session;
         $this->csrf = $csrf;
         $this->mailer = $mailer;
         $this->viewRenderer = $viewRenderer;
-        $this->request = $request;
+        $this->remoteAddress = $remoteAddr;
         $this->config = $config;
+        $this->authHashTable = $authHash;
     }
 
     /**
@@ -138,14 +149,14 @@ class EmailAuthenticator implements \VuFind\I18n\Translator\TranslatorAwareInter
         $subject = 'email_login_subject',
         $template = 'Email/login-link.phtml'
     ) {
-        $sessionContainer = $this->getSessionContainer();
-
         // Make sure we've waited long enough
         $recoveryInterval = isset($this->config->Authentication->recover_interval)
             ? $this->config->Authentication->recover_interval
             : 60;
-        if (null !== $sessionContainer->timestamp
-            && time() - $sessionContainer->timestamp < $recoveryInterval
+        $sessionId = $this->sessionManager->getId();
+
+        if (($row = $this->authHashTable->getLatestBySessionId($sessionId))
+            && time() - strtotime($row['created']) < $recoveryInterval
         ) {
             throw new AuthException('authentication_error_in_progress');
         }
@@ -154,14 +165,17 @@ class EmailAuthenticator implements \VuFind\I18n\Translator\TranslatorAwareInter
         $linkData = [
             'timestamp' => time(),
             'data' => $data,
-            'email' => $email
+            'email' => $email,
+            'ip' => $this->remoteAddress->getIpAddress()
         ];
         $hash = $this->csrf->getHash(true);
 
-        if (!isset($sessionContainer->requests)) {
-            $sessionContainer->requests = [];
-        }
-        $sessionContainer->requests[$hash] = $linkData;
+        $row = $this->authHashTable
+            ->getByHashAndType($hash, AuthHashTable::TYPE_EMAIL);
+
+        $row['session_id'] = $sessionId;
+        $row['data'] = json_encode($linkData);
+        $row->save();
 
         $serverHelper = $this->viewRenderer->plugin('serverurl');
         $urlHelper = $this->viewRenderer->plugin('url');
@@ -192,14 +206,23 @@ class EmailAuthenticator implements \VuFind\I18n\Translator\TranslatorAwareInter
      */
     public function authenticate($hash)
     {
-        $sessionContainer = $this->getSessionContainer();
+        $row = $this->authHashTable
+            ->getByHashAndType($hash, AuthHashTable::TYPE_EMAIL, false);
+        if (!$row) {
+            throw new AuthException('authentication_error_denied');
+        }
+        $linkData = json_decode($row['data'], true);
+        $row->delete();
 
-        if (!isset($sessionContainer->requests[$hash])) {
+        if (time() - strtotime($row['created']) > $this->loginRequestValidTime) {
             throw new AuthException('authentication_error_denied');
         }
-        $linkData = $sessionContainer->requests[$hash];
-        unset($sessionContainer->requests[$hash]);
-        if (time() - $linkData['timestamp'] > $this->loginRequestValidTime) {
+
+        // Require same session id or IP address:
+        $sessionId = $this->sessionManager->getId();
+        if ($row['session_id'] !== $sessionId
+            && $linkData['ip'] !== $this->remoteAddress->getIpAddress()
+        ) {
             throw new AuthException('authentication_error_denied');
         }
 
@@ -220,19 +243,10 @@ class EmailAuthenticator implements \VuFind\I18n\Translator\TranslatorAwareInter
             $request->getQuery()->get('hash', '')
         );
         if ($hash) {
-            $sessionContainer = $this->getSessionContainer();
-            return isset($sessionContainer->requests[$hash]);
+            $row = $this->authHashTable
+                ->getByHashAndType($hash, AuthHashTable::TYPE_EMAIL, false);
+            return !empty($row);
         }
         return false;
     }
-
-    /**
-     * Get the session container
-     *
-     * @return \Zend\Session\Container
-     */
-    protected function getSessionContainer()
-    {
-        return new \Zend\Session\Container('EmailAuth', $this->sessionManager);
-    }
 }
diff --git a/module/VuFind/src/VuFind/Auth/EmailAuthenticatorFactory.php b/module/VuFind/src/VuFind/Auth/EmailAuthenticatorFactory.php
index 150b8c7d701430e6999dcfbf5183a92ad034b847..2cfa4abb87974cf576e2e7d3716b194bdd96a2b9 100644
--- a/module/VuFind/src/VuFind/Auth/EmailAuthenticatorFactory.php
+++ b/module/VuFind/src/VuFind/Auth/EmailAuthenticatorFactory.php
@@ -66,8 +66,10 @@ class EmailAuthenticatorFactory
             $container->get(\VuFind\Validator\Csrf::class),
             $container->get(\VuFind\Mailer\Mailer::class),
             $container->get('ViewRenderer'),
-            $container->get('Request'),
-            $container->get(\VuFind\Config\PluginManager::class)->get('config')
+            $container->get(\Zend\Http\PhpEnvironment\RemoteAddress::class),
+            $container->get(\VuFind\Config\PluginManager::class)->get('config'),
+            $container->get(\VuFind\Db\Table\PluginManager::class)
+                ->get(\VuFind\Db\Table\AuthHash::class)
         );
     }
 }
diff --git a/module/VuFind/src/VuFind/Db/Row/AuthHash.php b/module/VuFind/src/VuFind/Db/Row/AuthHash.php
new file mode 100644
index 0000000000000000000000000000000000000000..265338271e773236abee5013156362ac7dd3a213
--- /dev/null
+++ b/module/VuFind/src/VuFind/Db/Row/AuthHash.php
@@ -0,0 +1,53 @@
+<?php
+/**
+ * Row Definition for auth_hash
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2010.
+ * Copyright (C) The National Library of Finland 2019.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Db_Row
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Site
+ */
+namespace VuFind\Db\Row;
+
+/**
+ * Row Definition for auth_hash
+ *
+ * @category VuFind
+ * @package  Db_Row
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Site
+ */
+class AuthHash extends RowGateway
+{
+    /**
+     * Constructor
+     *
+     * @param \Zend\Db\Adapter\Adapter $adapter Database adapter
+     */
+    public function __construct($adapter)
+    {
+        parent::__construct('id', 'auth_hash', $adapter);
+    }
+}
diff --git a/module/VuFind/src/VuFind/Db/Row/PluginManager.php b/module/VuFind/src/VuFind/Db/Row/PluginManager.php
index 5b402dec18fd5b2a14cc207acfd62c2a5f5930df..02d8d0fb4dbc899d3b49cf15216a159edfe4f593 100644
--- a/module/VuFind/src/VuFind/Db/Row/PluginManager.php
+++ b/module/VuFind/src/VuFind/Db/Row/PluginManager.php
@@ -67,6 +67,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
      * @var array
      */
     protected $factories = [
+        AuthHash::class => RowGatewayFactory::class,
         ChangeTracker::class => RowGatewayFactory::class,
         Comments::class => RowGatewayFactory::class,
         ExternalSession::class => RowGatewayFactory::class,
diff --git a/module/VuFind/src/VuFind/Db/Table/AuthHash.php b/module/VuFind/src/VuFind/Db/Table/AuthHash.php
new file mode 100644
index 0000000000000000000000000000000000000000..0918b0aa60ad04190a658dd556ea1fc136da4c05
--- /dev/null
+++ b/module/VuFind/src/VuFind/Db/Table/AuthHash.php
@@ -0,0 +1,145 @@
+<?php
+/**
+ * Table Definition for auth_hash
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2010.
+ * Copyright (C) The National Library of Finland 2019.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Db_Table
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Page
+ */
+namespace VuFind\Db\Table;
+
+use VuFind\Db\Row\RowGateway;
+use Zend\Db\Adapter\Adapter;
+
+/**
+ * Table Definition for auth_hash
+ *
+ * @category VuFind
+ * @package  Db_Table
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Site
+ */
+class AuthHash extends Gateway
+{
+    use ExpirationTrait;
+
+    const TYPE_EMAIL = 'email'; // EmailAuthenticator
+
+    /**
+     * Constructor
+     *
+     * @param Adapter       $adapter Database adapter
+     * @param PluginManager $tm      Table manager
+     * @param array         $cfg     Zend Framework configuration
+     * @param RowGateway    $rowObj  Row prototype object (null for default)
+     * @param string        $table   Name of database table to interface with
+     */
+    public function __construct(Adapter $adapter, PluginManager $tm, $cfg,
+        RowGateway $rowObj = null, $table = 'auth_hash'
+    ) {
+        parent::__construct($adapter, $tm, $cfg, $rowObj, $table);
+    }
+
+    /**
+     * Retrieve an object from the database based on hash and type; create a new
+     * row if no existing match is found.
+     *
+     * @param string $hash   Hash
+     * @param string $type   Hash type
+     * @param bool   $create Should we create rows that don't already exist?
+     *
+     * @return \VuFind\Db\Row\AuthHash
+     */
+    public function getByHashAndType($hash, $type, $create = true)
+    {
+        $row = $this->select(['hash' => $hash, 'type' => $type])->current();
+        if ($create && empty($row)) {
+            $row = $this->createRow();
+            $row->hash = $hash;
+            $row->type = $type;
+            $row->created = date('Y-m-d H:i:s');
+        }
+        return $row;
+    }
+
+    /**
+     * Retrieve last object from the database based on session id.
+     *
+     * @param string $sessionId Session ID
+     *
+     * @return \VuFind\Db\Row\AuthHash
+     */
+    public function getLatestBySessionId($sessionId)
+    {
+        $callback = function ($select) use ($sessionId) {
+            $select->where->equalTo('session_id', $sessionId);
+            $select->order('created DESC');
+        };
+        return $this->select($callback)->current();
+    }
+
+    /**
+     * Get a query representing expired sessions (this can be passed
+     * to select() or delete() for further processing).
+     *
+     * @param int $daysOld Age in days of an "expired" session.
+     *
+     * @return function
+     */
+    public function getExpiredQuery($daysOld = 2)
+    {
+        // Determine the expiration date:
+        $expireDate = time() - $daysOld * 24 * 60 * 60;
+        $callback = function ($select) use ($expireDate) {
+            $select->where->lessThan('created', date('Y-m-d H:i:s', $expireDate));
+        };
+        return $callback;
+    }
+
+    /**
+     * Update the select statement to find records to delete.
+     *
+     * @param Select $select  Select clause
+     * @param int    $daysOld Age in days of an "expired" record.
+     * @param int    $idFrom  Lowest id of rows to delete.
+     * @param int    $idTo    Highest id of rows to delete.
+     *
+     * @return void
+     */
+    protected function expirationCallback($select, $daysOld, $idFrom = null,
+        $idTo = null
+    ) {
+        $expireDate = time() - $daysOld * 24 * 60 * 60;
+        $where = $select->where
+            ->lessThan('created', date('Y-m-d H:i:s', $expireDate));
+        if (null !== $idFrom) {
+            $where->and->greaterThanOrEqualTo('id', $idFrom);
+        }
+        if (null !== $idTo) {
+            $where->and->lessThanOrEqualTo('id', $idTo);
+        }
+    }
+}
diff --git a/module/VuFind/src/VuFind/Db/Table/PluginManager.php b/module/VuFind/src/VuFind/Db/Table/PluginManager.php
index e08e1e41276ab3cbd179ba235b16a137f213346b..761b62288d880f10df715bdf7ec9dfb4ad0f1b19 100644
--- a/module/VuFind/src/VuFind/Db/Table/PluginManager.php
+++ b/module/VuFind/src/VuFind/Db/Table/PluginManager.php
@@ -67,6 +67,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
      * @var array
      */
     protected $factories = [
+        AuthHash::class => GatewayFactory::class,
         ChangeTracker::class => GatewayFactory::class,
         Comments::class => GatewayFactory::class,
         ExternalSession::class => GatewayFactory::class,
diff --git a/module/VuFind/src/VuFind/Http/PhpEnvironment/RemoteAddressFactory.php b/module/VuFind/src/VuFind/Http/PhpEnvironment/RemoteAddressFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..8e113bf5a94c8e46e3c775e78e43f3c5bde9f307
--- /dev/null
+++ b/module/VuFind/src/VuFind/Http/PhpEnvironment/RemoteAddressFactory.php
@@ -0,0 +1,72 @@
+<?php
+/**
+ * RemoteAddress utility factory. This uses the core Zend RemoteAddress but
+ * configures it according to VuFind settings.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2019.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  View_Helpers
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\Http\PhpEnvironment;
+
+use Interop\Container\ContainerInterface;
+use Zend\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * RemoteAddress utility factory.
+ *
+ * @category VuFind
+ * @package  View_Helpers
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class RemoteAddressFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options sent to factory.');
+        }
+        $cfg = $container->get(\VuFind\Config\PluginManager::class)->get('config');
+        $object = new $requestedName();
+        if ($cfg->Site->reverse_proxy ?? false) {
+            $object->setUseProxy(true);
+        }
+        return $object;
+    }
+}
diff --git a/module/VuFindConsole/Module.php b/module/VuFindConsole/Module.php
index ae048d2d0d3d172806dfa2fe34180d0676f99a10..8a509e31dea80326a825dc5f3dc88da1bece8315 100644
--- a/module/VuFindConsole/Module.php
+++ b/module/VuFindConsole/Module.php
@@ -123,6 +123,7 @@ class Module implements \Zend\ModuleManager\Feature\ConsoleUsageProviderInterfac
             'util createHierarchyTrees' => 'Cache populator for hierarchies',
             'util cssBuilder' => 'LESS compiler',
             'util deletes' => 'Tool for deleting Solr records',
+            'util expire_auth_hashes' => 'Database auth_hash table cleanup',
             'util expire_external_sessions'
                 => 'Database external_session table cleanup',
             'util expire_searches' => 'Database search table cleanup',
diff --git a/module/VuFindConsole/config/module.config.php b/module/VuFindConsole/config/module.config.php
index 210192d576296ea2f3d4938220110705e1859108..b153af319f3e1d45c27f64e8ee18d372f26cff6d 100644
--- a/module/VuFindConsole/config/module.config.php
+++ b/module/VuFindConsole/config/module.config.php
@@ -76,6 +76,7 @@ $routes = [
     'util/createHierarchyTrees' => 'util createHierarchyTrees [--skip-xml|-sx] [--skip-json|-sj] [<backend>] [--help|-h]',
     'util/cssBuilder' => 'util cssBuilder [...themes]',
     'util/deletes' => 'util deletes [--verbose] [<filename>] [<format>] [<index>]',
+    'util/expire_auth_hashes' => 'util expire_auth_hashes [--help|-h] [--batch=] [--sleep=] [<daysOld>]',
     'util/expire_external_sessions' => 'util expire_external_sessions [--help|-h] [--batch=] [--sleep=] [<daysOld>]',
     'util/expire_searches' => 'util expire_searches [--help|-h] [--batch=] [--sleep=] [<daysOld>]',
     'util/expire_sessions' => 'util expire_sessions [--help|-h] [--batch=] [--sleep=] [<daysOld>]',
diff --git a/module/VuFindConsole/src/VuFindConsole/Controller/UtilController.php b/module/VuFindConsole/src/VuFindConsole/Controller/UtilController.php
index 2514ec0d605d175f3551d8fd0eae7ba390fd856a..644aa0fee48fdc2dcc8938266cec8a62284d6ffa 100644
--- a/module/VuFindConsole/src/VuFindConsole/Controller/UtilController.php
+++ b/module/VuFindConsole/src/VuFindConsole/Controller/UtilController.php
@@ -556,6 +556,26 @@ class UtilController extends AbstractBase
         );
     }
 
+    /**
+     * Command-line tool to clear unwanted entries
+     * from auth_hash database table.
+     *
+     * @return \Zend\Console\Response
+     */
+    public function expireauthhashesAction()
+    {
+        $request = $this->getRequest();
+        if ($request->getParam('help') || $request->getParam('h')) {
+            return $this->expirationHelp('authentication hashes');
+        }
+
+        return $this->expire(
+            \VuFind\Db\Table\AuthHash::class,
+            '%%count%% expired authentication hashes deleted.',
+            'No expired authentication hashes to delete.'
+        );
+    }
+
     /**
      * Command-line tool to delete suppressed records from the index.
      *