Login Authentication Flow
Add administrator login to the product manager. Authentication establishes who is making the request. Authorization checks whether that current user may create, edit, or delete products. Both decisions must happen on the server.
Store Users And Failure Events
Add a second migration:
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL COLLATE NOCASE UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('viewer', 'admin'))
);
CREATE TABLE login_failures (
attempt_key TEXT NOT NULL,
failed_at INTEGER NOT NULL
);
CREATE INDEX login_failures_lookup
ON login_failures (attempt_key, failed_at);
Use TEXT for password_hash so the column does not assume the length of one algorithm. Create seed hashes with password_hash($password, PASSWORD_DEFAULT) in a development setup command; never store or log the plaintext password.
Generate one additional dummy hash during setup and provide it as DUMMY_PASSWORD_HASH. It is used when no user exists so both existing and unknown accounts still perform an expensive password_verify() call. Also provide an independent random LOGIN_RATE_LIMIT_KEY for hashing throttle identifiers. Fail application startup when either configuration value is absent.
Start A Hardened Session
Cookie settings and strict mode must be configured before session_start():
<?php
declare(strict_types=1);
// no-execute: requires an HTTP request and application HTTPS policy.
ini_set('session.use_strict_mode', '1');
$isHttps = ($_SERVER['HTTPS'] ?? '') !== '' && ($_SERVER['HTTPS'] ?? '') !== 'off';
session_set_cookie_params([
'path' => '/',
'secure' => $isHttps,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
Production must serve login over HTTPS and therefore issue a Secure cookie. When TLS terminates at a proxy, derive HTTPS state only from proxy headers that the application is explicitly configured to trust.
Reserve Attempts Atomically
A public error message alone does not stop account discovery if unknown users skip password hashing. Rate limiting must also cover unknown accounts. Derive a nonreversible key from the normalized email and client address, and never trust a forwarded address unless a trusted-proxy policy has already validated it.
For this single-instance SQLite project, reserve each attempt inside an immediate transaction:
<?php
declare(strict_types=1);
function loginAttemptKey(string $email, string $address, string $secret): string
{
return hash_hmac('sha256', strtolower(trim($email)) . "\0" . $address, $secret);
}
function reserveLoginAttempt(PDO $pdo, string $key, int $now): bool
{
$windowStart = $now - 900;
$pdo->exec('BEGIN IMMEDIATE');
try {
$delete = $pdo->prepare('DELETE FROM login_failures WHERE failed_at < :cutoff');
$delete->execute(['cutoff' => $windowStart]);
$count = $pdo->prepare(
'SELECT COUNT(*) FROM login_failures
WHERE attempt_key = :attempt_key AND failed_at >= :cutoff'
);
$count->execute(['attempt_key' => $key, 'cutoff' => $windowStart]);
if ((int) $count->fetchColumn() >= 5) {
$pdo->rollBack();
return false;
}
$insert = $pdo->prepare(
'INSERT INTO login_failures (attempt_key, failed_at)
VALUES (:attempt_key, :failed_at)'
);
$insert->execute(['attempt_key' => $key, 'failed_at' => $now]);
$pdo->commit();
return true;
} catch (Throwable $exception) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $exception;
}
}
function clearLoginAttempts(PDO $pdo, string $key): void
{
$statement = $pdo->prepare('DELETE FROM login_failures WHERE attempt_key = :attempt_key');
$statement->execute(['attempt_key' => $key]);
}
The transaction prevents concurrent requests on this SQLite database from all passing the count before recording themselves. A multi-server deployment needs a shared, purpose-built limiter; this implementation is deliberately scoped to the local project.
Verify A Password On Every Attempt
The login handler accepts POST only and verifies its CSRF token before reserving work:
<?php
declare(strict_types=1);
// no-execute: requires configured session, PDO, CSRF token, and environment secrets.
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
header('Allow: POST');
http_response_code(405);
exit('Method Not Allowed');
}
$token = $_POST['_csrf'] ?? null;
if (!is_string($token) || !hash_equals($_SESSION['csrf'], $token)) {
http_response_code(403);
exit('Invalid request token.');
}
$email = is_string($_POST['email'] ?? null) ? strtolower(trim($_POST['email'])) : '';
$password = is_string($_POST['password'] ?? null) ? $_POST['password'] : '';
$address = is_string($_SERVER['REMOTE_ADDR'] ?? null) ? $_SERVER['REMOTE_ADDR'] : 'unknown';
$rateSecret = getenv('LOGIN_RATE_LIMIT_KEY');
$dummyHash = getenv('DUMMY_PASSWORD_HASH');
if (!is_string($rateSecret) || $rateSecret === '' || !is_string($dummyHash) || $dummyHash === '') {
throw new RuntimeException('Login security configuration is missing.');
}
$attemptKey = loginAttemptKey($email, $address, $rateSecret);
if (!reserveLoginAttempt($pdo, $attemptKey, time())) {
http_response_code(429);
exit('Login temporarily unavailable.');
}
$statement = $pdo->prepare(
'SELECT id, email, password_hash, role FROM users WHERE email = :email'
);
$statement->execute(['email' => $email]);
$user = $statement->fetch();
$hash = is_array($user) && is_string($user['password_hash'] ?? null)
? $user['password_hash']
: $dummyHash;
$authenticated = password_verify($password, $hash);
if (!$authenticated || !is_array($user)) {
http_response_code(401);
exit('Invalid email or password.');
}
if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
$rehash = $pdo->prepare('UPDATE users SET password_hash = :hash WHERE id = :id');
$rehash->execute([
'hash' => password_hash($password, PASSWORD_DEFAULT),
'id' => $user['id'],
]);
}
clearLoginAttempts($pdo, $attemptKey);
session_regenerate_id(true);
$_SESSION['user_id'] = (int) $user['id'];
$_SESSION['csrf'] = bin2hex(random_bytes(32));
header('Location: /products.php', true, 303);
exit;
Do not write whether the email existed to public logs or responses. Operational security logs may record a keyed identifier and outcome, but not plaintext passwords or session IDs.
Load Authorization From Current Data
Store only the user ID in the session. On each protected request, load the current database row so a role change or deleted account takes effect without waiting for the session to expire:
<?php
declare(strict_types=1);
function currentUser(PDO $pdo, mixed $sessionUserId): ?array
{
if (!is_int($sessionUserId) || $sessionUserId < 1) {
return null;
}
$statement = $pdo->prepare('SELECT id, email, role FROM users WHERE id = :id');
$statement->execute(['id' => $sessionUserId]);
$user = $statement->fetch();
return $user === false ? null : $user;
}
function requireAdmin(?array $user): void
{
if ($user === null) {
http_response_code(401);
exit('Authentication required.');
}
if (($user['role'] ?? null) !== 'admin') {
http_response_code(403);
exit('Forbidden');
}
}
Call the guard before every create, update, and delete operation. Hiding navigation links is presentation, not authorization.
Logout Completely
Logout is a CSRF-protected POST. After verifying the token, clear server-side state and expire the browser cookie using the same attributes with which it was created:
<?php
declare(strict_types=1);
// no-execute: requires an active session and verified POST CSRF token.
$_SESSION = [];
$params = session_get_cookie_params();
setcookie(session_name(), '', [
'expires' => time() - 42000,
'path' => $params['path'],
'domain' => $params['domain'],
'secure' => $params['secure'],
'httponly' => $params['httponly'],
'samesite' => $params['samesite'],
]);
session_destroy();
header('Location: /login.php', true, 303);
exit;
Verify valid login, wrong password, unknown email, array-shaped input, regenerated session ID, password rehash, anonymous and non-admin writes, role revocation, five failures, blocked attempts, successful reset, POST-only logout, and loss of access after logout.
Practice
Practice: Build A Session Login Flow
Implement password-and-session administrator authentication for the product manager.
Requirements
- Store
PASSWORD_DEFAULThashes, never plaintext passwords. - Configure strict sessions and cookie attributes before starting the session.
- Require HTTPS and
Securecookies in production. - Normalize scalar email input and reject non-scalar credentials safely.
- Perform
password_verify()for both known and unknown accounts using a configured dummy hash. - Reserve attempts in the SQLite failure limiter before password verification.
- Return the same credential failure for unknown users and wrong passwords.
- Rehash a valid password when
password_needs_rehash()requests it. - Regenerate the session ID and CSRF token after login.
- Store only the user ID in the session and reload the current role for protected requests.
- Distinguish unauthenticated
401from unauthorized403outcomes. - Make logout POST-only and CSRF-protected; clear session data, cookie, and server state.
- Do not trust forwarded client addresses without a trusted-proxy policy.
Verify login, enumeration-resistant failure behavior, throttling, role changes, session regeneration, rehashing, logout, and every protected product write.
Show solution
Apply the user and failure-event migration, configure the session, and use the complete attempt reservation and login handler from the lesson. Generate the dummy hash once during setup rather than on every request, then supply both required secrets through the application's environment configuration.
Every protected handler reloads the session user and checks the current role:
<?php
declare(strict_types=1);
// no-execute: requires application bootstrap, active session, and PDO.
$currentUser = currentUser($pdo, $_SESSION['user_id'] ?? null);
requireAdmin($currentUser);
A complete logout handler checks method and CSRF before destroying anything:
<?php
declare(strict_types=1);
// no-execute: requires application bootstrap and an active session.
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
header('Allow: POST');
http_response_code(405);
exit('Method Not Allowed');
}
$token = $_POST['_csrf'] ?? null;
if (!is_string($token) || !hash_equals($_SESSION['csrf'], $token)) {
http_response_code(403);
exit('Invalid request token.');
}
$_SESSION = [];
$params = session_get_cookie_params();
setcookie(session_name(), '', [
'expires' => time() - 42000,
'path' => $params['path'],
'domain' => $params['domain'],
'secure' => $params['secure'],
'httponly' => $params['httponly'],
'samesite' => $params['samesite'],
]);
session_destroy();
header('Location: /login.php', true, 303);
exit;
Test unknown and known-account failures through the same public response. Confirm the limiter records both, blocks the sixth attempt within 15 minutes, and clears the keyed events after success. After changing an administrator to viewer directly in SQLite, the next protected request must return 403 without requiring logout.