diff --git a/README.md b/README.md
index 3377edc20a35bc61180a860ebe9a7f1dd9b907cf..8b52cb314be65bc7d3b9f94e46daaff01a9d67dd 100644
--- a/README.md
+++ b/README.md
@@ -6,9 +6,50 @@ Heavily based on [Device cookie example](https://github.com/Rundiz/device-cookie
 ## Installation
 
 ```
-composer install php-device-cookies
+composer require cesnet/php-device-cookies
 ```
 
 ## Usage
 
 Use the provided `DeviceCookies` class.
+
+### Example
+
+```php
+$login = filter_input(INPUT_POST, 'login', FILTER_SANITIZE_EMAIL);
+$password = filter_input(INPUT_POST, 'password');
+
+if (!empty($login) && !empty($password)) {
+	$user = getUserByLogin($login);
+	$user_id = $user !== null ? $login : null;
+	$isPasswordCorrect = password_verify($password, $user->password);
+	$dbh = new PDO('dsn', 'username', 'password');
+
+	$deviceCookies = new DeviceCookies([
+	    'Dbh' => $dbh,
+	    'deviceCookieExpire' => 730, // The number of days that this cookie will be expired
+	    'maxAttempt' => 10, // max number of authentication attempts allowed during "time period"
+	    'secretKey' => 'SkfEED4aKrNWFUNqgqf6hrFsJQ6K6Jhh', // server’s secret cryptographic key
+	    'timePeriod' => 60, // time period (in seconds)
+	]);
+
+	$result = $deviceCookies->check($login, $user_id, $isPasswordCorrect);
+
+	switch ($result) {
+		case DeviceCookiesResult::SUCCESS:
+			echo 'Logged in';
+			break;
+		case DeviceCookiesResult::REJECT:
+		case DeviceCookiesResult::REJECT_VALID:
+			http_response_code(403);
+			exit;
+		case DeviceCookiesResult::WRONG_PASSWORD:
+		case DeviceCookiesResult::LOCKOUT:
+			http_response_code(401);
+			exit;
+	}
+}
+
+// This should be called from a cron job once a day.
+$deviceCookies->cleanup($dbh);
+```
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000000000000000000000000000000000000..16997183f4eacecfd8389c4802cbeb4f7899e6b7
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,13 @@
+{
+    "name": "cesnet/php-device-cookies",
+    "description": "Implementation of Device Cookies in PHP",
+    "authors": [
+        {
+            "name": "Pavel Břoušek",
+            "email": "brousek@ics.muni.cz"
+        }
+    ],
+    "require": {
+        "php": ">=7.1.0"
+    }
+}
diff --git a/db.sql b/db.sql
new file mode 100644
index 0000000000000000000000000000000000000000..1e240b528cef4c802b6bbc987f5c6d23fa26472d
--- /dev/null
+++ b/db.sql
@@ -0,0 +1,20 @@
+CREATE TABLE IF NOT EXISTS `user_devicecookie_failedattempts` (
+  `attempt_id` bigint(20) NOT NULL AUTO_INCREMENT,
+  `user_id` bigint(20) NOT NULL COMMENT 'refer to users.id',
+  `login` varchar(255) DEFAULT NULL,
+  `datetime` datetime DEFAULT current_timestamp() COMMENT 'failed authentication on date/time',
+  `devicecookie_nonce` varchar(50) DEFAULT NULL COMMENT 'device cookie NONCE (if present).',
+  `devicecookie_signature` longtext DEFAULT NULL COMMENT 'device cookie signature (if present).',
+  PRIMARY KEY (`attempt_id`),
+  KEY `user_id` (`user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT COMMENT='contain login failed attempt for existing users.';
+
+CREATE TABLE IF NOT EXISTS `user_devicecookie_lockout` (
+  `lockout_id` bigint(20) NOT NULL AUTO_INCREMENT,
+  `user_id` bigint(20) DEFAULT NULL COMMENT 'refer to users.id',
+  `devicecookie_nonce` varchar(50) DEFAULT NULL COMMENT 'device cookie NONCE.',
+  `devicecookie_signature` longtext DEFAULT NULL COMMENT 'device cookie signature.',
+  `lockout_untrusted_clients` int(1) NOT NULL DEFAULT 0 COMMENT '0=just lockout selected device cookie, 1=lockout all untrusted clients.',
+  `lockout_until` datetime DEFAULT NULL COMMENT 'lockout selected user (user_id) until date/time.',
+  PRIMARY KEY (`lockout_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT COMMENT='contain user account lockout.';
diff --git a/ecs.yaml b/ecs.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..891f2c0d83fb7e6dbbb067ebbd63213fe0aef4e3
--- /dev/null
+++ b/ecs.yaml
@@ -0,0 +1,8 @@
+parameters:
+    sets:
+      - 'clean-code'
+      - 'common'
+      - 'psr12'
+    skip:
+        PhpCsFixer\Fixer\Operator\NotOperatorWithSuccessorSpaceFixer: ~
+services:
diff --git a/src/DeviceCookieResult.php b/src/DeviceCookieResult.php
new file mode 100644
index 0000000000000000000000000000000000000000..a54768b6e4406d077b817a5fbaf52af5d05eeec2
--- /dev/null
+++ b/src/DeviceCookieResult.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace DeviceCookies;
+
+/**
+ * Device cookies check result.
+ */
+abstract class DeviceCookieResult
+{
+    /**
+     * Untrusted clients or invalid device cookie and is in lockout
+     */
+    public const REJECT = 'reject';
+
+    /**
+     * There is valid device cookie but entered wrong credentials too many attempts until gets lockout
+     */
+    public const REJECT_VALID = 'rejectvalid';
+
+    /**
+     * Able to continue authentication
+     */
+    public const AUTHENTICATE = 'authenticate';
+
+    /**
+     * Logged in successfully
+     */
+    public const SUCCESS = 'success';
+
+    /**
+     * Incorrect login or password (threshold has not been reached)
+     */
+    public const WRONG_PASSWORD = 'wrongpassword';
+
+    /**
+     * Incorrect login or password too many times resulted in lockout
+     */
+    public const LOCKOUT = 'lockout';
+}
diff --git a/src/DeviceCookies.php b/src/DeviceCookies.php
new file mode 100644
index 0000000000000000000000000000000000000000..6749161ad0d0880d7c946f78789b176296aad8b7
--- /dev/null
+++ b/src/DeviceCookies.php
@@ -0,0 +1,389 @@
+<?php
+
+namespace DeviceCookies;
+
+use DeviceCookies\Models\UserDeviceCookieFailedAttempts;
+use DeviceCookies\Models\UserDeviceCookieLockout;
+
+/**
+ * Device cookies class for help prevent brute-force attack.
+ * @see https://www.owasp.org/index.php/Slow_Down_Online_Guessing_Attacks_with_Device_Cookies
+ */
+class DeviceCookies
+{
+    /**
+     * @var \PDO
+     */
+    protected $Dbh;
+
+    /**
+     * @var string The name of device cookie.
+     */
+    protected $deviceCookieName = 'deviceCookie';
+
+    /**
+     * @var int The number of days that this cookie will be expired.
+     */
+    protected $deviceCookieExpire = 730;
+
+    /**
+     * @var int Current failed attempts with in time period.
+     */
+    protected $currentFailedAttempt = 0;
+
+    /**
+     * @var array|null Contain lockout result object from `$UserDeviceCookieLockout->isInLockoutList()` method.
+     */
+    protected $lockoutResult;
+
+    /**
+     * @var int Max number of authentication attempts allowed during "time period".
+     */
+    protected $maxAttempt = 10;
+
+    /**
+     * @var string Server’s secret cryptographic key.
+     */
+    protected $secretKey = 'SkfEED4aKrNWFUNqgqf6hrFsJQ6K6Jhh';
+
+    /**
+     * @var int Time period (in seconds).
+     */
+    protected $timePeriod = 60;
+
+    /**
+     * Class constructor.
+     *
+     * @param array $options The options in associative array format.
+     */
+    public function __construct(array $options)
+    {
+        foreach ($options as $option => $value) {
+            if (property_exists($this, $option)) {
+                $this->{$option} = $value;
+            }
+        }// endforeach;
+        unset($option, $value);
+    }
+
+    /**
+     * Check for brute force.
+     * @return DeviceCookiesResult What to do.
+     */
+    public function check(string $login, ?string $userId, bool $isPasswordCorrect)
+    {
+        $entrypoint = $this->checkEntryPoint($login, $userId);
+        if ($entrypoint !== DeviceCookiesResult::AUTHENTICATE) {
+            return $entrypoint;
+        }
+
+        return $this->checkAuthenticate($login, $isPasswordCorrect);
+    }
+
+    /**
+     * Garbage collection to remove old data that is no longer used from DB.
+     */
+    public function cleanup($dbh)
+    {
+        $sql = 'DELETE FROM `user_devicecookie_failedattempts` WHERE `datetime` < NOW() - INTERVAL :time_period SECOND';
+        $Sth = $dbh->prepare($sql);
+        $Sth->bindValue(':time_period', $this->timePeriod + 10);
+        $Sth->execute();
+        $affected1 = $Sth->rowCount();
+
+        $sql = 'DELETE FROM `user_devicecookie_lockout` WHERE `lockout_until` < NOW()';
+        $Sth = $dbh->prepare($sql);
+        $Sth->execute();
+        $affected2 = $Sth->rowCount();
+
+        return $affected1 + $affected2;
+    }
+
+    private function checkAuthenticate(string $login, ?string $userId, bool $isPasswordCorrect)
+    {
+        // 1. check user credentials
+        if ($isPasswordCorrect) {
+            // 2. if credentials are valid.
+            // a. issue new device cookie to user’s client
+            $this->issueNewDeviceCookie($login);
+            // b. proceed with authenticated user
+            $output = DeviceCookiesResult::SUCCESS;
+        } else {
+            // 3. else
+            // a. register failed authentication attempt
+            if ($userId !== null) {
+                $this->registerFailedAuth($login, $userId);
+            }
+            // b. finish with failed user’s authentication
+            if (
+                is_numeric($this->currentFailedAttempt) &&
+                $this->currentFailedAttempt > 0 &&
+                is_numeric($this->maxAttempt) &&
+                is_numeric($this->timePeriod)
+            ) {
+                $output = DeviceCookiesResult::LOCKOUT;
+            } else {
+                $output = DeviceCookiesResult::WRONG_PASSWORD;
+            }
+        }
+        return $output;
+    }
+
+    /**
+     * Entry point for authentication request
+     *
+     * @param string $login The login ID such as email.
+     * @return DeviceCookiesResult What to do next.
+     */
+    private function checkEntryPoint(string $login, ?string $userId): string
+    {
+        $UserDeviceCookieLockout = new UserDeviceCookieLockout($this->Dbh);
+        $output = '';
+        if ($this->hasDeviceCookie() === true) {
+            // 1. if the incoming request contains a device cookie.
+            // --- a. validate device cookie
+            $validateDeviceCookieResult = $this->validateDeviceCookie($login);
+            if ($validateDeviceCookieResult !== true) {
+                // b. if device cookie is not valid.
+                // proceed to step 2.
+                $this->removeDeviceCookie();
+                $step2 = true;
+            } elseif ($UserDeviceCookieLockout->isInLockoutList($this->getDeviceCookie()) === true) {
+                // c. if the device cookie is in the lockout list (valid but in lockout list).
+                // reject authentication attempt∎
+                $output = DeviceCookiesResult::REJECT_VALID;
+                $this->lockoutResult = $UserDeviceCookieLockout->getLockoutResult();
+            } else {
+                // d. else
+                // authenticate user∎
+                $output = DeviceCookiesResult::AUTHENTICATE;
+            }
+        } else {
+            $step2 = true;
+        }// endif;
+        if (isset($step2) && $step2 === true) {
+            if ($UserDeviceCookieLockout->isInLockoutList(null, $userId) === true) {
+                // 2. if authentication from untrusted clients is locked out for the specific user.
+                // reject authentication attempt∎
+                $output = DeviceCookiesResult::REJECT;
+                $this->lockoutResult = $UserDeviceCookieLockout->getLockoutResult();
+            } else {
+                // 3. else
+                // authenticate user∎
+                $output = DeviceCookiesResult::AUTHENTICATE;
+            }// endif;
+        } else {
+            if (empty($output)) {
+                // i don't think someone will be in this condition.
+                $output = DeviceCookiesResult::REJECT;
+                $this->lockoutResult = $UserDeviceCookieLockout->getLockoutResult();
+            }
+        }// endif;
+        return $output;
+    }
+
+    /**
+     * Get device cookie content
+     *
+     * @return string Return cookie value or content.
+     */
+    private function getDeviceCookie(): string
+    {
+        if ($this->hasDeviceCookie() === true) {
+            return $_COOKIE[$this->deviceCookieName];
+        }
+        return '';
+    }
+
+    /**
+     * Get device cookie as array.
+     *
+     * @param string|null $cookieValue The cookie value. Leave null to get it from cookie variable.
+     *@return array Return array where 0 is login, 1 is nonce, 2 is signature.
+     */
+    private function getDeviceCookieArray(string $cookieValue = null): array
+    {
+        if ($cookieValue === null) {
+            $cookieValue = $this->getDeviceCookie();
+        }
+        $exploded = explode(',', $cookieValue);
+        if (is_array($exploded) && count($exploded) >= 3) {
+            $output = $exploded;
+        } else {
+            $output = [
+                '',
+                null,
+                null,
+            ];
+        }
+        unset($cookieValue, $exploded);
+        return $output;
+    }
+
+    /**
+     * Check if the incoming request contains a device cookie.
+     *
+     * This is just check that there is device cookie or not. It was not check for valid or invalid device cookie.
+     *
+     * @return bool Return `true` if there is device cookie. Return `false` if not.
+     */
+    private function hasDeviceCookie(): bool
+    {
+        if (isset($_COOKIE[$this->deviceCookieName])) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Issue new device cookie to user’s client.
+     *
+     * Issue a browser cookie with a value.
+     *
+     * @param string $login The login name (or internal ID).
+     */
+    private function issueNewDeviceCookie(string $login)
+    {
+        $nonce = $this->generateNonce();
+        $signature = $this->getHmacSignature($login, $nonce);
+        setcookie(
+            $this->deviceCookieName,
+            $login . ',' . $nonce . ',' . $signature,
+            time() + ($this->deviceCookieExpire * 24 * 60 * 60),
+            '/'
+        );
+    }
+
+    /**
+     * Register failed authentication attempt.
+     */
+    private function registerFailedAuth(string $login, ?string $userId)
+    {
+        $data = [
+            'login' => $login,
+            'user_id' => $userId,
+        ];
+        // get additional data from previous cookie.
+        if (isset($data['login']) && $this->validateDeviceCookie($data['login']) === true) {
+            // if a valid device cookie presented.
+            $validDeviceCookie = true; // mark that valid device cookie is presented.
+            list($login, $nonce, $signature) = $this->getDeviceCookieArray();
+            $data['devicecookie_nonce'] = $nonce;
+            $data['devicecookie_signature'] = $signature;
+            unset($login, $nonce, $signature);
+        }
+        // sanitize $data
+        if (isset($data['devicecookie_nonce']) && empty($data['devicecookie_nonce'])) {
+            $data['devicecookie_nonce'] = null;
+            unset($validDeviceCookie);
+        }
+        if (isset($data['devicecookie_signature']) && empty($data['devicecookie_signature'])) {
+            $data['devicecookie_signature'] = null;
+            unset($validDeviceCookie);
+        }
+        // 1. register a failed authentication attempt
+        $UserDeviceCookieFailedAttempts = new UserDeviceCookieFailedAttempts($this->Dbh);
+        $UserDeviceCookieFailedAttempts->addFailedAttempt($data);
+        // 2. depending on whether a valid device cookie is present in the request,
+        // count the number of failed authentication attempts within period T
+        if (!isset($data['devicecookie_nonce']) || !isset($data['devicecookie_signature'])) {
+            // a. all untrusted clients
+            $where = [];
+            $where['devicecookie_signature'] = null;
+            $failedAttempts = $UserDeviceCookieFailedAttempts->countFailedAttemptInPeriod($this->timePeriod, $where);
+        } else {
+            // b. a specific device cookie
+            $where = [];
+            $where['devicecookie_signature'] = $data['devicecookie_signature'];
+            $failedAttempts = $UserDeviceCookieFailedAttempts->countFailedAttemptInPeriod($this->timePeriod, $where);
+        }
+        $this->currentFailedAttempt = $failedAttempts;
+        unset($UserDeviceCookieFailedAttempts, $where);
+        // 3. if "number of failed attempts within period T" > N
+        if ($failedAttempts > $this->maxAttempt) {
+            $dataUpdate = [];
+            $dataUpdate['user_id'] = $data['user_id'];
+            $Datetime = new \Datetime();
+            $Datetime->add(new \DateInterval('PT' . $this->timePeriod . 'S'));
+            $dataUpdate['lockout_until'] = $Datetime->format('Y-m-d H:i:s');
+            unset($Datetime);
+            if (
+                isset($validDeviceCookie) &&
+                $validDeviceCookie === true
+            ) {
+                // a. if a valid device cookie is presented
+                // put the device cookie into the lockout list for device cookies until now+T
+                $dataUpdate['devicecookie_nonce'] = $data['devicecookie_nonce'];
+                $dataUpdate['devicecookie_signature'] = $data['devicecookie_signature'];
+            } else {
+                // b. else
+                // lockout all authentication attempts for a specific user from all untrusted clients until now+T
+                $dataUpdate['lockout_untrusted_clients'] = 1;
+            }
+            $UserDeviceCookieLockout = new UserDeviceCookieLockout($this->Dbh);
+            $UserDeviceCookieLockout->addUpdateLockoutList($dataUpdate);
+            unset($UserDeviceCookieLockout);
+        }
+    }
+
+    /**
+     * Remove a device cookie.
+     */
+    private function removeDeviceCookie()
+    {
+        setcookie($this->deviceCookieName, '', time() - ($this->deviceCookieExpire * 24 * 60 * 60), '/');
+    }
+
+    /**
+     * Validate device cookie.
+     *
+     * @partam string $userLogin The login ID input from user.
+     * @return bool Return `true` if device cookie is correct and the `login` contained in the cookie is matches.
+     */
+    private function validateDeviceCookie(string $userLogin): bool
+    {
+        if ($this->hasDeviceCookie() === true) {
+            $cookieValue = $_COOKIE[$this->deviceCookieName];
+            list($login, $nonce, $signature) = $this->getDeviceCookieArray($cookieValue);
+            if ($userLogin . ',' . $nonce . ',' . $signature === $cookieValue) {
+                // 1. Validate that the device cookie is formatted as described
+                if (
+                    hash_equals(
+                        $this->getHmacSignature($userLogin, $nonce),
+                        $signature
+                    )
+                ) {
+                    // 2. Validate that SIGNATURE == HMAC(secret-key, “LOGIN,NONCE”)
+                    if ($login === $userLogin) {
+                        // 3. Validate that LOGIN represents the user who is actually trying to authenticate
+                        return true;
+                    }
+                }
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Generate nonce
+     *
+     * @param int $length The string length.
+     * @return string Return generated nonce.
+     */
+    private function generateNonce(int $length = 32): string
+    {
+        return base64_encode(random_bytes($length));
+    }
+
+    /**
+     * Get HMAC signature content.
+     *
+     * @param string $login The login name.
+     * @param string $nonce NONCE.
+     * @return string Return generated string from HMAC.
+     */
+    private function getHmacSignature(string $login, string $nonce): string
+    {
+        return hash_hmac('sha512', $login . ',' . $nonce, $this->secretKey);
+    }
+}
diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php
new file mode 100644
index 0000000000000000000000000000000000000000..d481b1c99677bb6f1a7e0b7e5e8bb10cc1388574
--- /dev/null
+++ b/src/Models/BaseModel.php
@@ -0,0 +1,143 @@
+<?php
+
+namespace DeviceCookies\Models;
+
+abstract class BaseModel
+{
+    /**
+     * @var \PDO
+     */
+    protected $Dbh;
+
+    /**
+     * @var \PDOStatement
+     */
+    protected $Sth;
+
+    /**
+     * Class constructor.
+     */
+    public function __construct(\PDO $dbh)
+    {
+        $this->Dbh = $dbh;
+    }
+
+    /**
+     * Delete data from DB table.
+     *
+     * @see https://github.com/doctrine/dbal/blob/master/lib/Doctrine/DBAL/Connection.php#L643
+     * @param string $tableName The table name. This table name will NOT auto add prefix.
+     * @param array $identifier The identifier for use in `WHERE` statement.
+     * @return bool Return PDOStatement::execute(). Return `true` on success, `false` for otherwise.
+     * @throws \InvalidArgumentException Throw the error if `$identifier` is incorrect value.
+     */
+    public function delete(string $tableName, array $identifier)
+    {
+        if (empty($identifier)) {
+            throw new \InvalidArgumentException(
+                'The argument $identifier is required associative array column - value pairs.'
+            );
+        }
+        $columns = [];
+        $placeholders = [];
+        $values = [];
+        $conditions = [];
+        foreach ($identifier as $columnName => $value) {
+            $columns[] = '`' . $columnName . '`';
+            $conditions[] = '`' . $columnName . '` = ?';
+            $values[] = $value;
+        }// endforeach;
+        unset($columnName, $value);
+        $sql = 'DELETE FROM `' . $tableName . '` WHERE ' . implode(' AND ', $conditions);
+        $this->Sth = $this->Dbh->prepare($sql);
+        unset($columns, $placeholders, $sql);
+        return $this->Sth->execute($values);
+    }
+
+    /**
+     * Insert data into DB table.
+     *
+     * @see https://github.com/doctrine/dbal/blob/master/lib/Doctrine/DBAL/Connection.php#L749
+     * @param string $tableName The table name. This table name will NOT auto add prefix.
+     * @param array $data The associative array where column name is the key and its value is the value pairs.
+     * @return bool Return PDOStatement::execute(). Return `true` on success, `false` for otherwise.
+     * @throws \InvalidArgumentException Throw the error if `$data` is invalid.
+     */
+    public function insert(string $tableName, array $data): bool
+    {
+        if (empty($data)) {
+            throw new \InvalidArgumentException(
+                'The argument $data is required associative array column - value pairs.'
+            );
+        }
+        $columns = [];
+        $placeholders = [];
+        $values = [];
+        foreach ($data as $columnName => $value) {
+            $columns[] = '`' . $columnName . '`';
+            $placeholders[] = '?';
+            $values[] = $value;
+        }// endforeach;
+        unset($columnName, $value);
+        $sql = 'INSERT INTO `' . $tableName . '` (' . implode(', ', $columns) . ') VALUES ('
+            . implode(', ', $placeholders) . ')';
+        $this->Sth = $this->Dbh->prepare($sql);
+        unset($columns, $placeholders, $sql);
+        return $this->Sth->execute($values);
+    }
+
+    /**
+     * Get PDO statement after called `insert()`, `update()`, `delete()`.
+     *
+     * @return \PDOStatement|null Return `\PDOStatement` object if exists, `null` if not exists.
+     */
+    public function PDOStatement()
+    {
+        return $this->Sth;
+    }
+
+    /**
+     * Update data into DB table.
+     *
+     * @see https://github.com/doctrine/dbal/blob/master/lib/Doctrine/DBAL/Connection.php#L714
+     * @param string $tableName The table name. This table name will NOT auto add prefix.
+     * @param array $data The associative array where column name is the key and its value is the value pairs.
+     * @param array $identifier The identifier for use in `WHERE` statement.
+     * @return bool Return PDOStatement::execute(). Return `true` on success, `false` for otherwise.
+     * @throws \InvalidArgumentException Throw the error if `$data` or `$identifier` is incorrect value.
+     */
+    public function update(string $tableName, array $data, array $identifier): bool
+    {
+        if (empty($data)) {
+            throw new \InvalidArgumentException(
+                'The argument $data is required associative array column - value pairs.'
+            );
+        }
+        if (empty($identifier)) {
+            throw new \InvalidArgumentException(
+                'The argument $identifier is required associative array column - value pairs.'
+            );
+        }
+        $columns = [];
+        $placeholders = [];
+        $values = [];
+        $conditions = [];
+        foreach ($data as $columnName => $value) {
+            $columns[] = '`' . $columnName . '`';
+            $placeholders[] = '`' . $columnName . '` = ?';
+            $values[] = $value;
+        }// endforeach;
+        unset($columnName, $value);
+        foreach ($identifier as $columnName => $value) {
+            $columns[] = '`' . $columnName . '`';
+            $conditions[] = '`' . $columnName . '` = ?';
+            $values[] = $value;
+        }// endforeach;
+        unset($columnName, $value);
+        $sql = 'UPDATE `' . $tableName . '` SET ' . implode(', ', $placeholders) . ' WHERE '
+            . implode(' AND ', $conditions);
+        $this->Sth = $this->Dbh->prepare($sql);
+        unset($columns, $placeholders, $sql);
+        return $this->Sth->execute($values);
+    }
+}
diff --git a/src/Models/UserDeviceCookieFailedAttempts.php b/src/Models/UserDeviceCookieFailedAttempts.php
new file mode 100644
index 0000000000000000000000000000000000000000..020eedcb38b50055e1ce588dfb2b12cacd267cc9
--- /dev/null
+++ b/src/Models/UserDeviceCookieFailedAttempts.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace DeviceCookies\Models;
+
+/**
+ * User device cookie failed attempts model.
+ */
+class UserDeviceCookieFailedAttempts extends BaseModel
+{
+    protected $tableName = 'user_devicecookie_failedattempts';
+
+    /**
+     * Add/register failed authentication attempt.
+     *
+     * @param array $data The associative array where key is field.
+     * @throws \InvalidArgumentException Throw the error if `$data` is invalid.
+     */
+    public function addFailedAttempt(array $data)
+    {
+        if (!isset($data['user_id'])) {
+            throw new \InvalidArgumentException('The `$data` must contain `user_id` in the array key.');
+        }
+        $this->insert($this->tableName, $data);
+    }
+
+    /**
+     * Count the number of failed authentication within period.
+     *
+     * @param int $timePeriod The time period.
+     * @param array $where The associative array where key is field.
+     * @return int Return total number of failed authentication counted.
+     */
+    public function countFailedAttemptInPeriod(int $timePeriod, array $where = []): int
+    {
+        $sql = 'SELECT COUNT(*) AS `total_failed` FROM `' . $this->tableName
+            . '`WHERE `datetime` >= NOW() - INTERVAL :time_period SECOND';
+        $values = [];
+        $values[':time_period'] = $timePeriod;
+        foreach ($where as $field => $value) {
+            $sql .= ' AND ';
+            if ($value === null) {
+                $sql .= ' `' . $field . '` IS NULL';
+            } else {
+                $sql .= ' `' . $field . '` = :' . $field;
+                $values[':' . $field] = $value;
+            }
+        }// endforeach;
+        unset($field, $value);
+        $this->Sth = $this->Dbh->prepare($sql);
+        foreach ($values as $placeholder => $value) {
+            $this->Sth->bindValue($placeholder, $value);
+        }// endforeach;
+        unset($placeholder, $sql, $value, $values);
+        $this->Sth->execute();
+        $result = $this->Sth->fetchObject();
+        if (is_object($result) && isset($result->total_failed)) {
+            return intval($result->total_failed);
+        }
+        return 0;
+    }
+}
diff --git a/src/Models/UserDeviceCookieLockout.php b/src/Models/UserDeviceCookieLockout.php
new file mode 100644
index 0000000000000000000000000000000000000000..b1d0743097e6d4a13bb45e22f07df4ece111fd6b
--- /dev/null
+++ b/src/Models/UserDeviceCookieLockout.php
@@ -0,0 +1,161 @@
+<?php
+
+namespace DeviceCookies\Models;
+
+/**
+ * User device cookie lockout model.
+ */
+class UserDeviceCookieLockout extends BaseModel
+{
+    /**
+     * @var array|null Contain lockout result object from `isInLockoutList()` method.
+     */
+    protected $lockoutResult;
+
+    protected $tableName = 'user_devicecookie_lockout';
+
+    /**
+     * Put out the device cookie in the lockout list or lockout all untrusted clients.
+     *
+     * Check first that the specific data is already exists then update the data, otherwise add the data.
+     *
+     * @param array $data The associative array where key is field.
+     * @throws \InvalidArgumentException Throw the error if `$data` is invalid.
+     */
+    public function addUpdateLockoutList(array $data)
+    {
+        if (!isset($data['user_id'])) {
+            throw new \InvalidArgumentException('The `$data` must contain `user_id` in the array key.');
+        }
+        if (!isset($data['lockout_until'])) {
+            throw new \InvalidArgumentException('The `$data` must contain `lockout_until` in the array key.');
+        }
+        if (!isset($data['devicecookie_signature']) && !isset($data['lockout_untrusted_clients'])) {
+            throw new \InvalidArgumentException(
+                'The `$data` must contain `devicecookie_signature` OR `lockout_untrusted_clients` in the array key.'
+            );
+        }
+        // check that data already exists in DB.
+        $sql = 'SELECT `user_id`, `devicecookie_nonce`, `devicecookie_signature`, `lockout_untrusted_clients`,'
+            . ' `lockout_until` FROM `' . $this->tableName . '` WHERE `user_id` = :user_id';
+        $where = [];
+        $where[':user_id'] = $data['user_id'];
+        if (isset($data['devicecookie_signature'])) {
+            $sql .= ' AND`devicecookie_signature` = :devicecookie_signature';
+            $where[':devicecookie_signature'] = $data['devicecookie_signature'];
+        }
+        if (isset($data['lockout_untrusted_clients'])) {
+            $sql .= ' AND `lockout_untrusted_clients` = :lockout_untrusted_clients';
+            $where[':lockout_untrusted_clients'] = $data['lockout_untrusted_clients'];
+        }
+        $this->Sth = $this->Dbh->prepare($sql);
+        foreach ($where as $field => $value) {
+            $this->Sth->bindValue($field, $value);
+        }// endforeach;
+        $this->Sth->execute();
+        $result = $this->Sth->fetchAll();
+        if (is_array($result) && count($result) >= 1) {
+            $useInsert = false;
+        } else {
+            $useInsert = true;
+        }
+        // if not exists use insert, otherwise use update.
+        if (isset($useInsert) && $useInsert === true) {
+            $this->insert($this->tableName, $data);
+        } else {
+            $where = [];
+            $where['user_id'] = $data['user_id'];
+            $dataUpdate = [];
+            $dataUpdate['lockout_until'] = $data['lockout_until'];
+            if (isset($data['devicecookie_signature'])) {
+                $where['devicecookie_signature'] = $data['devicecookie_signature'];
+            }
+            if (isset($data['lockout_untrusted_clients'])) {
+                $where['lockout_untrusted_clients'] = $data['lockout_untrusted_clients'];
+            }
+            // reset
+            $this->update($this->tableName, $dataUpdate, $where);
+        }
+    }
+
+    /**
+     * Get result object after called to `isInLockoutList()` method.
+     *
+     *@return array Return result object if required method was called, return null if nothing.
+     */
+    public function getLockoutResult(): array
+    {
+        if (is_array($this->lockoutResult)) {
+            return $this->lockoutResult;
+        }
+        return [];
+    }
+
+    /**
+     * Check if current user is in lockout list.
+     *
+     * If device cookie content is specified, then it will check for specific device cookie.<br>
+     * If device cookie is `null` then it will check for untrusted clients.
+     *
+     * @param string $deviceCookie The device cookie content.
+     * @param int $user_id The user id. For checking from untrusted clients.
+     * @return bool Return `true` if current user is in the lockout list, return `false` for otherwise.
+     */
+    public function isInLockoutList(string $deviceCookie = null, int $user_id = null): bool
+    {
+        $sql = 'SELECT `user_id`, `devicecookie_nonce`, `devicecookie_signature`, `lockout_untrusted_clients`,'
+            . ' `lockout_until` FROM `' . $this->tableName . '` WHERE `lockout_until` >= NOW()';
+        $where = [];
+        if ($deviceCookie !== null) {
+            // if there is device cookie.
+            $exploded = explode(',', $deviceCookie);
+            if (is_array($exploded) && count($exploded) >= 3) {
+                list($login, $nonce, $signature) = $exploded;
+            } else {
+                list($login, $nonce, $signature) = [
+                    '',
+                    null,
+                    null,
+                ];
+            }
+            unset($exploded);
+            if ($signature === null) {
+                $sql .= ' AND `devicecookie_signature` IS NULL';
+            } else {
+                $sql .= ' AND `devicecookie_signature` = :devicecookie_signature';
+                $where[':devicecookie_signature'] = $signature;
+            }
+            unset($login, $nonce, $signature);
+        } else {
+            // if there is NO device cookie.
+            if ($user_id !== null && !empty($user_id)) {
+                // if there is a specific user_id.
+                $sql .= ' AND `user_id` = :user_id';
+                $where[':user_id'] = $user_id;
+            } elseif ($user_id === null) {
+                // if no specific user_id.
+                // this is may because of client enter invalid user login ID that cause this to be null.
+                // just return false and let them authenticate.
+                unset($sql, $where);
+                return false;
+            }
+            $sql .= ' AND `lockout_untrusted_clients` = :lockout_untrusted_clients';
+            $where[':lockout_untrusted_clients'] = 1;
+        }// endif;
+        $this->Sth = $this->Dbh->prepare($sql);
+        foreach ($where as $field => $value) {
+            $this->Sth->bindValue($field, $value);
+        }// endforeach;
+        unset($field, $sql, $value, $where);
+        $this->Sth->execute();
+        $result = $this->Sth->fetchAll();
+        if (is_array($result) && count($result) >= 1) {
+            // if found in lockout list
+            $this->lockoutResult = $result;
+            unset($result);
+            return true;
+        }
+        unset($result);
+        return false;
+    }
+}