Overview
A login system is where two of PHP's security-critical tools stop being optional: password hashing and sessions. Storing a password in plain text — or even hashed with a fast, unsalted algorithm like `md5()` — turns a single database leak into every user's password being exposed, since those hashes can be cracked in bulk with commodity hardware. `password_hash()` uses bcrypt by default, which is deliberately slow and automatically generates a unique random salt per password, embedding that salt directly inside the returned hash string so there is no separate salt column to manage.
Sessions solve a different problem: HTTP is stateless, so without help, the server has no way to remember that the browser making this request is the same one that just logged in a moment ago. `session_start()` gives each visitor a session id, stored in a cookie, and `$_SESSION` is server-side storage keyed to that id — write `$_SESSION['user_id'] = 4` after a successful login, and it will still be there on the next page load. By the end of this tutorial you will have five small files working together: `db.php` (a shared PDO connection), `register.php`, `login.php`, a protected `dashboard.php`, and `logout.php`.
- A `users` MySQL table storing a securely hashed password, never plain text.
- A PDO database connection wrapped in one reusable file, `db.php`.
- A registration script that hashes new passwords with `password_hash()` before saving them.
- A login script that verifies credentials with `password_verify()` and starts a session.
- A protected dashboard page that redirects any visitor who is not logged in.
- A logout script that fully destroys the session and its cookie.
Prerequisites
- Sessions — `session_start()`, `$_SESSION`, and how PHP tracks a logged-in visitor across requests.
- MySQL basics — creating a table with a schema, and connecting to it from PHP with PDO.
- Prepared statements — binding parameters with `?` placeholders instead of concatenating input into SQL.
- Password hashing — why `password_hash()`/`password_verify()` replace both plain text and older hashes like `md5()`.
- Functions and includes — `require_once` for sharing one database connection across multiple files.
Project Structure
This project is deliberately split across five small files rather than one large one, because each file maps to exactly one action a visitor can take: `db.php` opens the shared PDO connection every other file includes with `require_once`, `register.php` creates an account, `login.php` verifies credentials and starts a session, `dashboard.php` stands in for any page that should only be reachable while logged in, and `logout.php` ends the session. Keeping the connection in its own file means every script that needs the database includes the same `$pdo` variable instead of repeating connection code — and configuring a different database later only means editing `db.php` once.
Step 1: Design the Users Table
Every column here earns its place: `username` and `email` are both marked `UNIQUE` so the database itself refuses a duplicate account rather than relying on PHP to catch every case, and `password_hash` is sized generously at `VARCHAR(255)` because a bcrypt hash is roughly 60 characters but the algorithm/cost prefix PHP embeds in it can grow in future PHP versions.
CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, -- Unique numeric id for each account username VARCHAR(50) NOT NULL UNIQUE, -- UNIQUE stops two accounts sharing a username email VARCHAR(255) NOT NULL UNIQUE, -- UNIQUE stops two accounts sharing an email password_hash VARCHAR(255) NOT NULL, -- Stores the bcrypt hash from password_hash(), never plain text created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -- Records when the account was created);Step 2: Connect to MySQL with PDO
PDO's exception mode (`PDO::ERRMODE_EXCEPTION`) turns database errors into catchable `PDOException`s instead of silent failures or bare warnings, which is what makes the duplicate-account handling in Step 3 possible. `charset=utf8mb4` in the DSN string matters because plain `utf8` in MySQL is actually a 3-byte-limited subset that cannot store every Unicode character (including some emoji); `utf8mb4` is the full implementation and has been the recommended default for years.
<?php// db.php — one shared PDO connection every other script includes instead of// opening its own, so connection settings only ever need to change in one place.
$host = 'localhost';$dbName = 'login_demo';$dbUser = 'root';$dbPass = ''; // Replace with a real password outside of local development
$dsn = "mysql:host=$host;dbname=$dbName;charset=utf8mb4"; // utf8mb4 supports full Unicode, including emoji
try { $pdo = new PDO($dsn, $dbUser, $dbPass, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // Throw exceptions on error instead of failing silently PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // Rows come back as associative arrays by default ]);} catch (PDOException $e) { // In production, log $e->getMessage() somewhere private — never echo it directly, // since a raw database error can leak table names or connection details to visitors. die('Database connection failed. Please try again later.');}Step 3: Build the Registration Script
The password is intentionally *not* passed through `trim()` the way the other fields are, since leading or trailing spaces are technically a valid part of a password and silently stripping them would make a visitor's carefully chosen password behave differently than they typed it. `password_hash($password, PASSWORD_DEFAULT)` does the actual protection: it generates a random salt internally, hashes the password with it using bcrypt, and returns one string containing the algorithm, cost, salt, and hash together — that single string is the only thing ever written to the database.
<?phprequire_once 'db.php'; // Brings in the shared $pdo connection
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') { $username = trim($_POST['username'] ?? ''); $email = trim($_POST['email'] ?? ''); $password = $_POST['password'] ?? ''; // Not trimmed — leading/trailing spaces are technically a valid password
if ($username === '' || $email === '' || $password === '') { $errors[] = 'All fields are required.'; } if (strlen($password) < 8) { $errors[] = 'Password must be at least 8 characters long.'; }
if (empty($errors)) { // password_hash() generates a random salt internally and returns it embedded // in the hash string, so there is no separate salt column to manage by hand. $hash = password_hash($password, PASSWORD_DEFAULT);
// Prepared statement: the ? placeholders are bound as data, never concatenated // into the SQL text, which is what keeps this immune to SQL injection. $stmt = $pdo->prepare('INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)');
try { $stmt->execute([$username, $email, $hash]); $success = true; } catch (PDOException $e) { // SQLSTATE 23000 is a constraint violation — here, almost certainly the // UNIQUE constraint on username or email catching a duplicate signup. if ($e->getCode() === '23000') { $errors[] = 'That username or email is already registered.'; } else { $errors[] = 'Registration failed. Please try again.'; } } }}?><!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Register</title></head><body> <h1>Create an Account</h1>
<?php if (!empty($success)): ?> <p style="color: green;">Account created! You can now <a href="login.php">log in</a>.</p> <?php endif; ?>
<?php foreach ($errors as $error): ?> <p style="color: red;"><?= htmlspecialchars($error) ?></p> <?php endforeach; ?>
<form action="" method="POST"> <label for="username">Username</label><br> <input type="text" id="username" name="username" value="<?= htmlspecialchars($username ?? '') ?>"><br>
<label for="email">Email</label><br> <input type="email" id="email" name="email" value="<?= htmlspecialchars($email ?? '') ?>"><br>
<label for="password">Password</label><br> <input type="password" id="password" name="password"><br>
<button type="submit">Register</button> </form></body></html>Step 4: Build the Login Script
`session_start()` has to run before any HTML is echoed, which is why it sits on the very first line — PHP sessions work by sending a cookie header, and headers can only be sent before any other output has left the script. `password_verify($password, $user['password_hash'])` re-derives a hash from the submitted password using the exact algorithm, cost, and salt already embedded in the stored hash, then compares the two — the plain password from the form is never stored anywhere and never directly compared character-by-character. `session_regenerate_id(true)` on a successful login swaps in a brand-new session id, which defeats session fixation: an attacker who somehow set a visitor's session id *before* they logged in cannot reuse that same id to hijack the now-authenticated session.
<?php// session_start() must run before any HTML is echoed, so it lives at the very top of the file.session_start();require_once 'db.php';
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') { $username = trim($_POST['username'] ?? ''); $password = $_POST['password'] ?? '';
// Look up the account by username; the query returns at most one row since // username is UNIQUE at the database level (Step 1). $stmt = $pdo->prepare('SELECT id, username, password_hash FROM users WHERE username = ?'); $stmt->execute([$username]); $user = $stmt->fetch();
// password_verify() re-hashes the submitted password using the algorithm/salt already // embedded in $user['password_hash'] and compares the results — the plain password // itself is never stored or compared directly. if ($user && password_verify($password, $user['password_hash'])) { // Regenerating the session id on login prevents session fixation: it invalidates // any session id an attacker might have set for this visitor before they logged in. session_regenerate_id(true); $_SESSION['user_id'] = $user['id']; $_SESSION['username'] = $user['username'];
header('Location: dashboard.php'); // Redirect to the protected page now that login succeeded exit; // Always exit after a Location header so the rest of the script never runs }
// Deliberately vague: naming which of username/password was wrong tells an attacker // whether a given username even exists in the system. $errors[] = 'Invalid username or password.';}?><!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Log In</title></head><body> <h1>Log In</h1>
<?php foreach ($errors as $error): ?> <p style="color: red;"><?= htmlspecialchars($error) ?></p> <?php endforeach; ?>
<form action="" method="POST"> <label for="username">Username</label><br> <input type="text" id="username" name="username"><br>
<label for="password">Password</label><br> <input type="password" id="password" name="password"><br>
<button type="submit">Log In</button> </form>
<p>Don't have an account? <a href="register.php">Register</a></p></body></html>Step 5: Protect Pages and Log Out
`dashboard.php` stands in for any page in a real app that should only be visible to a logged-in visitor: the very first thing it does after `session_start()` is check `isset($_SESSION['user_id'])`, and if that is not set, it redirects to `login.php` and calls `exit` immediately, before any protected content ever gets echoed. `logout.php` reverses everything the login script set up: it empties `$_SESSION`, calls `session_destroy()` to remove the session's server-side storage, and manually expires the session cookie in the browser so a copy of that cookie cannot be replayed after logout.
<?phpsession_start();
// Guard clause: any page that requires login starts with this same check.// If there's no user_id in the session, this visitor never logged in (or their// session expired), so send them to the login page instead of showing anything.if (!isset($_SESSION['user_id'])) { header('Location: login.php'); exit;}?><!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Dashboard</title></head><body> <h1>Welcome, <?= htmlspecialchars($_SESSION['username']) ?>!</h1> <p>This page is only visible to logged-in users.</p> <a href="logout.php">Log Out</a></body></html><?phpsession_start();
$_SESSION = []; // Clear all session data in memorysession_destroy(); // Remove the session's storage on the server
// Also expire the session cookie in the browser, so a stale copy of it cannot// be replayed even before it would naturally expire on its own.if (ini_get('session.use_cookies')) { $params = session_get_cookie_params(); setcookie( session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly'] );}
header('Location: login.php');exit;Complete Code
The five files below work together as one system: `db.php` is included by every other file, `register.php` and `login.php` each render a form and process it on POST, `dashboard.php` is the protected page the visitor lands on after logging in, and `logout.php` tears the session back down.
<?php$host = 'localhost';$dbName = 'login_demo';$dbUser = 'root';$dbPass = '';
$dsn = "mysql:host=$host;dbname=$dbName;charset=utf8mb4";
try { $pdo = new PDO($dsn, $dbUser, $dbPass, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ]);} catch (PDOException $e) { die('Database connection failed. Please try again later.');}<?phprequire_once 'db.php';
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') { $username = trim($_POST['username'] ?? ''); $email = trim($_POST['email'] ?? ''); $password = $_POST['password'] ?? '';
if ($username === '' || $email === '' || $password === '') { $errors[] = 'All fields are required.'; } if (strlen($password) < 8) { $errors[] = 'Password must be at least 8 characters long.'; }
if (empty($errors)) { $hash = password_hash($password, PASSWORD_DEFAULT); $stmt = $pdo->prepare('INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)');
try { $stmt->execute([$username, $email, $hash]); $success = true; } catch (PDOException $e) { if ($e->getCode() === '23000') { $errors[] = 'That username or email is already registered.'; } else { $errors[] = 'Registration failed. Please try again.'; } } }}?><!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Register</title></head><body> <h1>Create an Account</h1>
<?php if (!empty($success)): ?> <p style="color: green;">Account created! You can now <a href="login.php">log in</a>.</p> <?php endif; ?>
<?php foreach ($errors as $error): ?> <p style="color: red;"><?= htmlspecialchars($error) ?></p> <?php endforeach; ?>
<form action="" method="POST"> <label for="username">Username</label><br> <input type="text" id="username" name="username" value="<?= htmlspecialchars($username ?? '') ?>"><br>
<label for="email">Email</label><br> <input type="email" id="email" name="email" value="<?= htmlspecialchars($email ?? '') ?>"><br>
<label for="password">Password</label><br> <input type="password" id="password" name="password"><br>
<button type="submit">Register</button> </form></body></html><?phpsession_start();require_once 'db.php';
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') { $username = trim($_POST['username'] ?? ''); $password = $_POST['password'] ?? '';
$stmt = $pdo->prepare('SELECT id, username, password_hash FROM users WHERE username = ?'); $stmt->execute([$username]); $user = $stmt->fetch();
if ($user && password_verify($password, $user['password_hash'])) { session_regenerate_id(true); $_SESSION['user_id'] = $user['id']; $_SESSION['username'] = $user['username'];
header('Location: dashboard.php'); exit; }
$errors[] = 'Invalid username or password.';}?><!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Log In</title></head><body> <h1>Log In</h1>
<?php foreach ($errors as $error): ?> <p style="color: red;"><?= htmlspecialchars($error) ?></p> <?php endforeach; ?>
<form action="" method="POST"> <label for="username">Username</label><br> <input type="text" id="username" name="username"><br>
<label for="password">Password</label><br> <input type="password" id="password" name="password"><br>
<button type="submit">Log In</button> </form>
<p>Don't have an account? <a href="register.php">Register</a></p></body></html><?phpsession_start();
if (!isset($_SESSION['user_id'])) { header('Location: login.php'); exit;}?><!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Dashboard</title></head><body> <h1>Welcome, <?= htmlspecialchars($_SESSION['username']) ?>!</h1> <p>This page is only visible to logged-in users.</p> <a href="logout.php">Log Out</a></body></html><?phpsession_start();
$_SESSION = [];session_destroy();
if (ini_get('session.use_cookies')) { $params = session_get_cookie_params(); setcookie( session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly'] );}
header('Location: login.php');exit;Sample Run
Click Run to see what this code prints.
Extend This Project
- Add a "remember me" option using a long-lived, securely random token cookie instead of relying only on the session cookie.
- Add password reset via a time-limited, single-use token emailed to the user (pairs well with the Contact Form Handler project's `mail()` code).
- Rate-limit failed login attempts per username or IP address to slow down brute-force password guessing.
- Add two-factor authentication (a TOTP code) as a second step after `password_verify()` succeeds.
- Store login history (timestamp, IP address) in its own table so a user can review recent activity on their account.
Summary
You built a login system on the two pillars every real one relies on: `password_hash()`/`password_verify()` so a leaked database never exposes a usable password, and PHP sessions so the server can recognize a returning, authenticated visitor across otherwise stateless requests. The guard-clause pattern in `dashboard.php` — check `$_SESSION`, redirect and `exit` if it fails, only then render protected content — is the same check you will put at the top of every page in a larger application that needs a login.