A complete PHP account system — registration, email verification, login/logout, password reset via single-use expiring tokens, and a "remember me" cookie built on the split selector/validator pattern — with every database call through PDO prepared statements.
This project extends the Login-System into a full identity lifecycle. It demonstrates how the hard parts of authentication are made safe:
- Registration stores a
password_hash()digest and issues an email-verification token. - Email verification activates the account only after the user proves control of the address.
- Login/logout reuses the hardened session flow (fixation defence + secure cookie flags).
- Password reset uses a single-use token that is hashed at rest and expires quickly.
- "Remember me" uses the selector/validator split: the selector looks the row up, the validator is compared with
hash_equals()against a stored hash — so a database leak cannot be replayed and lookups stay constant-time.
Threat model: credential theft at rest, token theft from the database, token reuse/replay, session fixation, user enumeration, and CSRF on every state-changing form.
Register ─▶ store user (unverified) + verify_token(hash) ─▶ email link
│
Verify ◀── GET /verify?selector&token ──────────────────────┘ set is_verified = 1
Login ─▶ password_verify ─▶ session_regenerate_id(true) ─▶ [optional] issue remember cookie
│
Remember cookie = "selector:validator" ──▶ DB row: selector, validator_hash, expires
(validator hashed at rest; compared with hash_equals)
Forgot ─▶ store reset_token(hash)+expiry ─▶ email link ─▶ Reset ─▶ password_hash + invalidate token
auth-system/
├── public/
│ ├── register.php
│ ├── verify.php
│ ├── login.php
│ ├── logout.php
│ ├── forgot.php
│ └── reset.php
├── src/
│ ├── Database.php # PDO factory (prepared statements, no emulation)
│ ├── Csrf.php # random_bytes token + hash_equals
│ ├── Mailer.php # verification / reset email
│ ├── Auth.php # register / login / verify
│ ├── PasswordReset.php
│ └── RememberMe.php # selector/validator cookie
├── config/
│ └── config.php
└── sql/
└── schema.sql
CREATE TABLE users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(190) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
is_verified TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Email verification and password reset both use single-use, hashed, expiring tokens.
CREATE TABLE auth_tokens (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
purpose ENUM('verify','reset') NOT NULL,
token_hash CHAR(64) NOT NULL, -- SHA-256 of the raw token
expires_at DATETIME NOT NULL,
used_at DATETIME NULL,
INDEX idx_user_purpose (user_id, purpose),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- "Remember me": selector locates the row, validator_hash is verified.
CREATE TABLE remember_tokens (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
selector CHAR(24) NOT NULL UNIQUE, -- hex, looked up directly
validator_hash CHAR(64) NOT NULL, -- SHA-256 of the validator
expires_at DATETIME NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;<?php
final class Database
{
private static ?PDO $pdo = null;
public static function get(): PDO
{
if (self::$pdo === null) {
$cfg = require __DIR__ . '/../config/config.php';
self::$pdo = new PDO($cfg['dsn'], $cfg['user'], $cfg['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
return self::$pdo;
}
}<?php
final class Auth
{
public function __construct(private PDO $db, private Mailer $mailer) {}
public function register(string $email, string $password): void
{
$email = filter_var($email, FILTER_VALIDATE_EMAIL)
?: throw new InvalidArgumentException('Invalid email.');
if (strlen($password) < 12) {
throw new InvalidArgumentException('Password too short.');
}
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $this->db->prepare(
'INSERT INTO users (email, password_hash) VALUES (:e, :h)'
);
// A duplicate email throws a unique-constraint error; catch it in the
// caller and show a GENERIC "check your inbox" message either way to
// avoid revealing which addresses are registered.
$stmt->execute([':e' => $email, ':h' => $hash]);
$userId = (int) $this->db->lastInsertId();
$raw = $this->issueToken($userId, 'verify', 60 * 24); // 24h
$this->mailer->sendVerification($email, $userId, $raw);
}
/** Returns the raw token to email; only its SHA-256 hash is stored. */
private function issueToken(int $userId, string $purpose, int $minutes): string
{
$raw = bin2hex(random_bytes(32));
$stmt = $this->db->prepare(
'INSERT INTO auth_tokens (user_id, purpose, token_hash, expires_at)
VALUES (:u, :p, :h, (NOW() + INTERVAL :m MINUTE))'
);
$stmt->bindValue(':u', $userId, PDO::PARAM_INT);
$stmt->bindValue(':p', $purpose);
$stmt->bindValue(':h', hash('sha256', $raw));
$stmt->bindValue(':m', $minutes, PDO::PARAM_INT);
$stmt->execute();
return $raw;
}
public function verify(int $userId, string $rawToken): bool
{
$stmt = $this->db->prepare(
"SELECT id FROM auth_tokens
WHERE user_id = :u AND purpose = 'verify' AND used_at IS NULL
AND expires_at > NOW() AND token_hash = :h
LIMIT 1"
);
$stmt->execute([':u' => $userId, ':h' => hash('sha256', $rawToken)]);
$tokenId = $stmt->fetchColumn();
if ($tokenId === false) {
return false;
}
$this->db->beginTransaction();
$this->db->prepare('UPDATE users SET is_verified = 1 WHERE id = :u')
->execute([':u' => $userId]);
$this->db->prepare('UPDATE auth_tokens SET used_at = NOW() WHERE id = :t')
->execute([':t' => $tokenId]); // single-use
$this->db->commit();
return true;
}
}[!warning] Storing reset/verify tokens in plaintext A naive design saves the raw token in the database and never expires it, so anyone who reads the table (SQLi, backup leak) can hijack every pending account. Store only
hash('sha256', $raw), set a shortexpires_at, markused_aton first use, and email the raw value once.
<?php
final class PasswordReset
{
public function __construct(private PDO $db, private Mailer $mailer) {}
public function request(string $email): void
{
$stmt = $this->db->prepare('SELECT id FROM users WHERE email = :e LIMIT 1');
$stmt->execute([':e' => $email]);
$userId = $stmt->fetchColumn();
// Silently succeed for unknown addresses: no user enumeration.
if ($userId === false) {
return;
}
$raw = bin2hex(random_bytes(32));
$stmt = $this->db->prepare(
"INSERT INTO auth_tokens (user_id, purpose, token_hash, expires_at)
VALUES (:u, 'reset', :h, (NOW() + INTERVAL 30 MINUTE))"
);
$stmt->execute([':u' => $userId, ':h' => hash('sha256', $raw)]);
$this->mailer->sendReset($email, (int) $userId, $raw);
}
public function reset(int $userId, string $rawToken, string $newPassword): bool
{
if (strlen($newPassword) < 12) {
return false;
}
$stmt = $this->db->prepare(
"SELECT id FROM auth_tokens
WHERE user_id = :u AND purpose = 'reset' AND used_at IS NULL
AND expires_at > NOW() AND token_hash = :h
LIMIT 1"
);
$stmt->execute([':u' => $userId, ':h' => hash('sha256', $rawToken)]);
$tokenId = $stmt->fetchColumn();
if ($tokenId === false) {
return false;
}
$hash = password_hash($newPassword, PASSWORD_DEFAULT);
$this->db->beginTransaction();
$this->db->prepare('UPDATE users SET password_hash = :h WHERE id = :u')
->execute([':h' => $hash, ':u' => $userId]);
$this->db->prepare('UPDATE auth_tokens SET used_at = NOW() WHERE id = :t')
->execute([':t' => $tokenId]);
// Invalidate all remember-me sessions after a password change.
$this->db->prepare('DELETE FROM remember_tokens WHERE user_id = :u')
->execute([':u' => $userId]);
$this->db->commit();
return true;
}
}<?php
final class RememberMe
{
private const COOKIE = 'remember';
private const DAYS = 30;
public function __construct(private PDO $db) {}
public function issue(int $userId): void
{
$selector = bin2hex(random_bytes(12)); // 24 hex chars, stored plainly
$validator = bin2hex(random_bytes(32)); // secret; only its hash is stored
$stmt = $this->db->prepare(
'INSERT INTO remember_tokens (user_id, selector, validator_hash, expires_at)
VALUES (:u, :s, :v, (NOW() + INTERVAL :d DAY))'
);
$stmt->bindValue(':u', $userId, PDO::PARAM_INT);
$stmt->bindValue(':s', $selector);
$stmt->bindValue(':v', hash('sha256', $validator));
$stmt->bindValue(':d', self::DAYS, PDO::PARAM_INT);
$stmt->execute();
setcookie(self::COOKIE, $selector . ':' . $validator, [
'expires' => time() + self::DAYS * 86400,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Strict',
]);
}
/** Returns a user id if the cookie authenticates, else null. */
public function login(): ?int
{
$raw = $_COOKIE[self::COOKIE] ?? '';
if (!str_contains($raw, ':')) {
return null;
}
[$selector, $validator] = explode(':', $raw, 2);
$stmt = $this->db->prepare(
'SELECT user_id, validator_hash FROM remember_tokens
WHERE selector = :s AND expires_at > NOW() LIMIT 1'
);
$stmt->execute([':s' => $selector]);
$row = $stmt->fetch();
if (!$row) {
return null;
}
// Constant-time compare of the hashed validator: a DB leak of
// validator_hash cannot be reversed into a usable cookie.
if (!hash_equals($row['validator_hash'], hash('sha256', $validator))) {
return null;
}
return (int) $row['user_id'];
}
public function forget(): void
{
$raw = $_COOKIE[self::COOKIE] ?? '';
if (str_contains($raw, ':')) {
[$selector] = explode(':', $raw, 2);
$this->db->prepare('DELETE FROM remember_tokens WHERE selector = :s')
->execute([':s' => $selector]);
}
setcookie(self::COOKIE, '', time() - 3600, '/');
}
}[!warning] "Remember me" as a raw password/id cookie Storing the user id or password hash directly in a persistent cookie means any theft is a permanent login, and a plain-token lookup with
===leaks timing. The selector/validator split lets the lookup use the (public) selector while the (secret) validator is verified withhash_equals()against a stored hash — theft is revocable per device and a DB dump is not replayable.
<?php
require __DIR__ . '/../src/Database.php';
require __DIR__ . '/../src/Mailer.php';
require __DIR__ . '/../src/Auth.php';
$userId = (int) filter_input(INPUT_GET, 'uid', FILTER_VALIDATE_INT);
$token = (string) filter_input(INPUT_GET, 'token');
$auth = new Auth(Database::get(), new Mailer());
$ok = $userId > 0 && $token !== '' && $auth->verify($userId, $token);
$msg = $ok ? 'Your email is verified. You can now sign in.'
: 'This verification link is invalid or has expired.';
echo htmlspecialchars($msg, ENT_QUOTES, 'UTF-8');| Risk | Mitigation in this project |
|---|---|
| SQL injection | Every statement is a PDO prepared statement with bound params; emulation disabled. |
| Password disclosure | password_hash() at rest, password_verify() to check; reset re-hashes. |
| Token theft from DB | Verify/reset tokens stored as SHA-256 hashes; remember-me validator hashed too. |
| Token replay / reuse | Single-use (used_at) verify/reset tokens with short expires_at. |
| Session fixation | session_regenerate_id(true) on login (see Login-System). |
| Persistent-cookie theft | Selector/validator split, hash_equals(), revocable per device, wiped on password change. |
| User enumeration | Registration and password-reset return the same message for known/unknown emails. |
| CSRF | random_bytes() token + hash_equals() on every POST form; SameSite cookies. |
| XSS | All output escaped with htmlspecialchars(..., ENT_QUOTES, 'UTF-8'). |
See OWASP Forgot Password and Authentication cheat sheets.
- Add TOTP/WebAuthn multi-factor and step-up auth for sensitive actions.
- Rate-limit registration, verification resends, and reset requests.
- Rehash passwords on login with
password_needs_rehash(). - Send email asynchronously via a queue; sign links so tampering is detectable.
- Notify the user by email whenever the password or email is changed.
- PHP Manual — password_hash()
- PHP Manual — random_bytes()
- PHP Manual — hash_equals()
- OWASP Cheat Sheet — Forgot Password
- OWASP Cheat Sheet — Authentication
- Login-System — the session login this builds on
- Password-Hashing — hashing and verifying credentials
- CSRF-Protection-Basics — token generation and verification
- Prepared-Statements and SQL-Injection-Prevention — parameterised PDO queries
- Session-Security — hardening PHP sessions
- Mini Projects — index
- Secure PHP Development — course home