First PHP Projects

Login Authentication Flow

Store Only Password Hashes

The user table keeps a flexible hash string and a current role:

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'))
);

Generate hashes with password_hash($password, PASSWORD_DEFAULT). Never store, log, or include the plaintext password in a seed file.

Create one dummy hash during setup and provide it as DUMMY_PASSWORD_HASH. Unknown users verify against that hash so they do not skip the expensive password work.

Configure The Session First

Settings must be applied before session_start():

PHP example
// Partial: HTTP bootstrap before any output.
ini_set('session.use_strict_mode', '1');
session_set_cookie_params([
    'path' => '/',
    'secure' => $isHttps,
    'httponly' => true,
    'samesite' => 'Lax',
]);
session_start();

$_SESSION['csrf'] ??= bin2hex(random_bytes(32));

Production requires HTTPS and a Secure cookie. Strict mode rejects attacker-supplied session IDs that PHP did not issue.

Throttle Known And Unknown Accounts

Use a keyed identifier instead of storing raw email and address in the throttle table:

PHP example
<?php

declare(strict_types=1);

function loginAttemptKey(string $email, string $address, string $secret): string
{
    $identity = strtolower(trim($email)) . "\0" . $address;

    return hash_hmac('sha256', $identity, $secret);
}

Use REMOTE_ADDR unless a trusted-proxy policy has already validated a forwarded address. The HMAC secret is independent from password hashes and absent from Git.

For the single-instance SQLite project, serialize count-and-record with an immediate transaction:

PHP example
// Partial: start of reserveLoginAttempt().
$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 five events already exist, roll back and return false. Otherwise insert the current event and commit:

PHP example
// Partial: successful reservation inside the same try block.
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;

The catch block rolls back when a transaction remains active, then rethrows. This design is scoped to one SQLite database; multi-server deployment needs a shared limiter.

Verify A Hash On Every Attempt

After method and CSRF checks, normalize only scalar credentials and require security configuration:

PHP example
// Partial: start of the login POST handler.
$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.');
}

Reserve throttle capacity before querying or hashing. Then choose a real or dummy hash:

PHP example
// Partial: after a successful throttle reservation.
$statement = $pdo->prepare(
    'SELECT id, 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.');
}

The public response is identical for unknown email and wrong password, and both consume throttle capacity.

Upgrade And Regenerate On Success

Only a real authenticated user reaches the success branch:

PHP example
// Partial: successful login branch.
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));

Regeneration prevents the pre-login session ID from remaining authenticated. Rotate CSRF state with the authentication transition, then return a 303 redirect.

Authorize Current Database State

Store only the user ID in the session. Each protected request reloads the role:

PHP example
// Partial: currentUser(PDO $pdo, mixed $sessionUserId).
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;

A missing user produces 401; a current non-admin role produces 403. Hiding links is presentation, not authorization. Role revocation must take effect on the next request.

Logout is POST-only and CSRF-protected. After verification, clear the server data, expire the cookie with its original attributes, call session_destroy(), and redirect with 303. A GET link must not log users out.

Verify known and unknown failures, array-shaped credentials, five reserved failures, the blocked sixth attempt, successful reset, rehashing, session ID rotation, role revocation, anonymous/non-admin writes, bad logout CSRF, 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_DEFAULT hashes, never plaintext passwords.
  • Configure strict sessions and cookie attributes before starting the session.
  • Require HTTPS and Secure cookies 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 401 from unauthorized 403 outcomes.
  • 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 example
<?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 example
<?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.