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
usersMySQL 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 likemd5(). - Functions and includes —
require_oncefor 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 PDOExceptions 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.