A normal session ends when the browser closes. A remember me option keeps the user logged in for days or weeks, by storing a long-lived token in a cookie.
Done carelessly, it is a security hole. Never store the user id or a password in the cookie. Use a random token that you can verify and cancel.
The safe design: selector and validator
Create two random values:
- A selector: used to find the row in the database. Not secret.
- A validator: the secret. Only its hash is stored in the database.
The cookie holds selector:validator. If the database leaks, the attacker only has hashes of the validators, which they cannot turn back into working cookies.
The table
CREATE TABLE auth_tokens (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
selector CHAR(24) NOT NULL UNIQUE,
validator_hash CHAR(64) NOT NULL,
expires_at DATETIME NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Create a token
<?php
function issueRememberToken(PDO $pdo, int $userId, int $days = 30): string
{
$selector = bin2hex(random_bytes(12)); // 24 hex characters
$validator = bin2hex(random_bytes(32)); // 64 hex characters
$pdo->prepare("INSERT INTO auth_tokens (user_id, selector, validator_hash, expires_at) VALUES (:user, :selector, :hash, :expires)")
->execute([
"user" => $userId,
"selector" => $selector,
"hash" => hash("sha256", $validator),
"expires" => date("Y-m-d H:i:s", time() + $days * 86400),
]);
return $selector . ":" . $validator; // this goes in the cookie
}
$pdo = require __DIR__ . "/db.php";
$pdo->exec("CREATE TABLE auth_tokens (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, selector TEXT NOT NULL UNIQUE, validator_hash TEXT NOT NULL, expires_at TEXT NOT NULL)");
$cookieValue = issueRememberToken($pdo, 1);
echo strlen($cookieValue) . " characters in the cookie\n";
echo $pdo->query("SELECT COUNT(*) FROM auth_tokens")->fetchColumn() . " token row stored";
89 characters in the cookie
1 token row stored
SHA-256 is fine here, because the validator is a long random value, not a human-chosen password. (Passwords need password_hash(). Random 256-bit tokens do not.)
Set the cookie
<?php
setcookie("remember", $cookieValue, [
"expires" => time() + 30 * 86400,
"path" => "/",
"secure" => true,
"httponly" => true,
"samesite" => "Lax",
]);
Only set it if the user ticked a “Remember me” checkbox.
Log in from the cookie
When there is no active session but a remember cookie arrives, check it.
<?php
function userFromRememberCookie(PDO $pdo, string $cookie): ?int
{
$parts = explode(":", $cookie, 2);
if (count($parts) !== 2) {
return null;
}
[$selector, $validator] = $parts;
$statement = $pdo->prepare("SELECT user_id, validator_hash, expires_at FROM auth_tokens WHERE selector = :selector");
$statement->execute(["selector" => $selector]);
$row = $statement->fetch();
if ($row === false || strtotime($row["expires_at"]) < time()) {
return null;
}
if (!hash_equals($row["validator_hash"], hash("sha256", $validator))) {
return null;
}
return (int) $row["user_id"];
}
$pdo = require __DIR__ . "/db.php";
$pdo->exec("CREATE TABLE auth_tokens (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, selector TEXT NOT NULL UNIQUE, validator_hash TEXT NOT NULL, expires_at TEXT NOT NULL)");
$selector = bin2hex(random_bytes(12));
$validator = bin2hex(random_bytes(32));
$pdo->prepare("INSERT INTO auth_tokens (user_id, selector, validator_hash, expires_at) VALUES (?, ?, ?, ?)")
->execute([2, $selector, hash("sha256", $validator), date("Y-m-d H:i:s", time() + 3600)]);
var_dump(userFromRememberCookie($pdo, $selector . ":" . $validator));
var_dump(userFromRememberCookie($pdo, $selector . ":" . str_repeat("0", 64)));
var_dump(userFromRememberCookie($pdo, "garbage"));
int(2)
NULL
NULL
The comparison uses hash_equals(), which takes the same time whether the first or last character differs.
After a successful remember login
session_regenerate_id(true)and set$_SESSION["user_id"].- Rotate the token: delete the used row and issue a new token, so a stolen cookie works only once.
- Treat sensitive actions (change password, delete account) as needing the password again, even for remembered logins.
Logout and password change
- On logout, delete the token row and expire the cookie.
- On a password change, delete all the user’s tokens.
- Delete expired tokens regularly (
DELETE FROM auth_tokens WHERE expires_at < NOW()).
What not to do
| Mistake | Why it is wrong |
|---|---|
setcookie("user_id", 42) | Anyone can change it to another id |
| Storing the password in the cookie | Plain secrets in a cookie |
| Storing the validator unhashed | A database leak gives working logins |
| Never expiring the token | A stolen cookie works forever |