Login Systems
Build secure registration, login, sessions, protected pages and user/admin role-based access with PDO.
- Creating a User Database and Table Using SQL
- Secure Password Storage with Password Hashing
- Creating a User Registration Form
- Creating a Login Form
- Using PHP Sessions to Keep Users Logged In
- Protecting Pages and Preventing Unauthorised Access
- Creating a Logout Page
- Adding Role-Based Access Control
- Creating a Navigation Menu Based on User Roles
- Protecting an Admin-Only Data Import Page
Creating a User Database and Table Using SQL
A login system needs a users table that can identify accounts, store password hashes and enforce roles. This example uses fictional accounts and prepares the database for PDO-based PHP pages.
Never store plain-text passwords. The
passwordfield below stores the output from PHP’spassword_hash()function.
Create the database
Open http://localhost/phpmyadmin/, select SQL, and run:
CREATE DATABASE project_db
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE project_db;
Create the users table
CREATE TABLE users (
user_id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'user',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_user_role CHECK (role IN ('user', 'admin'))
);
| Field | Purpose |
|---|---|
user_id |
Stable unique identifier |
username |
Unique login name |
password |
Password hash, never the original password |
role |
Authorisation level: user or admin |
created_at |
Account creation time |
Some older MariaDB versions accept but do not enforce CHECK constraints. PHP must still validate the role, and ordinary registration must never accept an administrator role from the user.
Create a fictional administrator
Generate a hash with PHP’s password_hash() or the classroom password-hasher tool. Insert the generated hash—not the test password:
INSERT INTO users (username, password, role)
VALUES ('admin_demo', '$2y$10$REPLACE_WITH_A_REAL_TEST_HASH', 'admin');
Use only a fictional test password. Never enter a personal, school or reused password into a classroom tool.
Verify the table
DESCRIBE users;
SELECT user_id, username, role, created_at FROM users;
Do not select or display password hashes unless diagnosing a specific local problem.
Check
- Username is unique.
- Password field is long enough for modern hashes.
- New accounts default to
user. - Administrator status cannot be selected during public registration.
- All accounts and data are fictional.
Secure Password Storage with Password Hashing
Password hashing converts a password into a one-way value suitable for storage. PHP automatically includes a salt, so the same password can produce different valid hashes.
Use fictional test passwords only. Never enter your school, email, banking or reused personal password.
Generate a hash
<?php
$testPassword = "fictional-test-password";
$hash = password_hash($testPassword, PASSWORD_DEFAULT);
echo htmlspecialchars($hash, ENT_QUOTES, "UTF-8");
Store the complete hash in a VARCHAR(255) database field. Do not shorten it.
Verify a submitted password
<?php
$submittedPassword = "fictional-test-password";
$storedHash = '$2y$10$REPLACE_WITH_A_COMPLETE_HASH';
if (password_verify($submittedPassword, $storedHash)) {
echo "Password accepted.";
} else {
echo "Password not accepted.";
}
Login code should retrieve the stored hash by username and pass it to password_verify(). Never hash the submitted password again and compare strings; salts make that unreliable.
Rehash when needed
if (password_needs_rehash($storedHash, PASSWORD_DEFAULT)) {
$newHash = password_hash($submittedPassword, PASSWORD_DEFAULT);
// Update the stored hash using a prepared statement.
}
This allows PHP’s current default algorithm to improve over time.
Safe practice
- Do not print passwords.
- Do not place passwords or connection details in screenshots.
- Do not email or log submitted passwords.
- Use HTTPS on hosted systems.
- Use prepared statements for inserts and updates.
- Keep login failure messages generic.
Check
- Database stores hashes, not original passwords.
-
password_hash()is used during registration. -
password_verify()is used during login. - Hash field is
VARCHAR(255). - Test data contains no real credentials.
Creating a User Registration Form
This standalone registration page accepts a fictional username and password, validates both on the server, hashes the password and inserts a standard user account with PDO.
Database connection
The example expects includes/db.php to create a PDO object named $pdo.
Registration page
<?php
require __DIR__ . "/includes/db.php";
$username = "";
$errors = [];
$created = false;
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$username = trim($_POST["username"] ?? "");
$password = $_POST["password"] ?? "";
$confirm = $_POST["confirm_password"] ?? "";
if (!preg_match('/^[A-Za-z0-9_]{3,50}$/', $username)) {
$errors[] = "Username must be 3–50 letters, numbers or underscores.";
}
if (strlen($password) < 10) {
$errors[] = "Password must contain at least 10 characters.";
}
if ($password !== $confirm) {
$errors[] = "Passwords do not match.";
}
if (!$errors) {
$check = $pdo->prepare(
"SELECT user_id FROM users WHERE username = :username"
);
$check->execute(["username" => $username]);
if ($check->fetch()) {
$errors[] = "That username is unavailable.";
} else {
$insert = $pdo->prepare(
"INSERT INTO users (username, password, role)
VALUES (:username, :password, 'user')"
);
$insert->execute([
"username" => $username,
"password" => password_hash($password, PASSWORD_DEFAULT)
]);
$created = true;
$username = "";
}
}
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Create account</title>
</head>
<body>
<h1>Create account</h1>
<?php if ($created): ?>
<p role="status">Account created. You can now log in.</p>
<?php endif; ?>
<?php if ($errors): ?>
<div role="alert">
<p>Please correct the following:</p>
<ul>
<?php foreach ($errors as $error): ?>
<li><?= htmlspecialchars($error) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<form method="post">
<label for="username">Username</label>
<input id="username" name="username" maxlength="50"
autocomplete="username" required
value="<?= htmlspecialchars($username) ?>">
<label for="password">Password</label>
<input id="password" name="password" type="password"
autocomplete="new-password" required>
<label for="confirm_password">Confirm password</label>
<input id="confirm_password" name="confirm_password" type="password"
autocomplete="new-password" required>
<button type="submit">Create account</button>
</form>
</body>
</html>
The form never accepts a role. Every public registration receives the server-controlled user role.
Test
Test valid input, short passwords, mismatched passwords, invalid usernames, duplicates and missing fields. Confirm that unsuccessful attempts create no database row.
Check
- Labels are associated with inputs.
- PHP validates all inputs.
- Username duplicates are handled.
- Password is hashed before insertion.
- Prepared statements are used.
- Registration cannot create an administrator.
Creating a Login Form
This standalone login page validates credentials with PDO, starts a secure session and redirects authenticated users. It uses one generic failure message so it does not reveal whether a username exists.
Login page
<?php
session_start();
require __DIR__ . "/includes/db.php";
$error = "";
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$username = trim($_POST["username"] ?? "");
$password = $_POST["password"] ?? "";
$stmt = $pdo->prepare(
"SELECT user_id, username, password, role
FROM users
WHERE username = :username"
);
$stmt->execute(["username" => $username]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user["password"])) {
session_regenerate_id(true);
$_SESSION["user_id"] = (int) $user["user_id"];
$_SESSION["username"] = $user["username"];
$_SESSION["role"] = $user["role"];
header("Location: dashboard.php");
exit;
}
$error = "Username or password was not accepted.";
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Log in</title>
</head>
<body>
<h1>Log in</h1>
<?php if ($error): ?>
<p role="alert"><?= htmlspecialchars($error) ?></p>
<?php endif; ?>
<form method="post">
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username" required>
<label for="password">Password</label>
<input id="password" name="password" type="password"
autocomplete="current-password" required>
<button type="submit">Log in</button>
</form>
</body>
</html>
Why the security steps matter
- prepared statements keep input separate from SQL
password_verify()checks the stored hash- session ID regeneration reduces session fixation risk
- role comes from the database, not the form
- a generic error reduces account discovery
Test
Test a valid standard user, a valid administrator, a wrong password, an unknown username and empty fields. Confirm that failed attempts do not create session identity values.
Check
- Session starts before output.
- Query uses a prepared statement.
- Password hash is verified correctly.
- Session ID changes after login.
- User ID, username and role are stored.
- Failure message is generic.
Using PHP Sessions to Keep Users Logged In
PHP sessions store a small amount of trusted server-side state between requests. A login system can use them to remember the authenticated user’s identifier, username and role.
Start the session
Call session_start() before HTML or other output:
<?php
session_start();
Set identity after successful login
Only set these values after a database user has been found and password_verify() succeeds:
session_regenerate_id(true);
$_SESSION["user_id"] = (int) $user["user_id"];
$_SESSION["username"] = $user["username"];
$_SESSION["role"] = $user["role"];
The role must come from the database. Never trust a role supplied by a login or registration form.
Read session values safely
<?php
session_start();
$username = $_SESSION["username"] ?? "";
$role = $_SESSION["role"] ?? "user";
?>
<p>
Signed in as
<?= htmlspecialchars($username, ENT_QUOTES, "UTF-8") ?>
</p>
Session values still need escaping when inserted into HTML.
What belongs in a session
Suitable:
- internal user identifier
- display username
- authorised role
- short status messages
- CSRF tokens
Avoid:
- plain-text passwords
- complete database records
- sensitive information not needed across requests
- values copied directly from unvalidated form fields
Important limits
A session records authentication state; it does not automatically protect a page. Every protected server-side route must check the required identity and role.
Check
- Session starts before output.
- ID is regenerated after successful login.
- Identity and role come from the database.
- Password is never stored in the session.
- Session text is escaped when displayed.
- Protected routes perform their own checks.
Protecting Pages and Preventing Unauthorised Access
A protected page checks authentication on the server before sending restricted content. Hiding a menu link is not protection because a user can enter the URL directly.
Require a logged-in user
Place this before any HTML:
<?php
session_start();
if (!isset($_SESSION["user_id"])) {
header("Location: login.php");
exit;
}
The exit prevents the rest of the page from running after the redirect.
Complete protected page
<?php
session_start();
if (!isset($_SESSION["user_id"])) {
header("Location: login.php");
exit;
}
$username = $_SESSION["username"] ?? "user";
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Dashboard</title>
</head>
<body>
<h1>User dashboard</h1>
<p>Welcome, <?= htmlspecialchars($username) ?>.</p>
</body>
</html>
Use a reusable guard
Create includes/require-login.php:
<?php
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
if (!isset($_SESSION["user_id"])) {
header("Location: login.php");
exit;
}
Then begin protected pages with:
require __DIR__ . "/includes/require-login.php";
Test direct access
Use a private browser window or log out, then enter the protected URL directly. Also test after closing the browser and after destroying the session.
Check
- Authentication is checked before output.
- Redirect is followed by
exit. - Every protected processing route uses the guard.
- Direct URL access is tested.
- Displayed session values are escaped.
Creating a Logout Page
Logout should remove server-side session data, expire the session cookie and return the user to a safe public page.
Logout script
Create logout.php:
<?php
session_start();
$_SESSION = [];
if (ini_get("session.use_cookies")) {
$parameters = session_get_cookie_params();
setcookie(
session_name(),
"",
time() - 42000,
$parameters["path"],
$parameters["domain"],
$parameters["secure"],
$parameters["httponly"]
);
}
session_destroy();
header("Location: login.php");
exit;
Clearing $_SESSION removes values in the current request. Expiring the cookie removes the browser’s session identifier. session_destroy() removes the stored session.
Logout control
For a basic local classroom project:
<a href="logout.php">Log out</a>
For a stronger design, use a POST form and a CSRF token so another website cannot trigger logout unexpectedly.
Test
- Log in and open a protected page.
- Log out.
- use the Back button and refresh.
- enter the protected URL directly.
- confirm the application requires login again.
Check
- Session array is cleared.
- Session cookie is expired when cookies are used.
- Stored session is destroyed.
- Redirect is followed by
exit. - Protected content is unavailable after logout.
Adding Role-Based Access Control
Role-based access control allows authenticated users to perform only the actions permitted by their stored role. This example uses two roles: user and admin.
Database role
A suitable users table contains:
role VARCHAR(20) NOT NULL DEFAULT 'user'
Ordinary registration must always create user accounts. Promote a fictional test account through a controlled administrator process or directly in the local development database:
UPDATE users
SET role = 'admin'
WHERE username = 'admin_demo';
Do not allow a public form to submit its own role.
Store the trusted role at login
After verifying the password:
session_regenerate_id(true);
$_SESSION["user_id"] = (int) $user["user_id"];
$_SESSION["username"] = $user["username"];
$_SESSION["role"] = $user["role"];
Require an administrator
<?php
session_start();
if (!isset($_SESSION["user_id"])) {
header("Location: login.php");
exit;
}
if (($_SESSION["role"] ?? "") !== "admin") {
http_response_code(403);
exit("You do not have permission to access this page.");
}
Authentication asks “Who is signed in?” Authorisation asks “May that user perform this action?” Both checks are required.
Reusable administrator guard
Create includes/require-admin.php containing the checks above, then require it from every administrative display and processing route.
Test matrix
| Account state | User page | Admin page |
|---|---|---|
| Logged out | Redirect to login | Redirect to login |
| Standard user | Allowed | 403 response |
| Administrator | Allowed | Allowed |
| Changed form/URL value | No role change | No additional access |
Check
- Roles are
userandadmin. - Role comes from the stored user record.
- Administrative processing is protected.
- Direct URLs are tested.
- Denied access uses an appropriate response.
Creating a Navigation Menu Based on User Roles
Start with a protected page
<?php
require __DIR__ . "/includes/require-login.php";
$username = $_SESSION["username"] ?? "user";
$role = $_SESSION["role"] ?? "user";
?>
Display navigation
<nav aria-label="Main navigation">
<ul>
<li><a href="dashboard.php">Dashboard</a></li>
<li><a href="results.php">Results</a></li>
<?php if ($role === "admin"): ?>
<li><a href="import.php">Import dataset</a></li>
<li><a href="manage-users.php">Manage users</a></li>
<?php endif; ?>
<li><a href="logout.php">Log out</a></li>
</ul>
</nav>
<p>
Signed in as
<?= htmlspecialchars($username, ENT_QUOTES, "UTF-8") ?>
</p>
Protect every destination
The condition only controls whether the link appears. import.php and every other administrator route must also require includes/require-admin.php.
Do not use JavaScript or CSS visibility as an access-control mechanism. Those technologies run in the user’s browser and can be changed.
Design guidance
- Use descriptive link text.
- Identify the current page with
aria-current="page". - Keep navigation order consistent.
- Do not display links that will always deny the current role.
- Provide a visible logout action.
- Ensure keyboard focus is clear in CSS.
Test
Compare logged-out, standard-user and administrator views. Then type each protected URL directly to verify that hidden links are not the only control.
Check
- Standard users see standard actions.
- Administrators see administrative actions.
- Every destination enforces access independently.
- Navigation is labelled and keyboard accessible.
- Session text is escaped.
Protecting an Admin-Only Data Import Page
A role-based system must enforce permissions on the server. Hiding a link is helpful navigation, but it does not protect the page.
Store trusted session values at login
After verifying the password and retrieving the database user:
session_regenerate_id(true);
$_SESSION["user_id"] = (int) $user["user_id"];
$_SESSION["role"] = $user["role"];
The role must come from the database, not from a form field supplied by the user.
Protect the import page
Place this code before any HTML output:
<?php
session_start();
if (!isset($_SESSION["user_id"])) {
header("Location: login.php");
exit;
}
if (($_SESSION["role"] ?? "") !== "admin") {
http_response_code(403);
exit("You do not have permission to access this page.");
}
Protect processing as well as display
The role check must run on the script that processes the uploaded CSV. A user can send a request directly even when the navigation link is hidden.
You may keep the form and processing in one protected file or require the same protection file from both scripts.
Show navigation by role
<?php if (($_SESSION["role"] ?? "") === "admin"): ?>
<a href="import.php">Import dataset</a>
<?php endif; ?>
This improves usability but is not the security control.
Test the access rules
| Test | Expected result |
|---|---|
| Logged out user opens import URL | Redirected to login |
| Standard user opens import URL | 403 response |
| Administrator opens import URL | Upload form appears |
| Standard user submits directly | Request rejected |
| Changed browser role field | No effect because role comes from session |
Check
- Sessions start before output.
- Login and role are checked server-side.
- Processing route repeats the protection.
- Roles come from stored user records.
- Tests include direct URL access.
- Test accounts and data are fictional.