Authentication Without a Framework
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against PHP 8.5 (8.5.11), checked on 2026-10-05; the default bcrypt cost of 12 applies since PHP
8.4 (it was 10 before). Every output was produced by running the code below against SQLite on PHP 8.5.11, and the
HTTP Basic example under both php -S and PHP-FPM. The cryptographic building blocks (password_hash(),
random_bytes(), hash_equals()) are explained on the PHP Reference’s
Cryptography, Hashing and Passwords page.
In Laravel, a starter kit or Fortify provides all of this (see Authentication);
this page shows what they do for you.
The Auth service
Bookshelf Lite keeps users, failed logins, "remember me" tokens and reset tokens in four tables:
CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL);
CREATE TABLE login_failures (throttle_key TEXT NOT NULL, failed_at INTEGER NOT NULL);
CREATE TABLE remember_tokens (selector TEXT PRIMARY KEY, validator_hash TEXT NOT NULL,
user_id INTEGER NOT NULL, expires_at INTEGER NOT NULL);
CREATE TABLE password_resets (token_hash TEXT PRIMARY KEY, user_id INTEGER NOT NULL, expires_at INTEGER NOT NULL);
The whole service is one class; each part is explained in its own section below.
<?php
declare(strict_types=1);
final class Auth
{
private const int MAX_ATTEMPTS = 5;
private const int WINDOW_SECONDS = 900;
public function __construct(private readonly PDO $pdo) {}
public function register(string $email, string $password): int
{
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false || mb_strlen($password) < 12) {
throw new InvalidArgumentException('A valid email and a password of at least 12 characters are required.');
}
$stmt = $this->pdo->prepare('INSERT INTO users (email, password_hash) VALUES (?, ?)');
$stmt->execute([mb_strtolower($email), password_hash($password, PASSWORD_DEFAULT)]);
return (int) $this->pdo->lastInsertId();
}
/** @return int|null the user id, or null when the credentials are wrong */
public function attempt(string $email, string $password, string $ip): ?int
{
$key = hash('sha256', mb_strtolower($email) . '|' . $ip);
if ($this->recentFailures($key) >= self::MAX_ATTEMPTS) {
throw new RuntimeException('Too many attempts. Try again later.');
}
$stmt = $this->pdo->prepare('SELECT id, password_hash FROM users WHERE email = ?');
$stmt->execute([mb_strtolower($email)]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
// Verify against a dummy hash for unknown emails, so both paths take the same time
$hash = $user['password_hash'] ?? '$2y$12$' . str_repeat('a', 53);
if (!password_verify($password, $hash) || $user === false) {
$this->pdo->prepare('INSERT INTO login_failures (throttle_key, failed_at) VALUES (?, ?)')->execute([$key, time()]);
return null;
}
// Upgrade the hash when the algorithm or cost has changed (e.g. bcrypt cost 10 -> 12 in PHP 8.4)
if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
$this->pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
->execute([password_hash($password, PASSWORD_DEFAULT), $user['id']]);
}
$this->pdo->prepare('DELETE FROM login_failures WHERE throttle_key = ?')->execute([$key]);
return (int) $user['id'];
}
private function recentFailures(string $key): int
{
$stmt = $this->pdo->prepare('SELECT COUNT(*) FROM login_failures WHERE throttle_key = ? AND failed_at > ?');
$stmt->execute([$key, time() - self::WINDOW_SECONDS]);
return (int) $stmt->fetchColumn();
}
/** "Remember me": a selector to find the row and a validator stored only as a hash. */
public function issueRememberToken(int $userId): string
{
$selector = bin2hex(random_bytes(12));
$validator = bin2hex(random_bytes(32));
$this->pdo->prepare('INSERT INTO remember_tokens (selector, validator_hash, user_id, expires_at) VALUES (?, ?, ?, ?)')
->execute([$selector, hash('sha256', $validator), $userId, time() + 30 * 86400]);
return "$selector:$validator"; // the cookie value
}
public function consumeRememberToken(string $cookie): ?int
{
[$selector, $validator] = explode(':', $cookie, 2) + [1 => ''];
$stmt = $this->pdo->prepare('SELECT validator_hash, user_id, expires_at FROM remember_tokens WHERE selector = ?');
$stmt->execute([$selector]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
$this->pdo->prepare('DELETE FROM remember_tokens WHERE selector = ?')->execute([$selector]); // single use
if ($row === false || $row['expires_at'] < time() || !hash_equals($row['validator_hash'], hash('sha256', $validator))) {
return null;
}
return (int) $row['user_id']; // the caller logs in and issues a new token
}
/** Password reset: a random token, stored hashed, valid for one hour, usable once. */
public function issueResetToken(string $email): ?string
{
$stmt = $this->pdo->prepare('SELECT id FROM users WHERE email = ?');
$stmt->execute([mb_strtolower($email)]);
$userId = $stmt->fetchColumn();
if ($userId === false) {
return null; // say "if the account exists, we sent an email" either way
}
$token = bin2hex(random_bytes(32));
$this->pdo->prepare('INSERT INTO password_resets (token_hash, user_id, expires_at) VALUES (?, ?, ?)')
->execute([hash('sha256', $token), $userId, time() + 3600]);
return $token; // goes into the emailed link
}
public function resetPassword(string $token, string $newPassword): bool
{
$stmt = $this->pdo->prepare('DELETE FROM password_resets WHERE token_hash = ? AND expires_at > ? RETURNING user_id');
$stmt->execute([hash('sha256', $token), time()]);
$userId = $stmt->fetchColumn();
if ($userId === false) {
return false;
}
$this->pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
->execute([password_hash($newPassword, PASSWORD_DEFAULT), $userId]);
$this->pdo->prepare('DELETE FROM remember_tokens WHERE user_id = ?')->execute([$userId]); // log out everywhere
return true;
}
}
Source: PHP Manual — Password Hashing, random_bytes and hash_equals
Registration and login
password_hash($password, PASSWORD_DEFAULT) creates a salted, slow hash (bcrypt, cost 12 since PHP 8.4) that
includes the algorithm, cost and salt, so the database column only needs room for 255 characters to stay
future-proof. password_verify() checks a password against it in constant time. Never compare hashes yourself, never
hash with md5()/sha1(), and never limit or alter the password (no trim(), no strip_tags()): a password is
opaque bytes.
<?php
declare(strict_types=1);
$auth = new Auth($pdo);
$id = $auth->register('Ada@Example.com', 'correct horse battery');
var_dump($id); // int(1)
var_dump($auth->attempt('ada@example.com', 'correct horse battery', '203.0.113.7')); // int(1)
var_dump($auth->attempt('ada@example.com', 'wrong', '203.0.113.7')); // NULL
var_dump($auth->attempt('nobody@example.com', 'x', '203.0.113.7')); // NULL
// An old cost-10 hash is upgraded on the next successful login
$pdo->exec("UPDATE users SET password_hash = '" . password_hash('correct horse battery', PASSWORD_BCRYPT, ['cost' => 10]) . "'");
$auth->attempt('ada@example.com', 'correct horse battery', '203.0.113.7');
echo substr($pdo->query('SELECT password_hash FROM users')->fetchColumn(), 0, 7), "\n"; // $2y$12$
var_dump(password_get_info(password_hash('x', PASSWORD_DEFAULT))['options']); // ['cost' => 12]
Source: password_hash, password_verify and password_needs_rehash
Three details in attempt() matter:
-
Rehash on login.
password_needs_rehash()reports that a hash uses an older algorithm or cost, and the only moment the plain password is available to compute a new one is a successful login. The test above turned a$2y$10$hash into$2y$12$. -
The same answer and the same time for unknown accounts. The function returns
nullfor a wrong password and for an unknown email alike, and an error message must not say which. Unknown emails are verified against a dummy hash, so that the response time does not reveal whether the account exists: measured on this machine,password_verify()took 236 ms against a real cost-12 hash and 252 ms against the dummy, while skipping the verification would answer almost instantly. -
Normalisation. Emails are compared lowercased, so
Ada@Example.comandada@example.comare the same account.
After a successful attempt, start the authenticated session as on
Sessions and Cookies: session_regenerate_id(true), then store only the
user ID ($_SESSION['user_id'] = $id), and load the user from the database on each request. Logging out destroys the
session and deletes the user’s remember-me token.
Login throttling
Without a limit, an attacker can try passwords as fast as the server answers. attempt() records each failure under a
key made of the email and the client IP, and refuses after five failures within fifteen minutes:
<?php
declare(strict_types=1);
for ($i = 0; $i < 6; $i++) {
try {
$auth->attempt('ada@example.com', "guess$i", '198.51.100.9');
} catch (RuntimeException $e) {
echo 'attempt ', $i + 1, ': ', $e->getMessage(), "\n";
}
}
// attempt 6: Too many attempts. Try again later.
Keying on the email alone would let an attacker lock any user out; keying on the IP alone misses attacks spread over
many addresses. Real deployments add a global limit per IP, store the counters in Redis rather than the database, and
place the client IP behind proxies carefully (trust X-Forwarded-For only from your own load balancer). Laravel’s
RateLimiter implements exactly this pattern.
"Remember me"
A persistent login cookie must survive browser restarts without storing the password or anything that can be replayed if the database leaks. The selector/validator scheme does this:
-
the selector (random, stored in clear) finds the row with a normal index lookup;
-
the validator (random, 256 bits) is stored only as a SHA-256 hash and compared with
hash_equals(), so a stolen database does not yield usable cookies, and the comparison does not leak timing; -
the token is single use: it is deleted when consumed, and a new one is issued with the new session.
<?php
declare(strict_types=1);
$cookie = $auth->issueRememberToken($id);
echo strlen($cookie), " character cookie\n"; // 89 character cookie (24 + 1 + 64)
var_dump($auth->consumeRememberToken($cookie)); // int(1): log in, then issue a fresh token
var_dump($auth->consumeRememberToken($cookie)); // NULL: already used
var_dump($auth->consumeRememberToken(explode(':', $auth->issueRememberToken($id))[0] . ':forged')); // NULL
setcookie('remember', $cookie, [
'expires' => time() + 30 * 86400, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax',
]);
Source: hash_equals and setcookie
A plain SHA-256 hash is right here (unlike for passwords) because the validator is 256 random bits: there is nothing to guess, so a slow hash adds no protection. Treat a remember-me login as weaker than a password login: ask for the password again before changing the email, the password or payment details.
Password reset
The reset flow emails a link with a token. The token must be unpredictable (random_bytes(), never md5(uniqid())
or rand()), stored only as a hash, expire quickly (one hour here), be usable once, and the response to "reset my
password" must be the same whether or not the email exists:
<?php
declare(strict_types=1);
$token = $auth->issueResetToken('ada@example.com'); // email https://bookshelf.test/reset?token=...
var_dump($auth->issueResetToken('nobody@example.com')); // NULL, but show the same message to the user
var_dump($auth->resetPassword($token, 'a brand new passphrase')); // bool(true)
var_dump($auth->resetPassword($token, 'again')); // bool(false): single use
var_dump($auth->attempt('ada@example.com', 'a brand new passphrase', '203.0.113.7')); // int(1)
Source: OWASP — Forgot Password Cheat Sheet and random_bytes
resetPassword() consumes the token with DELETE ... RETURNING (SQLite 3.35+, PostgreSQL, MariaDB 10.5+), so two
concurrent requests cannot both use it, and it deletes all remember-me tokens: a password change should end every
other session. Send the reset link to the address on file only, never to an address supplied in the request.
HTTP Basic authentication
HTTP Basic authentication asks the browser for a username and password with a 401 and a WWW-Authenticate
header; the browser then sends them base64-encoded (not encrypted) in every request’s Authorization header. It is
suitable for a small admin area or an internal tool, only over HTTPS. PHP exposes the credentials as
$_SERVER['PHP_AUTH_USER'] and $_SERVER['PHP_AUTH_PW']:
<?php
declare(strict_types=1);
// HTTP Basic authentication for a small admin area (only over HTTPS)
$user = $_SERVER['PHP_AUTH_USER'] ?? '';
$password = $_SERVER['PHP_AUTH_PW'] ?? '';
$expectedHash = getenv('ADMIN_PASSWORD_HASH') ?: ''; // a password_hash() value from the environment
if (!hash_equals('admin', $user) || !password_verify($password, $expectedHash)) {
header('WWW-Authenticate: Basic realm="Bookshelf admin", charset="UTF-8"');
http_response_code(401);
echo "Authentication required\n";
exit;
}
echo "Hello, admin\n";
$ curl -i localhost:8000/admin.php
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="Bookshelf admin", charset="UTF-8"
Authentication required
$ curl -u admin:wrong localhost:8000/admin.php
Authentication required
$ curl -u admin:s3cret-admin-pass localhost:8000/admin.php
Hello, admin
Source: PHP Manual — HTTP authentication with PHP and RFC 7617 — The Basic HTTP Authentication Scheme
PHP_AUTH_USER was filled under both php -S and PHP-FPM behind Nginx in the test runs. Some CGI setups and
Apache configurations do not pass the Authorization header to PHP; then read $_SERVER['HTTP_AUTHORIZATION']
(Apache needs CGIPassAuth On). There is no logout in Basic authentication: the browser keeps sending the credentials
until it is closed.
Where older books and tutorials are outdated
The authentication code in the older books contains several mistakes that must not be copied:
-
Plain-text passwords in Nixon’s social-network capstone (ch. 27) and Kromann’s auto-login (ch. 17): store only
password_hash()output. -
MD5 or SHA-1 password hashes (Wiley ch. 8 and 15) and reversible
AES_ENCRYPT()storage with the key interpolated into SQL (Powers ch. 19): fast or reversible hashes are cracked in bulk after a breach. -
Binding
password_hash()output intoWHERE password = ?(Kromann ch. 14) can never match, because every hash has a new salt: fetch the hash by email, then callpassword_verify(). -
Passwords passed through
strip_tags()orhtmlentities()before hashing (Nixon ch. 12 and 27) silently change the password; hash exactly what the user typed. -
md5(uniqid(rand()))reset and CSRF tokens (Kromann ch. 14, Wiley ch. 8) are predictable; usebin2hex(random_bytes(32)). Kromann’s reset token also never expires and uses an off-by-onerandom_int()range; tokens must expire and be single use. -
bcrypt cost 10 in examples written before PHP 8.4: the default is now 12, and
password_needs_rehash()upgrades existing hashes at login.
References
-
PHP Manual — Password Hashing: password_hash, password_verify, password_needs_rehash
-
RFC: Increasing the default BCrypt cost (PHP 8.4)
-
OWASP — Authentication Cheat Sheet, Password Storage Cheat Sheet and Forgot Password Cheat Sheet
-
Robin Nixon — Learning PHP, MySQL & JavaScript, 5th ed. (O’Reilly, 2018), Frank M. Kromann — Beginning PHP and MySQL, 5th ed. (Apress, 2018), David Powers — PHP 7 Solutions, 4th ed. (Apress, 2019) and Porębski et al. — Building PHP Applications with Symfony, CakePHP, and Zend Framework (Wiley, 2011)