Building a Secure Authentication System
Design and build a complete authentication system covering registration, password hashing, session-based login with fixation protection, CSRF protection, password reset via signed tokens, login rate limiting, and role-based authorization.
Authentication is the single most security-sensitive feature most PHP applications ship, and it is also where a huge share of real-world breaches originate — not from exotic attacks, but from basic mistakes like storing plaintext passwords or building login queries with string concatenation. This capstone project draws together the Password Hashing, Sessions, CSRF Protection, and SQL Injection Prevention lessons from earlier in the course into one coherent system: registration, login, logout, password reset, brute-force mitigation, and role-based authorization, built the way a careful PHP developer would actually ship it.
Project Overview and Threat Model
Before writing a line of code, it helps to name what the system is actually defending against, because every design decision in this project traces back to a specific threat. Registration must prevent duplicate accounts and reject weak or malformed input. Login must never allow an attacker who steals the database to recover usable passwords, must resist session fixation, and must resist automated brute-force and credential-stuffing attempts against the login form. Every form that changes state (registration, login, password reset, profile updates) must resist Cross-Site Request Forgery, where a malicious page tricks a logged-in user's browser into submitting a request on their behalf. Password reset must let a legitimate user recover access without ever emailing a real password or an easily-guessable token. And once logged in, users with different roles must be prevented from performing actions their role doesn't permit, even if they know the right URL to try.
None of these are exotic requirements — they are the baseline expectations for any authentication system handling real user accounts, and each has a well-established, standard solution in PHP: password_hash()/password_verify() for passwords, session_regenerate_id() for fixation, a CSRF token synchronizer pattern for forgery, HMAC-signed time-limited tokens for reset, a persistent attempt counter for rate limiting, and a simple role check before every sensitive action for authorization. This project implements all six, and just as importantly, explains *why* each one is necessary — because an authentication system built by pattern-matching without understanding the underlying threat is the kind that quietly fails the first time an attacker deviates slightly from the expected happy path.
The data model needs a users table with id, email (unique), password_hash, role (e.g. member or admin), created_at, plus a small login_attempts table (or a Redis/cache-backed counter, for a production system) to track rate limiting, and a password_resets table to store reset token metadata — never the plaintext token itself, for reasons covered later in this lesson.
• Threats addressed: plaintext password exposure, session fixation, CSRF, brute force/credential stuffing, insecure password reset, missing authorization checks. • Each threat maps to one standard PHP mechanism: password_hash/verify, session_regenerate_id, CSRF tokens, signed reset tokens, attempt-counting rate limits, role checks. • Data model: users (id, email, password_hash, role), login_attempts (or cache-backed counter), password_resets (hashed token, expiry). • Security through understanding the threat, not just copying a pattern — this is what lets the system hold up against attacks that deviate from the expected case.
Registration: Validation, Duplicates, and Hashing
The registration handler validates every field before touching the database: email format via filter_var($email, FILTER_VALIDATE_EMAIL), a minimum password length (at least 8 characters, following current NIST-influenced guidance rather than the outdated 'must contain a symbol and a number' rules that tend to push users toward predictable substitutions), and a password-confirmation field that must match. Only after these pass does the handler check for a duplicate email — with a SELECT id FROM users WHERE email = :email prepared statement, never a raw concatenated query — and reject registration with a generic 'that email is already registered, or verify your details' style message that avoids confirming or denying account existence in ways that could aid an attacker enumerating valid emails, depending on how strict the application wants to be.
The password itself is hashed with password_hash($password, PASSWORD_DEFAULT) exactly as covered in the Password Hashing lesson — never MD5, SHA-1, or any general-purpose hash function, because those are designed to be fast, and fast is exactly the wrong property for a password hash: it's what lets an attacker with a stolen database try billions of guesses per second. PASSWORD_DEFAULT currently maps to bcrypt and automatically handles generating a unique random salt per password, embedding it in the resulting hash string, so no separate salt column or salt-management code is needed anywhere in this system.
The password_hash() output — never the plaintext password — is the only thing written to the password_hash column. Once that INSERT succeeds, the plaintext password variable should be allowed to fall out of scope as soon as possible and never logged, written to a file, or included in an error message, even during development, since logs are a common place where 'temporary' plaintext leaks end up living far longer than intended.
• Validate format and confirm-password match before ever querying the database. • Check for a duplicate email with a prepared statement, and reply with a message that doesn't unnecessarily confirm account existence. • password_hash($password, PASSWORD_DEFAULT) — bcrypt with an automatic per-password random salt — is the only acceptable way to store a password. • Never log, echo, or persist the plaintext password anywhere, even temporarily, even in development.
Login: Sessions and Preventing Fixation
The login handler looks up the user by email, then calls password_verify($submittedPassword, $storedHash) — never a manual comparison against a hash the application computes itself — because password_verify() performs the comparison in constant time relative to the correct hash, closing off a timing side-channel that a naive === comparison could theoretically leak information through. A failed lookup (no such email) and a failed password check should produce the exact same generic error message ('invalid email or password') and take roughly the same amount of time, so an attacker cannot use the response to determine whether a guessed email even has an account — a subtle but well-documented account-enumeration risk.
On a successful login, the single most important line most tutorials skip is session_regenerate_id(true), called immediately after authentication succeeds and before writing any session data like the user's id or role. Without it, the system is vulnerable to session fixation: an attacker who can set a victim's session ID before login (for example, by sending them a link containing a session ID in the URL, or via a subdomain cookie) could then use that same known session ID to hijack the now-authenticated session after the victim logs in. Regenerating the session ID at the moment privilege changes — logging in, and again on logout — invalidates whatever ID existed beforehand and issues a fresh one the attacker never saw.
Only the user's id and role are stored in $_SESSION, never the password hash or other sensitive fields — the session store should hold the minimum needed to identify and authorize the user, with everything else fetched fresh from the database when needed. Logout should both call session_destroy() and explicitly clear the session cookie by setting an expired cookie with the same name, since some browsers otherwise retain a now-invalid cookie value that could confuse subsequent requests.
• password_verify() does a constant-time comparison against the stored hash — never compare hashes manually. • Unknown email and wrong password return the same generic message, at roughly the same speed, to resist account enumeration. • session_regenerate_id(true) immediately after successful login (and again on logout) prevents session fixation. • Store only user id and role in $_SESSION — never the password hash.
CSRF Protection on Every State-Changing Form
Every form that changes server state — registration, login, password reset request and confirmation, and any future profile-edit or admin action — includes a hidden CSRF token generated with bin2hex(random_bytes(32)), stored in $_SESSION['csrf_token'], and rendered into the form as a hidden input. On submission, the handler compares the submitted token to the session's stored token using hash_equals() (not ===, again to avoid a timing side-channel) before processing the request at all, and rejects the request outright — without touching the database — if the tokens don't match or the session token is missing.
It's worth being explicit about why the login form itself needs CSRF protection, which surprises some developers who assume CSRF only matters for already-authenticated actions: without it, an attacker could force a victim's browser to submit a login request using the *attacker's* own credentials, logging the victim into the attacker's account without their knowledge — a technique called login CSRF that can be used to trick a victim into unknowingly saving sensitive data (search history, payment details entered later) into an account the attacker controls.
Regenerating the CSRF token after each successful state-changing submission (not just after login) is good practice to limit the token's exposure window, though a per-session token that persists for the life of the session, refreshed at least at login, is an acceptable and common middle ground that this project adopts for simplicity.
• Every state-changing form gets a hidden CSRF token stored in the session and compared with hash_equals(). • hash_equals() avoids timing side-channels; == or === does not give that guarantee. • A mismatched or missing token rejects the request before any database work happens. • Even the login form needs CSRF protection — otherwise an attacker can force login CSRF using their own credentials.
Password Reset: Time-Limited Signed Tokens
When a user requests a password reset, the system never emails a new password directly — emailing a password (new or existing) means it sits in plaintext in an inbox, in mail server logs, and potentially in transit, permanently expanding the attack surface for that account. Instead, the handler generates a cryptographically random token with bin2hex(random_bytes(32)), stores only its SHA-256 hash (via hash('sha256', $token)) together with the user's id and an expiry timestamp (typically 15–60 minutes out) in the password_resets table, and emails the *unhashed* token embedded in a reset link. Storing only the hash means that even if the password_resets table were ever leaked, the tokens inside it couldn't be used directly — exactly the same principle as never storing plaintext passwords, applied to a different kind of secret.
When the user visits the reset link, the handler hashes the submitted token the same way and looks up a matching, non-expired row. A match lets the user submit a new password (validated and hashed with password_hash() exactly as at registration); a non-match or expired row shows a generic 'this reset link is invalid or has expired' message, again without confirming whether the underlying email address exists. Successfully completing a reset should delete the used token row (or mark it consumed) so it cannot be replayed, and — as good practice — invalidate any other outstanding reset tokens for that user, in case an attacker requested one earlier.
The reset-request endpoint itself should also be rate-limited (using the same mechanism covered in the next section), since without a limit an attacker could use it to flood a victim's inbox with reset emails, or attempt to enumerate valid accounts by observing subtle timing or response differences across many requests.
• Never email a raw password — always a single-use, time-limited reset link with a random token. • Store only a hash of the reset token (e.g. SHA-256) in the database, never the token itself. • Expire tokens after a short window (15-60 minutes) and delete/consume them after one successful use. • Rate-limit the reset-request endpoint too, to prevent inbox-flooding and account enumeration.
Rate Limiting Login Attempts
Without a limit, a login form is an open invitation to brute-force every password for a known email, or to credential-stuff a list of email/password pairs leaked from other breaches. This project implements a simple, persistent counter: every failed login attempt records a row (or increments a counter) keyed by a combination of the submitted email and the requesting IP address, with a timestamp. Before processing any login attempt, the handler counts how many failed attempts have been recorded for that key within a recent window (e.g. the last 15 minutes); past a threshold (e.g. 5 attempts), the handler rejects the request immediately with a 'too many attempts, try again later' message and does not even call password_verify(), regardless of whether the submitted password happens to be correct.
Keying the limit on the combination of email and IP, rather than either alone, balances two failure modes: limiting by IP alone lets an attacker rotate through many IPs (common with botnets) to bypass the limit against one target email, while limiting by email alone lets an attacker spread a credential-stuffing attempt across thousands of different emails from one IP without ever tripping a per-email counter. Combining both, or applying a lighter global per-IP limit alongside a stricter per-email limit, covers both attack shapes without either one alone being a full solution.
A successful login should reset the failed-attempt counter for that email/IP pair, since the point is to slow down an attacker still guessing, not to permanently lock out a legitimate user who mistyped their password a few times before getting it right. For a production system, this counter is typically backed by Redis or another fast cache with a built-in expiry (EXPIRE) rather than a MySQL table, both for performance and so old attempt records clean themselves up automatically — but the logic described here works identically against a simple database table for a smaller application.
• Count recent failed attempts per email+IP combination before even calling password_verify(). • Past a threshold within a time window, reject immediately with a generic rate-limit message. • Key on email+IP together — IP-only misses distributed attacks, email-only misses credential stuffing across many accounts. • Reset the counter on a successful login; a cache with automatic expiry (e.g. Redis) is the typical production backing store.
Authorization: Checking Roles Before Sensitive Actions
Authentication answers 'who is this user'; authorization answers 'is this user allowed to do this specific thing', and the two must never be conflated. Every sensitive action — deleting another user's account, viewing another student's private data in the Student Management System project, accessing an admin dashboard — starts with an explicit check of the logged-in user's role or permission, reading $_SESSION['role'] (or, for a larger system, a dedicated permissions table joined at login) and comparing it against what the action requires, before any of the action's actual logic runs.
This check belongs on the server for every single request that performs the sensitive action, not just on the link or button that leads to it. Hiding an 'Delete User' button from non-admins in the rendered HTML is a usability nicety, not a security control — a user who knows or guesses the underlying URL (/admin/delete-user.php?id=7) can submit that request directly regardless of what the UI shows them, so the authorization check inside delete-user.php itself is the only thing that actually prevents an unauthorized deletion, exactly the same client-side-is-not-enough principle covered earlier for form validation.
A clean way to structure this is a small requireRole(string $role): void helper called at the top of every protected script or controller action, which checks the session, and calls http_response_code(403); exit; (or redirects to a login/denied page) immediately if the check fails — fail closed, not open, meaning that any ambiguous or missing session state results in access being denied by default rather than accidentally allowed through.
• Authentication (who you are) and authorization (what you're allowed to do) are separate checks — never conflate them. • Authorization must be enforced server-side on every request to a sensitive action, not just by hiding UI elements. • A requireRole() helper checked at the top of every protected action keeps the check consistent and hard to forget. • Fail closed: any missing or ambiguous session/role state should deny access by default.
Testing and Extending the Authentication System
Following the Testing Authentication Flows and Mocking Sessions lessons, tests should cover: registration rejecting a duplicate email, registration hashing the password (never storing the plaintext — assert the stored value is not equal to the submitted password and that password_verify() succeeds against it), login succeeding with correct credentials and failing with incorrect ones, login regenerating the session id (observable by asserting the session id changes across the call), CSRF rejection when the token is missing or wrong, and the rate limiter blocking a login attempt after the threshold is exceeded within the window.
Realistic extensions include adding two-factor authentication (a TOTP code checked alongside the password, covered conceptually in the Two-Factor Authentication lesson), 'remember me' functionality using a separate long-lived, single-use token stored hashed in its own table (never simply extending the session lifetime, which would keep a stolen session cookie valid far longer than necessary), and an audit log recording every login, failed login, and password reset event with a timestamp and IP address, which is often what actually lets a team notice and respond to a real attack in progress rather than discovering it after the fact.
Code Examples
Validation runs entirely before any query, the duplicate-email check and the insert both use prepared statements, and password_hash(PASSWORD_DEFAULT) is the only thing that ever touches the password_hash column — the plaintext $password variable is never written anywhere else.
$hash = password_hash($password, PASSWORD_DEFAULT);
The rate-limit check runs before password_verify() is ever called, the same generic error covers both an unknown email and a wrong password, and session_regenerate_id(true) is called immediately on success and before any session data is written, closing the session-fixation window.
hash_equals() performs a constant-time comparison so an attacker cannot use response-timing differences to guess the token character by character, and the check happens before any other form processing — including on the login form itself, to prevent login CSRF.
return hash_equals($_SESSION['csrf_token'], $submitted);
$stmt->execute([':hash' => md5($password)]); // or storing $password directly
$stmt->execute([':hash' => password_hash($password, PASSWORD_DEFAULT)]);
Storing a plaintext password, or hashing it with a fast general-purpose function like MD5 or SHA-1, means anyone who steals the database (or an insider with access) immediately has usable passwords or can crack them at billions of guesses per second. password_hash() with PASSWORD_DEFAULT uses a slow, salted algorithm purpose-built to resist this.
if ($storedHash === md5($submittedPassword)) { /* logged in */ } if (password_verify($submittedPassword, $storedHash)) { /* logged in */ }
A manual hash comparison recomputes a fast, unsalted hash and invites both the fast-hash cracking problem above and, with a naive string comparison, a timing side-channel. password_verify() is built specifically to check a password against a password_hash() result safely.
// login succeeds, sets $_SESSION['user_id'] directly with the pre-existing session id session_regenerate_id(true); $_SESSION['user_id'] = $user['id'];
Without regenerating the session id at the moment of login, a session id an attacker set before authentication (session fixation) remains valid and privileged after login, letting the attacker access the now-authenticated session using an id they already knew.
$sql = "SELECT * FROM users WHERE email = '" . $_POST['email'] . "'";
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email'); $stmt->execute([':email' => $_POST['email']]);
Concatenating user input into a login query is a classic SQL injection vector — an email field containing ' OR '1'='1 could bypass authentication entirely. Every query, especially one guarding account access, must use a prepared statement with bound parameters.
Why is password_hash() with PASSWORD_DEFAULT preferred over hashing a password with SHA-256 or MD5? A. SHA-256 and MD5 are not available in PHP 8 B. password_hash() is deterministic while SHA-256 is not C. password_hash() uses a slow, salted algorithm (bcrypt by default) purpose-built to resist brute-force cracking, while general-purpose hashes are designed to be fast D. There is no real difference; either is equally secure for passwords
C. password_hash() uses a slow, salted algorithm (bcrypt by default) purpose-built to resist brute-force cracking, while general-purpose hashes are designed to be fast — General-purpose hash functions like MD5 and SHA-256 are optimized for speed, which is exactly the wrong property for password storage since it lets an attacker with stolen hashes try enormous numbers of guesses quickly; password_hash() uses a deliberately slow, salted algorithm instead.
What specific attack does calling session_regenerate_id(true) immediately after a successful login prevent? A. SQL injection B. Session fixation, where an attacker sets a victim's session id before login and reuses it after the victim authenticates C. Cross-site scripting D. Brute-force password guessing
B. Session fixation, where an attacker sets a victim's session id before login and reuses it after the victim authenticates — If the session id used before login remains valid after login, an attacker who fixed that id on the victim's browser beforehand could use the same id to access the now-authenticated session. Regenerating the id at the point of authentication invalidates whatever id existed before.
Why is a password reset implemented by emailing a time-limited signed token rather than a new plaintext password? A. Emailing a token is faster to implement, with no security benefit B. Emailing a plaintext password leaves it exposed in the inbox, mail logs, and in transit indefinitely, while a token is random, single-use, and expires quickly C. PHP does not allow sending passwords in emails D. Tokens are shorter than passwords so they fit better in a URL
B. Emailing a plaintext password leaves it exposed in the inbox, mail logs, and in transit indefinitely, while a token is random, single-use, and expires quickly — A plaintext password sent by email persists as a readable secret across multiple systems (inbox, mail server logs, transit) indefinitely, while a random, hashed-at-rest, time-limited token minimizes exposure and cannot be reused once consumed or expired.
Why should the login rate limiter key its counter on the combination of email and IP address rather than either alone? A. Keying on both is required by PHP's session functions B. IP-only limiting misses distributed attacks (rotating IPs against one account) while email-only limiting misses credential stuffing spread across many accounts from one IP; combining both covers more attack shapes C. Using both makes the query run faster D. There's no real difference; either alone is equally effective
B. IP-only limiting misses distributed attacks (rotating IPs against one account) while email-only limiting misses credential stuffing spread across many accounts from one IP; combining both covers more attack shapes — An attacker with many IPs can bypass an IP-only limit while still hammering one target account, and an attacker with many stolen email/password pairs can bypass an email-only limit by spreading attempts across accounts from one IP; combining both dimensions closes both gaps.
A non-admin user manually visits /admin/delete-user.php?id=7, a URL only shown to admins in the UI. What actually prevents the deletion from happening? A. The browser blocks navigation to URLs not linked on the current page B. PHP automatically hides admin-only files from non-admin sessions C. A server-side authorization check (e.g. requireRole('admin')) at the top of delete-user.php that runs regardless of how the request arrived D. Nothing — hiding the button in the UI is sufficient protection on its own
C. A server-side authorization check (e.g. requireRole('admin')) at the top of delete-user.php that runs regardless of how the request arrived — Hiding a UI element is only a usability nicety; a user who knows or guesses the URL can submit the request directly. Only a server-side check of the authenticated user's role, enforced on every request to that action, actually prevents unauthorized use.
<?php
declare(strict_types=1);
function registerUser(PDO $pdo, array $input): array
{
$errors = [];
$email = trim($input['email'] ?? '');
$password = $input['password'] ?? '';
$confirm = $input['password_confirm'] ?? '';
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Please provide a valid email address.';
}
if (strlen($password) < 8) {
$errors[] = 'Password must be at least 8 characters long.';
}
if (!hash_equals($password, $confirm)) {
$errors[] = 'Password confirmation does not match.';
}
if ($errors) {
return ['success' => false, 'errors' => $errors];
}
$checkStmt = $pdo->prepare('SELECT id FROM users WHERE email = :email');
$checkStmt->execute([':email' => $email]);
if ($checkStmt->fetch() !== false) {
return ['success' => false, 'errors' => ['That email is already registered.']];
}
$hash = password_hash($password, PASSWORD_DEFAULT);
$insertStmt = $pdo->prepare(
'INSERT INTO users (email, password_hash, role) VALUES (:email, :hash, :role)'
);
$insertStmt->execute([
':email' => $email,
':hash' => $hash,
'
…<?php
declare(strict_types=1);
function attemptLogin(PDO $pdo, string $email, string $password, string $ip): array
{
$windowStart = date('Y-m-d H:i:s', time() - 15 * 60);
$countStmt = $pdo->prepare(
'SELECT COUNT(*) FROM login_attempts
WHERE email = :email AND ip_address = :ip AND created_at > :window_start AND success = 0'
);
$countStmt->execute([':email' => $email, ':ip' => $ip, ':window_start' => $windowStart]);
$recentFailures = (int) $countStmt->fetchColumn();
if ($recentFailures >= 5) {
return ['success' => false, 'error' => 'Too many attempts. Please try again later.'];
}
$userStmt = $pdo->prepare('SELECT id, password_hash, role FROM users WHERE email = :email');
$userStmt->execute([':email' => $email]);
$user = $userStmt->fetch();
$valid = $user !== false && password_verify($password, $user['password_hash']);
$logStmt = $pdo->prepare(
'INSERT INTO login_attempts (email, ip_address, success, created_at) VALUES (:email, :ip, :success, NOW())'
);
$logStmt->execute([':email' => $email, ':ip' => $ip, ':success' => $valid ? 1 : 0]);
if (!$valid) {
return ['success' => false,
…<?php
declare(strict_types=1);
function csrfToken(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function verifyCsrfToken(?string $submitted): bool
{
if (empty($_SESSION['csrf_token']) || $submitted === null) {
return false;
}
return hash_equals($_SESSION['csrf_token'], $submitted);
}
// In the form template:
// <input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrfToken()) ?>">
// In the form handler, before any other processing:
if (!verifyCsrfToken($_POST['csrf_token'] ?? null)) {
http_response_code(403);
exit('Invalid or missing CSRF token.');
}