LEARN TO CODE | PHP | 04 MINI PROJECTS AND NEXT STEPS
04.2 Login concepts and access control
Follow a login from submitted credentials to a server-side session, then enforce permissions on each protected request.
01 | WHAT DOES SIGNING IN ACTUALLY DO?
Imagine a school resource directory. Robin should be able to read it. Alex and Sam should each be able to add and maintain their own links. The server needs two decisions: who is making this request, and what may that person do?
Authentication checks identity, usually by verifying credentials. Authorisation checks permission for an action. A successful login is not permission to edit everybody’s resources.
Use PHP 8.1 or later with PDO MySQL and a running local MariaDB/MySQL server. The PHP built-in server does not install or start the database. Download the complete Section 04 example files , extract them and open the php04-examples folder in VS Code.
The public folder contains browser-facing pages. The private and tools folders are siblings outside that document root. Keep the complete folder structure: copying individual PHP pages without their shared files will not work.
In your database administration client, run setup.sql once in a new local practice environment . Replace the password placeholder first. Copy private/config.example.php to private/config.php and enter the same account, host, port and database details. Never put your administrator credentials in the application.
The setup creates Users, Resources and Messages. If you already ran it, do not keep rerunning CREATE DATABASE; inspect the existing practice database or ask your teacher for a clean one. Use fictional details throughout.
02 | THE ACCOUNT TABLE AND PASSWORD HASH
CREATE TABLE Users (
user_id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(10) NOT NULL,
CONSTRAINT allowed_role CHECK (role IN ('reader', 'editor'))
) ENGINE=InnoDB;
This table is already in setup.sql; do not create it twice. user_id identifies an account. username is unique. role describes one part of its permissions. password_hash stores a verifier rather than the original password.
password_hash creates a salted, one-way password hash. password_verify checks a submitted password against that stored hash. Hashing is not reversible encryption: the application does not decrypt the stored value to recover the password. A hash must still be protected because a stolen hash can be attacked offline.
Do not compare freshly generated hashes for equality: salts mean hashing the same password twice can produce different strings. Use password_verify.
03 | CREATE FICTIONAL PRACTICE ACCOUNTS
tools/create-user.php
<?php
// Run in the terminal, never through a browser.
if (PHP_SAPI !== 'cli') { http_response_code(404); exit; }
require __DIR__ . '/../private/database.php';
$name = $argv[1] ?? '';
$role = $argv[2] ?? '';
if (!preg_match('/\A[A-Za-z0-9_]{3,50}\z/', $name) || !in_array($role, ['reader', 'editor'], true)) {
exit("Usage: php tools/create-user.php username reader|editor
");
}
fwrite(STDOUT, "Local practice password (visible as you type, do not use a real password): ");
$password = rtrim((string)fgets(STDIN), "\r\n");
if (strlen($password) < 12 || strlen($password) > 64) exit("Use 12 to 64 bytes for this demo.
");
$hash = password_hash($password, PASSWORD_DEFAULT);
$db = openDatabase();
$q = $db->prepare('INSERT INTO Users (username, password_hash, role) VALUES (:name, :hash, :role)');
$q->execute(['name' => $name, 'hash' => $hash, 'role' => $role]);
echo "Practice account created.
";
php tools/create-user.php alex editor
php tools/create-user.php sam editor
php tools/create-user.php robin reader
Run these once from the project root in VS Code’s terminal. At each prompt, enter a new practice password of 12 to 64 bytes. Input is visible in this simple local tool: do not use a real personal password. The password is not passed as a command-line argument or stored as plaintext.
The CLI tool provisions accounts outside the browser. A duplicate username triggers the database’s unique constraint. Choose a different name rather than deleting real records to make a test pass.
The local application account has INSERT on Users so this tool can run. A deployed runtime should use separate provisioning credentials and should not keep that account-creation permission.
04 | WHAT A SESSION REMEMBERS
HTTP requests are separate. After verifying a password, the server stores user_id in session data. The browser stores an opaque session identifier in a cookie and sends it on subsequent requests. PHP uses that identifier to find the server-side data.
We do not put the password or the role into an editable form field and trust it on the next request. currentUser looks up the account’s current role in the database each time, so a changed role or deleted account is not silently trusted from an old role value.
A session identifier is sensitive: stealing it may allow impersonation. HttpOnly reduces JavaScript access to the cookie, Secure restricts its transmission to HTTPS, and SameSite limits some cross-site sending. These settings do not replace output escaping or access checks.
$_SESSION['user_id'] = (int)$row['user_id'];
05 | A COMPLETE LOGIN PAGE
public/login.php
<?php
require __DIR__ . '/../private/bootstrap.php';
$db = openDatabase();
if (currentUser($db) !== null) redirect('/resources.php');
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
checkCsrf();
$username = postText('username');
// Passwords must not be trimmed: spaces may be intentional.
$password = $_POST['password'] ?? '';
if (!is_string($password)) $password = '';
$q = $db->prepare('SELECT user_id, password_hash FROM Users WHERE username = :name');
$q->execute(['name' => $username]);
$row = $q->fetch();
// Fixed dummy hash makes unknown names perform a verification too.
$dummy = '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.';
$valid = password_verify($password, $row === false ? $dummy : $row['password_hash']);
if ($row !== false && $valid) {
// Rotate before adding authenticated identity to the new session.
if (!session_regenerate_id(true)) throw new RuntimeException('Session rotation failed');
$_SESSION['user_id'] = (int)$row['user_id'];
$_SESSION['csrf'] = bin2hex(random_bytes(32));
redirect('/resources.php');
}
$error = 'Username or password is incorrect.';
}
?>
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Local project login</title></head>
<body>
<h1>Sign in</h1>
<p>Local learning demo: use a teacher-created practice account.</p>
<p role="alert"><?= h($error) ?></p>
<form method="post">
<input type="hidden" name="csrf" value="<?= h($_SESSION['csrf']) ?>">
<p><label for="username">Username : </label><input id="username" name="username" required maxlength="50" autocomplete="username"></p>
<p><label for="password">Password : </label><input id="password" name="password" type="password" required autocomplete="current-password"></p>
<button>Sign in</button>
</form>
<p><a href="/contact.php">Contact form</a></p>
</body></html>
GET displays the form. POST validates the CSRF token, reads the username, then prepares a lookup. The password is deliberately not trimmed because spaces may be part of it.
The response uses the same failure message for an unknown username and an incorrect password. A dummy hash makes unknown users go through verification too; this is not a claim that every request takes identical time or that rate limiting is implemented.
06 | THE MOMENT LOGIN SUCCEEDS
On success the code rotates the session identifier before adding user_id, rotates the CSRF token, then redirects with 303. The next request reads the directory as the authenticated account. A redirect followed by exit prevents the old request continuing to print a page.
This local demo deletes the old session immediately with session_regenerate_id(true). It keeps the example small, but concurrent requests or unstable networks can race with that deletion. A deployed service needs a deliberate session-rotation and expiry strategy. This example does not implement idle or absolute session expiry.
TRY IT YOURSELF
Why must the server ignore a posted role=editor value?
Reveal guidance
A visitor can change form fields. Fetch the role for the verified account from the server-side database instead of treating a submitted role as authority.
07 | FOLLOW THE PERMISSION RULES
| Visitor | Read/search directory | Create | Change another account’s row |
|---|---|---|---|
| Not signed in | Redirect to login | No | No |
| Reader | Yes | No | No |
| Editor | Yes | Yes | No: edits/deletes only own rows |
requireUser stops unauthenticated requests. requireOwner checks both the current account’s editor role and the row’s owner_id. The application performs this check in request handlers, even if the button was hidden on the previous page.
Hidden inputs are submitted data, not trusted proof. Changing an ID must never turn a forbidden operation into an allowed one.
08 | SERVER-SIDE GUARDS
$db = openDatabase();
$user = requireUser($db);
// $resource must have been fetched using a validated resource ID.
requireOwner($user, $resource);
These calls assume the shared bootstrap is loaded. requireUser redirects and exits if there is no current account. requireOwner returns a 403 response and exits when the account cannot make the requested change.
The later update and delete SQL also includes owner_id in WHERE. That keeps the stored mutation tied to the account checked by the handler. A page that only hides buttons does not enforce this boundary.
09 | SIGN OUT WITH A POST
public/logout.php
<?php
require __DIR__ . '/../private/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Allow: POST');
http_response_code(405);
exit('Use the logout form.');
}
checkCsrf();
$_SESSION = [];
$p = session_get_cookie_params();
setcookie(session_name(), '', [
'expires' => time() - 3600, 'path' => $p['path'],
'domain' => $p['domain'], 'secure' => $p['secure'],
'httponly' => $p['httponly'], 'samesite' => $p['samesite'],
]);
session_destroy();
redirect('/login.php');
The directory supplies a POST logout form with a valid CSRF token. The handler clears the session data, expires the cookie using matching attributes and destroys the stored session, then redirects.
Pressing Back can display an old browser view, but submitting a fresh protected request must require login. The shared bootstrap sends Cache-Control: no-store for these project pages.
10 | TEST WITH THREE SEPARATE IDENTITIES
In the VS Code terminal, from the project root, start the server once:
php -c development.ini -S 127.0.0.1:8000 -t public
Keep this terminal running. Hover over http://127.0.0.1:8000 and follow the link, then add the required filename in the browser. Save PHP changes in VS Code and refresh the browser. You do not need to restart the server for every source edit; configuration changes may require a restart.
Use this local PHP server rather than a static Live Server extension. The browser must request a page through PHP for server-side code to execute.
TRY IT YOURSELF
Create a resource as Alex. Sign out, sign in as Sam and try Alex’s edit URL directly. Repeat as Robin. Also try an incorrect password and a logout request without a token.
Reveal guidance
Alex can change Alex’s own resource. Sam must receive 403 for that resource despite being an editor. Robin can view but cannot add or change resources. Incorrect credentials get the generic message. A missing logout token gets 403 and must not silently sign out.
11 | WHAT THIS LESSON DOES NOT BUILD
This is a local authentication demonstration, not a finished public account service. It has no registration, email verification, password reset, login throttling, multifactor authentication or session expiry policy. Those are separate design and implementation tasks.
For a public site, consider a maintained authentication framework or identity provider. Even when another provider verifies identity, your application still needs correct resource permissions. Keep practising with fictional accounts while learning these boundaries.
KEEP LEARNING
Previous page 04.3 Project: a contact form
Download complete Section 04 example files
Official references: PHP error configuration · Password hashing · Password verification · Session identifier rotation