PHP Development

How to Build a Secure PHP Login System with Password Hashing & Sessions

Step-by-step tutorial on architecting a secure authentication system in PHP 8 using password_hash(), session regeneration, and PDO prepared statements.

1. Essential Security Requirements for Authentication

Authentication is the front gate of any web application. A production-ready login system must guarantee three foundational pillars: safe credential storage (using modern slow hashing algorithms), strict transport/session management (stopping hijacking and fixation), and parameterized database queries (eliminating SQL injection).

2. Relational User Schema Design

Always allocate at least 255 characters for password hashes to accommodate future algorithm upgrades (such as Argon2id) without database truncation errors:

CREATE TABLE users (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    email VARCHAR(180) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_user_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

3. Registering Users with password_hash()

<?php
declare(strict_types=1);

// Hash password using default modern cost parameters (Bcrypt)
$password = $_POST['password'] ?? '';
$email = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);

if (!$email || strlen($password) < 12) {
    throw new InvalidArgumentException('Password must be at least 12 characters.');
}

$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);

$stmt = $pdo->prepare('INSERT INTO users (email, password_hash) VALUES (:email, :hash)');
$stmt->execute([
    ':email' => $email,
    ':hash'  => $hash
]);

4. Validating Credentials with password_verify()

<?php
declare(strict_types=1);

$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';

$stmt = $pdo->prepare('SELECT id, email, password_hash FROM users WHERE email = :email LIMIT 1');
$stmt->execute([':email' => $email]);
$user = $stmt->fetch();

// Constant-time verification prevents timing attacks
if ($user && password_verify($password, $user['password_hash'])) {
    // Regenerate session ID to prevent session fixation
    session_regenerate_id(true);

    $_SESSION['user_id'] = $user['id'];
    $_SESSION['logged_in_at'] = time();

    // Check if algorithm requires rehash under updated system cost
    if (password_needs_rehash($user['password_hash'], PASSWORD_BCRYPT, ['cost' => 12])) {
        $newHash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
        $update = $pdo->prepare('UPDATE users SET password_hash = :hash WHERE id = :id');
        $update->execute([':hash' => $newHash, ':id' => $user['id']]);
    }

    header('Location: /dashboard');
    exit;
} else {
    // Generic error message prevents account enumeration
    $error = 'Invalid email address or password provided.';
}

Key Takeaways

  • Never store plaintext passwords or outdated hashes (MD5, SHA1).
  • Always call session_regenerate_id(true) immediately upon successful authentication.
  • Return identical, generic error messages for both non-existent emails and wrong passwords to eliminate account harvesting.
Marcus Thorne

Marcus Thorne

Head of Technology & Web Engineering

Marcus is a full-stack engineer and educator who has architected fintech payment gateways, secure authentication engines, and performant data pipelines using modern PHP and MySQL.

Editorial Transparency & Disclaimer: The educational content published on AureviaFin is researched and authored independently. It does not constitute individualized financial, investment, or legal counsel. See our full Financial Disclaimer and Editorial Policy.