Practice 11 - PHP: Sessions and User Authentication
Introduction
HTTP is stateless — every request the browser makes is independent. The server has no built-in memory of who was on the previous request. This is a problem the moment you want a “logged-in” user: how does the server know that request #47 comes from the same person who logged in on request #3?
PHP solves this with sessions. A session is a small piece of server-side storage tied to a particular browser via a cookie. This session covers:
- How sessions work and how long they last
$_SESSION— reading and writing session data- Protecting pages so only logged-in users can see them
- Password hashing and verification
- Registration and login flow
1. How sessions work
When you call session_start(), PHP does the following:
- Checks whether the browser sent a cookie called
PHPSESSID - If yes — loads the existing session data from the server’s session storage
- If no — creates a new session, generates a unique id, and sends
Set-Cookie: PHPSESSID=<id>back to the browser
On every subsequent request the browser automatically sends that cookie, so PHP can find the right session data.
First request:
Browser → GET login.php
PHP: no PHPSESSID cookie → create new session (id = "abc123")
PHP → Set-Cookie: PHPSESSID=abc123
Subsequent requests:
Browser → GET index.php Cookie: PHPSESSID=abc123
PHP: load session data for "abc123"
Session data is stored on the server (in a temporary file by default). The browser only receives and stores the session id, not the actual data. This is why it is safe to store sensitive information like user_id in $_SESSION.
How long does a session last?
A PHP session ends in two situations:
- The browser is closed. The
PHPSESSIDcookie is a session cookie — it has no expiry date, so the browser deletes it when all tabs/windows are closed. The next time the browser opens, the cookie is gone and a new session starts. - The session times out on the server. PHP has a garbage collection mechanism that deletes session files that have not been accessed for a while. The default is 24 minutes of inactivity (
session.gc_maxlifetime = 1440seconds inphp.ini). Each request that callssession_start()resets this timer.
In practice, for a typical login session: closing the browser logs the user out. If they leave the tab open but do nothing for 24+ minutes, the server-side session may be cleaned up.
2. session_start() — the required first call
Every page that needs to read or write session data must call session_start() as the very first thing — before any output, including whitespace before <?php:
<?php
session_start(); // must be first
// now $_SESSION is available
Forget this and $_SESSION will be empty. PHP will also issue a warning if you try to start a session after output has already been sent.
3. Reading and writing $_SESSION
$_SESSION is a superglobal associative array. You read and write it like any other array:
// Write
$_SESSION["user_id"] = $user["id"];
$_SESSION["counter"] = 0;
// Read
$userId = $_SESSION["user_id"] ?? null;
// Delete one key
unset($_SESSION["user_id"]);
The data persists across requests — as long as the same session id is in use, the values are there the next time the page loads.
A simple counter example (demo.php)
<?php
session_start();
$counter = $_SESSION["counter"] ?? 0;
$counter++;
$_SESSION["counter"] = $counter;
echo $counter;
Refresh the page and the number increments. Open the same URL in a different browser — a fresh session starts from 1. This is sessions in their simplest form.
4. Destroying a session — session_destroy()
<?php
session_start();
session_destroy();
header("location: login.php");
exit();
session_destroy() deletes the session data from the server. The cookie in the browser is not immediately deleted by this call — but on the next request session_start() will find an empty (or non-existent) session for that id.
Always call session_start() before session_destroy(). You cannot destroy a session that has not been loaded.
5. Password hashing
Never store plain-text passwords. If your data.json file is ever read by someone who should not see it, you do not want every user’s password to be exposed.
PHP provides two functions:
password_hash — hashing on registration
$hash = password_hash($password1, PASSWORD_DEFAULT);
// $hash looks like: $2y$10$eImiTXuWVxfM37uY4JANjQ...
PASSWORD_DEFAULT currently uses bcrypt, a hashing algorithm specifically designed to be slow — making brute-force attacks expensive. The result is a long string that includes the algorithm, a random salt, and the hash.
Every time you call password_hash with the same input you get a different hash. That is intentional — the salt is random. This means you cannot check equality with ===. You must use password_verify.
password_verify — checking on login
if (password_verify($plaintextPassword, $storedHash)) {
// correct password
} else {
// wrong password
}
password_verify extracts the salt from the stored hash, re-hashes the plain-text attempt, and compares. It returns true or false.
| JavaScript analogy | PHP |
|---|---|
| No built-in equivalent (use bcrypt library) | password_hash($pw, PASSWORD_DEFAULT) |
| No built-in equivalent | password_verify($plain, $hash) |
6. Registration — register.php
<?php
$username = mb_strtolower($_POST["username"] ?? "");
$password1 = $_POST["password"] ?? "";
$password2 = $_POST["password2"] ?? "";
$errors = [];
if ($_POST) {
$users = json_decode(file_get_contents(__DIR__ . "/data/users.json"), true) ?? [];
// Check if username is already taken
$found = array_filter($users, fn($u) => $u["username"] === $username);
$existingUser = count($found) > 0 ? array_values($found)[0] : null;
if ($username === "")
$errors["username"] = "Username cannot be empty";
else if ($existingUser !== null)
$errors["username"] = "Username is already taken.";
if ($password1 === "")
$errors["password"] = "Password cannot be empty";
if ($password1 !== $password2)
$errors["password"] = "Passwords must match";
if (count($errors) === 0) {
$hash = password_hash($password1, PASSWORD_DEFAULT);
$id = uniqid();
$users[$id] = [
"id" => $id,
"username" => $username,
"password" => $hash, // only the hash is saved, never the plain text
];
file_put_contents(__DIR__ . "/data/users.json", json_encode($users, JSON_PRETTY_PRINT));
header("location: login.php");
exit();
}
}
Key points:
mb_strtolowerconverts the username to lowercase soAliceandaliceare treated as the same user.mb_functions handle multi-byte characters (accents, non-ASCII letters) correctly.- Usernames are unique:
array_filtersearches the existing users for a matching username. __DIR__is a PHP magic constant that resolves to the directory of the current file, regardless of where the script is called from. Safer than a relative path.- The password hash — not the plain text — is what gets written to
users.json. - On success: redirect to the login page.
7. Login — login.php
<?php
session_start();
// If already logged in, skip the login page entirely
if (isset($_SESSION["user_id"])) {
header("location: index.php");
exit();
}
$username = mb_strtolower($_POST['username'] ?? "");
$password = $_POST["password"] ?? "";
$errors = [];
if ($_POST) {
$users = json_decode(file_get_contents(__DIR__ . "/data/users.json"), true) ?? [];
$found = array_filter($users, fn($u) => $u["username"] === $username);
$user = count($found) > 0 ? array_values($found)[0] : null;
if ($user === null) {
$errors["username"] = "User not found";
} else {
if (!password_verify($password, $user["password"])) {
$errors["password"] = "Wrong password";
}
}
if (count($errors) === 0) {
$_SESSION["user_id"] = $user["id"];
header("location: index.php");
exit();
}
}
The login flow:
- Look up the submitted username in
users.json - If not found → error
- If found →
password_verifythe submitted plain-text against the stored hash - If everything matches → write the user’s id into
$_SESSIONand redirect
Notice that the error for a wrong password says “Wrong password” and is stored on the "password" key, not the "username" key. Separating “user not found” from “wrong password” in the UI is fine for a teaching example. In a production app you would return the same generic error for both (e.g. “Invalid credentials”) to prevent username enumeration attacks.
Why only the user_id in $_SESSION?
After login, only the id is stored in the session:
$_SESSION["user_id"] = $user["id"];
This is intentional. The id is stable and small. When a protected page needs the full user record, it re-reads users.json using the stored id. This way, if a user’s details change (username, password), the session does not hold stale data.
8. Protecting a page — index.php
Any page that should only be accessible when logged in uses the same guard at the top:
<?php
session_start();
if (!isset($_SESSION["user_id"])) {
header("location: login.php");
exit();
}
// At this point we know the user is logged in
$users = json_decode(file_get_contents(__DIR__ . "/data/users.json"), true) ?? [];
$user = $users[$_SESSION["user_id"]];
Hello, <?= $user["username"] ?>
<a href="logout.php">Logout</a>
The pattern: check $_SESSION["user_id"], redirect to login if missing, otherwise proceed.
9. Logout — logout.php
<?php
session_start();
session_destroy();
header("location: login.php");
exit();
Four lines. Load the session, destroy it, redirect to login.
10. The full authentication flow
Registration:
POST /register.php username=alice password=...
→ hash password
→ save user to users.json
→ redirect to /login.php
Login:
POST /login.php username=alice password=...
→ find user in users.json
→ password_verify(submitted, stored_hash)
→ $_SESSION["user_id"] = "6642c1..."
→ redirect to /index.php
Protected page:
GET /index.php Cookie: PHPSESSID=abc123
→ session_start() → load session
→ $_SESSION["user_id"] is set → proceed
→ look up full user record → display
Logout:
GET /logout.php
→ session_destroy()
→ redirect to /login.php
Next visit to /index.php (after logout):
→ $_SESSION["user_id"] not set → redirect to /login.php
Summary
| Concept | Code |
|---|---|
| Start session | session_start(); — must be first line |
| Write session value | $_SESSION["key"] = $value; |
| Read session value | $_SESSION["key"] ?? null |
| Delete session key | unset($_SESSION["key"]); |
| Destroy session | session_destroy(); |
| Session lifetime | Browser close, or ~24 min inactivity (server default) |
| Protect a page | if (!isset($_SESSION["user_id"])) { header("location: login.php"); exit(); } |
| Hash a password | password_hash($plain, PASSWORD_DEFAULT) |
| Verify a password | password_verify($plain, $storedHash) |
| Lowercase username | mb_strtolower($str) |
| Current file’s directory | __DIR__ |
| Check unique username | array_filter($users, fn($u) => $u["username"] === $input) |