Magyar zászlóMagyar

Practice 11 - PHP: Sessions and User Authentication

2025-05-08 3 min read GitHub

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:

  1. Checks whether the browser sent a cookie called PHPSESSID
  2. If yes — loads the existing session data from the server’s session storage
  3. 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:

  1. The browser is closed. The PHPSESSID cookie 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.
  2. 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 = 1440 seconds in php.ini). Each request that calls session_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 analogyPHP
No built-in equivalent (use bcrypt library)password_hash($pw, PASSWORD_DEFAULT)
No built-in equivalentpassword_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_strtolower converts the username to lowercase so Alice and alice are treated as the same user. mb_ functions handle multi-byte characters (accents, non-ASCII letters) correctly.
  • Usernames are unique: array_filter searches 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:

  1. Look up the submitted username in users.json
  2. If not found → error
  3. If found → password_verify the submitted plain-text against the stored hash
  4. If everything matches → write the user’s id into $_SESSION and 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

ConceptCode
Start sessionsession_start(); — must be first line
Write session value$_SESSION["key"] = $value;
Read session value$_SESSION["key"] ?? null
Delete session keyunset($_SESSION["key"]);
Destroy sessionsession_destroy();
Session lifetimeBrowser close, or ~24 min inactivity (server default)
Protect a pageif (!isset($_SESSION["user_id"])) { header("location: login.php"); exit(); }
Hash a passwordpassword_hash($plain, PASSWORD_DEFAULT)
Verify a passwordpassword_verify($plain, $storedHash)
Lowercase usernamemb_strtolower($str)
Current file’s directory__DIR__
Check unique usernamearray_filter($users, fn($u) => $u["username"] === $input)