Summary: in this tutorial, you’ll learn to securely implement the remember me feature in PHP.
Introduction to the PHP remember me feature
When users log in to a web application and then close web browsers, the session cookies associated with the logins expire immediately. It means that if the users access the web application later, they need to log in again.
The remember me feature allows the users to save their logins for some time, even after closing the web browsers. To implement the remember me feature, you’ll use cookies with expiration times in the future.
The common but insecure way
The insecure way to implement the remember me is to add a user id to the cookie with an expiration time:
user_id=120
Code language: PHP (php)
When users access the web application, you check if the user id in the cookie is valid before logging them in automatically.
This naive approach relies solely on cookies, which is not secure for the following reasons:
- First, users can change the id to another to log in as another user.
- Second, the user id may reveal the number of users in the system.
A more secure approach
A more secure way to implement the remember me feature is to store a random token instead of a user id in both cookies and database server.
The value in the cookies will look like this:
remember_me=6179f9c66a9d007e689c7809b5d8320a6692787773488f12a4330cd5ffd25d91
Code language: PHP (php)
And here’s a database table that stores the tokens:
CREATE TABLE user_tokens
(
id INT AUTO_INCREMENT PRIMARY KEY,
token VARCHAR(255) NOT NULL,
expiry DATETIME NOT NULL,
user_id INT NOT NULL,
CONSTRAINT fk_user_id
FOREIGN KEY (user_id)
REFERENCES users (id) ON DELETE CASCADE
);
Code language: SQL (Structured Query Language) (sql)
When users access the web application, you match the cookies’ tokens with those stored in the database. Also, you can check the token’s expiration time. If the tokens match and have not expired, you can get the user id associated with the token and sign the user in automatically.
The query for matching the token will look like this:
SELECT user_id
FROM user_tokens
WHERE token = '6179f9c66a9d007e689c7809b5d8320a6692787773488f12a4330cd5ffd25d91' and
expiry > NOW()
Code language: PHP (php)
This approach solves two issues above:
- First, the token is more challenging to guess.
- Second, the token doesn’t reveal the number of users.
However, this approach exposes another security issue which is known as a timing attack.
When the database compares the cookie’s token with the token stored in the database, it returns the different comparison times according to how much two tokens are similar.
For example, if you have the following token stored in the cookie:
6179f9c66a9d007e689c7809b5d8320a6692787773488f12a4330cd5ffd25d91
Code language: PHP (php)
And the following token in the database:
6f9a1ef3020bb8351456cd65176e1e62ceeefcdca0a750201886a230f8736cad
Code language: PHP (php)
When comparing these tokens, the database compares each character in the tokens and stops matching when it finds a mismatch. In this example, the database stops at the second character:
However, when comparing the following pair of tokens:
6179f9c66a9d007e689c7809b5d8320a6692787773488f12a4330cd5ffd25d91
6179f9c66a9d007e689c7809b5d8320a6692787773488f12a4330cd5ffd25d92
Code language: PHP (php)
The database stops matching after it comparing the second last character.
The comparing time in the second example will always be greater than the second one because the database needs to compare more characters.
By testing different tokens, you can get different response times. In other words, the timing is leaked. To avoid timing leaks, the comparison function needs to return a constant time regardless of the tokens.
Prevent timing attacks
The following shows how to prevent the timing attack as proposed by P.I.E. In this approach, instead of storing a single token in the cookie, you store a pair of tokens: selector
and validator
with the format: selector:validator
.
The selector
is for selecting the validator
stored in the database. In the database, you store the selector
and the validator
‘s hash:
CREATE TABLE user_tokens
(
id INT AUTO_INCREMENT PRIMARY KEY,
selector VARCHAR(255) NOT NULL,
hashed_validator VARCHAR(255) NOT NULL,
user_id INT NOT NULL,
expiry DATETIME NOT NULL,
CONSTRAINT fk_user_id
FOREIGN KEY (user_id)
REFERENCES users (id) ON DELETE CASCADE
);
Code language: PHP (php)
To hash the validator, you use the password_hash() function.
To get a user id, you match the selector
from the cookie with the selector
from the database:
SELECT id, selector, hashed_validator, user_id, expiry
FROM user_tokens
WHERE selector = :selector
Code language: PHP (php)
If the query returns a row, you can match the validator
from the cookie with the hashed_validator
using the password_verify()
function.
If the validators match, you can log the user with the user_id
in automatically.
The following section will enhance the login system by adding the remember me feature using the third approach.
Create a user_tokens table to store the tokens
The following statement creates a user_tokens
table that stores the selector
, hashed validator
, expiry
, and user id.
CREATE TABLE user_tokens
(
id INT AUTO_INCREMENT PRIMARY KEY,
selector VARCHAR(255) NOT NULL,
hashed_validator VARCHAR(255) NOT NULL,
user_id INT NOT NULL,
expiry DATETIME NOT NULL,
CONSTRAINT fk_user_id
FOREIGN KEY (user_id)
REFERENCES users (id) ON DELETE CASCADE
);
Code language: PHP (php)
Add the remember me checkbox to the login form
First, add a remember me checkbox to the login form in the public/login.php
file:
<?php
require __DIR__ . '/../src/bootstrap.php';
require __DIR__ . '/../src/login.php';
?>
<?php view('header', ['title' => 'Login']) ?>
<?php if (isset($errors['login'])) : ?>
<div class="alert alert-error">
<?= $errors['login'] ?>
</div>
<?php endif ?>
<form action="login.php" method="post">
<h1>Login</h1>
<div>
<label for="username">Username:</label>
<input type="text" name="username" id="username" value="<?= $inputs['username'] ?? '' ?>">
<small><?= $errors['username'] ?? '' ?></small>
</div>
<div>
<label for="password">Password:</label>
<input type="password" name="password" id="password">
<small><?= $errors['password'] ?? '' ?></small>
</div>
<div>
<label for="remember_me">
<input type="checkbox" name="remember_me" id="remember_me"
value="checked" <?= $inputs['remember_me'] ?? '' ?> />
Remember Me
</label>
<small><?= $errors['agree'] ?? '' ?></small>
</div>
<section>
<button type="submit">Login</button>
<a href="register.php">Register</a>
</section>
</form>
<?php view('footer') ?>
Code language: PHP (php)
Second, add the code to handle the remember me checkbox to the src/login.php
file:
<?php
if (is_user_logged_in()) {
redirect_to('index.php');
}
$inputs = [];
$errors = [];
if (is_post_request()) {
[$inputs, $errors] = filter($_POST, [
'username' => 'string | required',
'password' => 'string | required',
'remember_me' => 'string'
]);
if ($errors) {
redirect_with('login.php', ['errors' => $errors, 'inputs' => $inputs]);
}
// if login fails
if (!login($inputs['username'], $inputs['password'], isset($inputs['remember_me']))) {
$errors['login'] = 'Invalid username or password';
redirect_with('login.php', [
'errors' => $errors,
'inputs' => $inputs
]);
}
// login successfully
redirect_to('index.php');
} else if (is_get_request()) {
[$errors, $inputs] = session_flash('errors', 'inputs');
}
Code language: PHP (php)
In the src/login.php
, add the remember me checkbox to the filter() function call:
[$inputs, $errors] = filter($_POST, [
'username' => 'string | required',
'password' => 'string | required',
'remember_me' => 'string'
]);
Code language: PHP (php)
Also, add the third parameter to the login() function:
login($inputs['username'], $inputs['password'], isset($inputs['remember_me'])
Code language: PHP (php)
We’ll go back to enhance the login()
function later.
Define functions for handling remember me feature
First, create the remember.php
file in the src
folder.
Second, define the following new functions for handling the tokens in the remember.php
file:
Generate tokens
The following defines the generate_tokens()
to generate pair of random tokens called selector and validator:
function generate_tokens(): array
{
$selector = bin2hex(random_bytes(16));
$validator = bin2hex(random_bytes(32));
return [$selector, $validator, $selector . ':' . $validator];
}
Code language: PHP (php)
The generate_tokens()
function returns an array of three elements: selector
, valdiator
, and selector:validator
.
Parse the token
The following parse_token()
function splits the token stored in the cookie into selector
and validator
:
function parse_token(string $token): ?array
{
$parts = explode(':', $token);
if ($parts && count($parts) == 2) {
return [$parts[0], $parts[1]];
}
return null;
}
Code language: PHP (php)
Insert a new user token
The following insert_user_tokens()
function adds a new row to the user_tokens
table:
function insert_user_token(int $user_id, string $selector, string $hashed_validator, string $expiry): bool
{
$sql = 'INSERT INTO user_tokens(user_id, selector, hashed_validator, expiry)
VALUES(:user_id, :selector, :hashed_validator, :expiry)';
$statement = db()->prepare($sql);
$statement->bindValue(':user_id', $user_id);
$statement->bindValue(':selector', $selector);
$statement->bindValue(':hashed_validator', $hashed_validator);
$statement->bindValue(':expiry', $expiry);
return $statement->execute();
}
Code language: PHP (php)
Find token by a selector
The following find_user_token_by_selector()
function finds a row in the user_tokens
table by a selector. It only returns the match selector if the token is not expired by comparing the expiry with the current time.
function find_user_token_by_selector(string $selector)
{
$sql = 'SELECT id, selector, hashed_validator, user_id, expiry
FROM user_tokens
WHERE selector = :selector AND
expiry >= now()
LIMIT 1';
$statement = db()->prepare($sql);
$statement->bindValue(':selector', $selector);
$statement->execute();
return $statement->fetch(PDO::FETCH_ASSOC);
}
Code language: PHP (php)
Delete a user token
The following delete_user_token()
function deletes all tokens associated with a user:
function delete_user_token(int $user_id): bool
{
$sql = 'DELETE FROM user_tokens WHERE user_id = :user_id';
$statement = db()->prepare($sql);
$statement->bindValue(':user_id', $user_id);
return $statement->execute();
}
Code language: PHP (php)
Find a user by a token
The following find_user_by_token()
function returns user_id
and username
by a token.
function find_user_by_token(string $token)
{
$tokens = parse_token($token);
if (!$tokens) {
return null;
}
$sql = 'SELECT users.id, username
FROM users
INNER JOIN user_tokens ON user_id = users.id
WHERE selector = :selector AND
expiry > now()
LIMIT 1';
$statement = db()->prepare($sql);
$statement->bindValue(':selector', $tokens[0]);
$statement->execute();
return $statement->fetch(PDO::FETCH_ASSOC);
}
Code language: PHP (php)
Check if a token is valid
The following toke_is_valid()
function parse the token stored in the cookie (selector:validator
) and return true
if the token is valid and not expired:
function token_is_valid(string $token): bool { // parse the token to get the selector and validator [$selector, $validator] = parse_token($token);
$tokens = find_user_token_by_selector($selector);
if (!$tokens) {
return false;
}
return password_verify($validator, $tokens['hashed_validator']);
Code language: PHP (php)
Modifying the function in the auth.php
The following describes the changes to the functions in the auth.php
file:
The login() function
The following adds the third parameter $remember
to the login()
function:
function login(string $username, string $password, bool $remember = false): bool
{
$user = find_user_by_username($username);
// if user found, check the password
if ($user && is_user_active($user) && password_verify($password, $user['password'])) {
log_user_in($user);
if ($remember) {
remember_me($user['id']);
}
return true;
}
return false;
}
Code language: PHP (php)
If the $remember
is true
, call the remember_me()
function.
The log_user_in() function
The log_user_in() function logs a user in:
/**
* log a user in
* @param array $user
* @return bool
*/
function log_user_in(array $user): bool
{
// prevent session fixation attack
if (session_regenerate_id()) {
// set username & id in the session
$_SESSION['username'] = $user['username'];
$_SESSION['user_id'] = $user['id'];
return true;
}
return false;
}
Code language: PHP (php)
The remember_me() function
The following defines the remember_me()
function:
function remember_me(int $user_id, int $day = 30)
{
[$selector, $validator, $token] = generate_tokens();
// remove all existing token associated with the user id
delete_user_token($user_id);
// set expiration date
$expired_seconds = time() + 60 * 60 * 24 * $day;
// insert a token to the database
$hash_validator = password_hash($validator, PASSWORD_DEFAULT);
$expiry = date('Y-m-d H:i:s', $expired_seconds);
if (insert_user_token($user_id, $selector, $hash_validator, $expiry)) {
setcookie('remember_me', $token, $expired_seconds);
}
}
Code language: PHP (php)
The remember_me()
function saves the login for a user for a specified number of days. By default, it remembers the login for 30 days.
The remember_me()
function does the following:
- First, generate
selector
,validator
, and token (selector:validator
) - Second, insert a new row into the
user_tokens
table. - Third, set a cookie with the specified expiration time.
The logout() function
If users log out, besides deleting the session, you need to delete the records in the user_tokens
table and remove the remember_me
cookie:
function logout(): void
{
if (is_user_logged_in()) {
// delete the user token
delete_user_token($_SESSION['user_id']);
// delete session
unset($_SESSION['username'], $_SESSION['user_id`']);
// remove the remember_me cookie
if (isset($_COOKIE['remember_me'])) {
unset($_COOKIE['remember_me']);
setcookie('remember_user', null, -1);
}
// remove all session data
session_destroy();
// redirect to the login page
redirect_to('login.php');
}
}
Code language: PHP (php)
The is_user_logged_in() function
The following is_user_logged_in()
function verifies if the user is currently logged in:
function is_user_logged_in(): bool
{
// check the session
if (isset($_SESSION['username'])) {
return true;
}
// check the remember_me in cookie
$token = filter_input(INPUT_COOKIE, 'remember_me', FILTER_SANITIZE_STRING);
if ($token && token_is_valid($token)) {
$user = find_user_by_token($token);
if ($user) {
return log_user_in($user);
}
}
return false;
}
Code language: PHP (php)
How it works
- First, the function returns
true
if the session has the keyusername
. - Then, verify the token in the cookies and log the user in if the token is valid.
Summary
- The remember me feature saves the login for some time even after the web browsers are closed.
- Use cookies to implement the remember me feature.