A session-based PHP login that authenticates users securely — hashed passwords, session fixation defence, hardened cookies, brute-force lockout, CSRF protection, and user-enumeration-resistant error messages.
This project demonstrates a minimal but production-shaped login flow. A user submits a username and password; the server verifies the credential against a password_hash() digest, and on success rotates the session ID and stores an authenticated identity.
Threat model and the concepts it demonstrates:
- Credential theft at rest — passwords stored as bcrypt/Argon2 digests via
password_hash(), never plaintext ormd5/sha1. - Session fixation —
session_regenerate_id(true)on privilege change. - Session hijacking —
Secure,HttpOnly,SameSitecookie flags. - Brute force / credential stuffing — throttling and lockout keyed by both username and client IP.
- User enumeration — one generic failure message and constant-ish work for both "no such user" and "wrong password".
- CSRF — a per-session token bound to the login form and checked with
hash_equals().
By the end of this project you should be able to:
- Store and verify credentials with
password_hash()/password_verify()instead of reversible or fast hashes. - Harden a PHP session with
Secure,HttpOnly, andSameSitecookie flags set beforesession_start(). - Prevent session fixation by rotating the session ID with
session_regenerate_id(true)on authentication. - Implement a per-session CSRF token generated with
random_bytes()and verified in constant time withhash_equals(). - Throttle brute-force and credential-stuffing attempts keyed by both client IP and username.
- Write login flows that resist user enumeration through generic errors and timing-equalised verification.
Browser ──POST /login (username, password, csrf_token)──▶ public/login.php
│
┌───────────────────────┼───────────────────────┐
▼ ▼ ▼
CSRF verify LoginThrottle Database (PDO)
(hash_equals) (attempts by ip+user) SELECT user by name
│ │ │
└───────────────────────┴───────────────────────┘
▼
password_verify() ── ok ──▶ session_regenerate_id(true)
│ store $_SESSION['uid']
└── fail ─▶ record attempt + generic error
login-system/
├── public/ # web root (only this directory is served)
│ ├── login.php # form + POST handler
│ └── logout.php
├── src/
│ ├── Database.php # PDO connection factory
│ ├── Csrf.php # token issue + verify
│ └── LoginThrottle.php # brute-force lockout
├── config/
│ └── config.php # DSN + credentials (outside public/)
└── sql/
└── schema.sql
CREATE TABLE users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL, -- bcrypt/Argon2 output from password_hash()
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE login_attempts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
ip_address VARBINARY(16) NOT NULL, -- inet_pton() form (v4 or v6)
username VARCHAR(64) NOT NULL,
attempted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_ip_time (ip_address, attempted_at),
INDEX idx_user_time (username, attempted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;<?php
// Kept OUTSIDE the web root so it is never served as text.
return [
'dsn' => 'mysql:host=127.0.0.1;dbname=app;charset=utf8mb4',
'user' => 'app_user',
'pass' => getenv('DB_PASS') ?: '', // prefer env vars over literals
];<?php
final class Database
{
private static ?PDO $pdo = null;
public static function get(): PDO
{
if (self::$pdo === null) {
$cfg = require __DIR__ . '/../config/config.php';
self::$pdo = new PDO($cfg['dsn'], $cfg['user'], $cfg['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // real prepared statements
]);
}
return self::$pdo;
}
}<?php
final class Csrf
{
public static function token(): string
{
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf'];
}
public static function check(?string $sent): bool
{
// hash_equals is constant-time: defeats timing side-channels.
return is_string($sent)
&& !empty($_SESSION['csrf'])
&& hash_equals($_SESSION['csrf'], $sent);
}
}[!warning] Insecure vs secure token check The naive
if ($_POST['csrf'] === $_SESSION['csrf'])leaks timing information and often skips the "token exists" check, so an empty/absent token can pass. Always generate the token withrandom_bytes()(notrand()/uniqid()) and compare withhash_equals().
<?php
final class LoginThrottle
{
private const MAX_ATTEMPTS = 5; // per window
private const WINDOW_MIN = 15; // minutes
public static function lockedOut(PDO $db, string $ip, string $user): bool
{
$stmt = $db->prepare(
'SELECT COUNT(*) FROM login_attempts
WHERE (ip_address = :ip OR username = :user)
AND attempted_at > (NOW() - INTERVAL :mins MINUTE)'
);
$stmt->bindValue(':ip', inet_pton($ip), PDO::PARAM_STR);
$stmt->bindValue(':user', $user, PDO::PARAM_STR);
$stmt->bindValue(':mins', self::WINDOW_MIN, PDO::PARAM_INT);
$stmt->execute();
return (int) $stmt->fetchColumn() >= self::MAX_ATTEMPTS;
}
public static function record(PDO $db, string $ip, string $user): void
{
$stmt = $db->prepare(
'INSERT INTO login_attempts (ip_address, username) VALUES (:ip, :user)'
);
$stmt->execute([':ip' => inet_pton($ip), ':user' => $user]);
}
public static function clear(PDO $db, string $ip, string $user): void
{
$stmt = $db->prepare(
'DELETE FROM login_attempts WHERE ip_address = :ip OR username = :user'
);
$stmt->execute([':ip' => inet_pton($ip), ':user' => $user]);
}
}<?php
// Harden the session cookie BEFORE session_start().
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => true, // only sent over HTTPS
'httponly' => true, // unreadable to JavaScript (mitigates XSS cookie theft)
'samesite' => 'Strict', // not sent on cross-site requests (CSRF defence-in-depth)
]);
session_start();
require __DIR__ . '/../src/Database.php';
require __DIR__ . '/../src/Csrf.php';
require __DIR__ . '/../src/LoginThrottle.php';
$db = Database::get();
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = trim((string) filter_input(INPUT_POST, 'username'));
$password = (string) ($_POST['password'] ?? '');
if (!Csrf::check($_POST['csrf'] ?? null)) {
$error = 'Your session expired. Please try again.';
} elseif (LoginThrottle::lockedOut($db, $ip, $username)) {
$error = 'Too many attempts. Try again later.';
} else {
$stmt = $db->prepare('SELECT id, password_hash FROM users WHERE username = :u LIMIT 1');
$stmt->execute([':u' => $username]);
$user = $stmt->fetch();
// Verify even when the user does not exist, against a dummy hash, so the
// response time does not reveal whether the username is valid.
$hash = $user['password_hash']
?? '$2y$12$usesomesillystringforsalt0000000000000000000000000000000';
if ($user && password_verify($password, $hash)) {
LoginThrottle::clear($db, $ip, $username);
session_regenerate_id(true); // defeat session fixation
$_SESSION['uid'] = (int) $user['id'];
unset($_SESSION['csrf']); // rotate CSRF secret post-auth
header('Location: /dashboard.php');
exit;
}
LoginThrottle::record($db, $ip, $username);
$error = 'Invalid username or password.'; // generic: no user enumeration
}
}
?><!doctype html>
<meta charset="utf-8">
<title>Sign in</title>
<?php if ($error !== ''): ?>
<p class="error"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>
<form method="post" action="/login.php" autocomplete="on">
<input type="hidden" name="csrf"
value="<?= htmlspecialchars(Csrf::token(), ENT_QUOTES, 'UTF-8') ?>">
<label>Username <input name="username" required></label>
<label>Password <input type="password" name="password" required></label>
<button type="submit">Sign in</button>
</form><?php
session_start();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
header('Location: /login.php');
exit;[!warning] Enumeration through error messages Distinct messages — "user not found" vs "wrong password" — let an attacker enumerate valid usernames, and short-circuiting
password_verify()when the user is missing creates a timing oracle. The secure handler returns a single message and runspassword_verify()against a dummy hash for unknown users so both paths cost roughly the same.
| Risk | Mitigation in this project |
|---|---|
| SQL injection | Every query uses PDO prepared statements with bound parameters; PDO::ATTR_EMULATE_PREPARES => false. |
| Password disclosure | password_hash($pw, PASSWORD_DEFAULT) at rest; password_verify() to check. Never md5/sha1. |
| Session fixation | session_regenerate_id(true) immediately after successful auth. |
| Session hijacking / XSS cookie theft | Secure + HttpOnly + SameSite=Strict cookie flags. |
| Brute force / credential stuffing | login_attempts throttle keyed by IP and username, 5 tries / 15 min. |
| User enumeration | Single generic error; dummy-hash verify equalises timing. |
| CSRF | random_bytes() token per session, hash_equals() verify, SameSite cookie. |
| Reflected XSS | All dynamic output escaped with htmlspecialchars(..., ENT_QUOTES, 'UTF-8'). |
See OWASP Authentication and Session Management cheat sheets.
- Add multi-factor authentication (TOTP) as a second step after password verify.
- Rehash on login when
password_needs_rehash()reports an outdated cost/algorithm. - Move throttling to a fast store (Redis) and add an exponential back-off delay.
- Enforce HTTPS with HSTS and add a CAPTCHA after repeated failures.
- Log authentication events for detection/alerting without recording secrets.
- PHP Manual — password_hash()
- PHP Manual — session_regenerate_id()
- OWASP Cheat Sheet — Authentication
- OWASP Cheat Sheet — Session Management
- Password-Hashing — hashing and verifying credentials
- CSRF-Protection-Basics — token generation and verification
- Session-Security and Secure-Session-Configuration — hardening PHP sessions
- Prepared-Statements — parameterised PDO queries
- Mini Projects — index
- Secure PHP Development — course home