Skip to content

PHP Tutorial Authentication

Remember Me

Lesson 288 of 369 2 min read By Deepak Paswan Updated Oct 5, 2026

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

query.sql
SQL
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

index.php
PHP
<?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";
Output
OUTPUT
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.)

index.php
PHP
<?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.

When there is no active session but a remember cookie arrives, check it.

index.php
PHP
<?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"));
Output
OUTPUT
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

  1. session_regenerate_id(true) and set $_SESSION["user_id"].
  2. Rotate the token: delete the used row and issue a new token, so a stolen cookie works only once.
  3. 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

MistakeWhy it is wrong
setcookie("user_id", 42)Anyone can change it to another id
Storing the password in the cookiePlain secrets in a cookie
Storing the validator unhashedA database leak gives working logins
Never expiring the tokenA stolen cookie works forever
info
Frameworks do this for you
Laravel’s “remember me” feature and WordPress’s login cookie follow this kind of design. When you can, use the tested version instead of writing your own.