First PHP Projects

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 example
<?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 example
<?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 example
<?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 example
<?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 example
<?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_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.