Lesson 13 of 20

Sessions & Cookies

How a Session Actually Works

The first lesson made the point that PHP forgets everything between requests. A session is the standard way around that, and understanding the mechanism makes everything else in this lesson obvious.

When you call session_start() for the first time, PHP generates a long random session id, creates a storage area on the server labelled with that id, and sends the id to the browser as a cookie. On every later request the browser sends that cookie back, PHP finds the matching storage, and loads it into $_SESSION. Anything you put into that array is written back to the server's storage when the script finishes.

So the crucial split is this: the data lives on your server; only the id travels to the browser. That is what makes sessions the right place for anything that must be trustworthy. A visitor can see and change their session id cookie, but they cannot see or change what is stored against it.

Because sending the cookie means sending an HTTP header, session_start() must run before any output — before a single character of HTML, before a stray space above your opening PHP tag. Output first, and you get "Cannot modify header information — headers already sent", and the session silently fails to work. Put session_start() on the very first line of every page that uses $_SESSION, and calling it when a session is already active is harmless because PHP just warns and continues.

By default PHP keeps session data in files on the server. That is fine for a single machine. Once a site runs on several servers behind a load balancer, sessions must move to shared storage such as a database or Redis, or a visitor's second request lands on a different machine and appears logged out.

Example
<?php
session_start();          // FIRST line - no output before this

// Store anything
$_SESSION['user_id']    = 42;
$_SESSION['login_time'] = time();
$_SESSION['cart']       = ['P-101' => 2, 'P-244' => 1];

// Read it back on any later page
if (isset($_SESSION['user_id'])) {
    echo 'Logged in as user #' . (int) $_SESSION['user_id'];
}

// Remove one value
unset($_SESSION['cart']);

// Inspect what is there while debugging
// echo '<pre>'; print_r($_SESSION); echo '</pre>';

echo session_id();        // the random id in the cookie
echo session_name();      // 'PHPSESSID' by default
?>
<!DOCTYPE html>
<html>
<body>...</body>
</html>
Notes
  • Session data is not encrypted, and on shared hosting the default file location may be readable by other accounts on the same server. Store an id in the session and look the rest up from the database; do not store a password, a card number or an API key there.

Storing Passwords: password_hash and password_verify

Before building a login, get this right, because it is the part that cannot be quietly fixed later. Passwords are never stored. You store a hash — a one-way transformation that cannot be reversed — and at login you hash what was typed and compare. If your database is ever copied, the attacker gets hashes, not passwords.

The critical word is which hash. MD5 and SHA-1 are still all over old tutorials and college assignments, and both are completely unsuitable here. They were designed to be fast, and fast is exactly wrong for passwords: cheap hardware can try billions of guesses per second against them, and precomputed tables exist for every common password. Using MD5 for passwords in 2026 is not a small compromise, it is the same as storing them in plain text against any serious attacker.

PHP gives you the correct tool built in. password_hash() uses a deliberately slow algorithm designed for passwords, generates a random salt for you, and embeds the salt and the algorithm's settings in the output string. You do not need to create a salt column, and you should not try to invent your own scheme — this is a field where homemade solutions fail in ways that are not visible from the outside.

password_verify() does the comparison. It reads the settings out of the stored hash, applies the same process to the submitted password, and compares in constant time. Never compare hashes with === yourself, and never hash the input and compare the strings — password_verify() is the only correct way.

Two practical details. Store the hash in a column of at least VARCHAR(255): today's output is 60 characters, but PASSWORD_DEFAULT is explicitly allowed to change in future PHP versions, and a column that is too narrow will silently truncate hashes and lock every user out. And use password_needs_rehash() at login so that when the default does change, users are upgraded transparently the next time they sign in.

Example
<?php
// Registration
$password = $_POST['password'] ?? '';

if (mb_strlen($password) < 8) {
    exit('Password must be at least 8 characters.');
}

$hash = password_hash($password, PASSWORD_DEFAULT);
// e.g. $2y$12$Xr8Y...  - salt and cost are inside the string

$stmt = $pdo->prepare('INSERT INTO users (email, password_hash) VALUES (?, ?)');
$stmt->execute([$email, $hash]);

// Login
$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch();

if ($user && password_verify($password, $user['password_hash'])) {
    // correct password

    // Transparently upgrade the hash if PHP's default has improved
    if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
        $new = password_hash($password, PASSWORD_DEFAULT);
        $pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
            ->execute([$new, $user['id']]);
    }
} else {
    // WRONG email or WRONG password - say only "invalid credentials"
}

// NEVER do any of these
// $hash = md5($password);
// $hash = sha1($password . 'mysalt');
// if ($storedHash === md5($password)) { ... }
Notes
  • Give the same error message whether the email was unknown or the password was wrong. "No account with that email" tells an attacker which addresses are registered, which is useful for targeting them elsewhere. One message — "Invalid email or password" — for both cases.

A Login Flow That Holds Up

With hashing understood, the flow itself is short. Verify the credentials, regenerate the session id, store the user's id in the session, and redirect. What matters is what you store and what you do about the session id.

Store the id, not the identity. Putting $_SESSION['user_id'] = 42 and looking the user up on each request means that if their role changes or their account is suspended, the very next page reflects it. Copying the whole user record — including role => 'admin' — into the session means a user who is demoted stays an admin until they happen to log out.

Call session_regenerate_id(true) at login. This defends against session fixation, an attack where someone gets a victim to use a session id the attacker already knows — through a crafted link, or a shared computer — and then uses that same id after the victim logs in. Issuing a brand-new id at the moment privileges change makes the old one worthless. The true argument deletes the old session's data rather than leaving it behind.

Logging out properly takes three steps, and most tutorials show only the last one. session_destroy() removes the server-side data but leaves $_SESSION populated for the rest of the current request and leaves the cookie in the browser. Clear the array, expire the cookie, and then destroy the session.

Finally, protect pages with a single included guard rather than repeating the check. One require at the top of every restricted page keeps the rule in one place — and remember to exit after the redirect, or the protected content below still gets rendered and sent.

Example
<?php
// login.php
session_start();

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $email    = trim($_POST['email'] ?? '');
    $password = $_POST['password'] ?? '';

    $stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE email = ?');
    $stmt->execute([$email]);
    $user = $stmt->fetch();

    if ($user && password_verify($password, $user['password_hash'])) {
        session_regenerate_id(true);          // new id, old data deleted

        $_SESSION['user_id']       = (int) $user['id'];
        $_SESSION['last_activity'] = time();

        header('Location: /dashboard.php');
        exit;
    }

    $error = 'Invalid email or password.';
}

// ---- auth.php : include at the top of every protected page ----
session_start();

if (!isset($_SESSION['user_id'])) {
    header('Location: /login.php');
    exit;                                     // stop, do not render the page
}

// Idle timeout: 30 minutes
if (time() - ($_SESSION['last_activity'] ?? 0) > 1800) {
    session_unset();
    session_destroy();
    header('Location: /login.php?timeout=1');
    exit;
}
$_SESSION['last_activity'] = time();

// Fetch the current user fresh, so role changes take effect immediately
$stmt = $pdo->prepare('SELECT id, name, role, is_active FROM users WHERE id = ?');
$stmt->execute([$_SESSION['user_id']]);
$currentUser = $stmt->fetch();

// ---- logout.php : all three steps ----
session_start();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
    $p = session_get_cookie_params();
    setcookie(session_name(), '', time() - 42000,
              $p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
header('Location: /login.php');
exit;
Notes
  • Add a delay or a lockout after several failed attempts from the same account or address. Without it, an attacker can try passwords as fast as your server will answer, and a slow hash only helps so much when there is no limit on the number of guesses.

Cookies: Stored in the Browser, Editable by the Visitor

A cookie is a small piece of text the server asks the browser to keep and send back on every subsequent request to that site. Sessions are built on one; you can also set your own for things like a theme preference or a dismissed banner.

The single most important property is that cookies live on the visitor's machine and are trivially editable. A browser's developer tools will let anyone change any cookie value in seconds. So a cookie such as role=admin or user_id=42 is not a mechanism, it is an invitation. Anything that determines what a user is allowed to do belongs in the session, where the value stays on your server.

setcookie() takes an options array in PHP 7.3 and later, which is far more readable than its older positional form. Four options matter. httponly hides the cookie from JavaScript, so a cross-site scripting bug cannot steal it. secure means it is only ever sent over HTTPS. samesite controls whether it travels on requests originating from other sites, and 'Lax' is a sensible default that also blunts the CSRF attack from the forms lesson. expires sets the lifetime; leave it out and the cookie disappears when the browser closes.

One behaviour catches everyone: a cookie you set is not in $_COOKIE during the same request. setcookie() only queues a header asking the browser to store it; $_COOKIE holds what the browser sent you at the start of this request. It will be there on the next one. If you need the value immediately, set it in $_COOKIE yourself as well, or restructure with a redirect.

Deleting a cookie means setting it again with an expiry in the past — and with the same path and domain as when it was created. Get those wrong and you create a second, different cookie that expires immediately while the original happily survives, which is a genuinely confusing half-hour.

Example
<?php
// Modern options-array form (PHP 7.3+)
setcookie('theme', 'dark', [
    'expires'  => time() + 30 * 86400,   // 30 days
    'path'     => '/',
    'secure'   => true,                  // HTTPS only
    'httponly' => true,                  // hidden from JavaScript
    'samesite' => 'Lax',
]);

// Reading - this is what the browser sent with THIS request
$theme = $_COOKIE['theme'] ?? 'light';

// The classic surprise
setcookie('lang', 'hi', ['path' => '/']);
echo $_COOKIE['lang'] ?? 'not set';   // "not set" on this request

// Deleting: same path, expiry in the past
setcookie('theme', '', ['expires' => time() - 3600, 'path' => '/']);

// A cookie is fine for a preference
if (($_COOKIE['theme'] ?? 'light') === 'dark') {
    echo '<body class="dark">';
}

// A cookie is NOT fine for authorisation
// if ($_COOKIE['role'] === 'admin') { ... }      <- anyone can set this
if (($currentUser['role'] ?? '') === 'admin') {   // from the session + database
    // ...
}

// Cookie values are untrusted input like any other
echo htmlspecialchars($_COOKIE['theme'] ?? '', ENT_QUOTES, 'UTF-8');
Notes
  • Browsers cap each cookie at roughly 4 KB, and every cookie is sent with every request to your domain — including requests for images and stylesheets. Storing anything sizeable in cookies makes your whole site slower for no benefit. Keep an id in the cookie and the data on the server.

Hardening the Session Cookie

The session id cookie deserves the same protection as any other, and more, because stealing it means impersonating the user completely — no password needed. PHP lets you configure it with session_set_cookie_params(), which must be called before session_start(), since the cookie is sent as part of starting the session.

Set httponly to true so JavaScript cannot read the session id. This is the specific control that limits the damage of a cross-site scripting bug: an injected script can still do things as the user, but it cannot copy the id and use it later from elsewhere. Set secure to true on any site served over HTTPS so the id never travels in the clear. And set samesite to 'Lax' so the session cookie is not attached to requests initiated by other sites.

One more setting is worth turning on: session.use_strict_mode. With it enabled, PHP refuses to accept a session id it did not itself issue, and generates a new one instead. That closes the simplest form of session fixation, where an attacker simply invents an id and gets a victim to use it.

Because these settings must be applied consistently on every page, put them in a single bootstrap file that every request includes, together with session_start(). Configuring sessions correctly on the login page and forgetting on the others gives you none of the benefit.

Finally, be aware that PHP cleans up old session files with a probabilistic garbage collector governed by session.gc_maxlifetime, which defaults to about 24 minutes of inactivity. That is a cleanup mechanism, not a security guarantee — a session might survive longer. If you want a defined idle timeout, implement it yourself with a timestamp in the session, as the login example did.

Example
<?php
// bootstrap.php - included first by every page
declare(strict_types=1);

ini_set('session.use_strict_mode', '1');   // reject ids PHP did not issue

session_set_cookie_params([
    'lifetime' => 0,          // until the browser closes
    'path'     => '/',
    'domain'   => '',         // current host only
    'secure'   => true,       // HTTPS only (set false for local http testing)
    'httponly' => true,       // not visible to JavaScript
    'samesite' => 'Lax',      // not sent on cross-site requests
]);

session_start();

// Regenerate the id whenever privileges change:
//   after login, after logout, after a password change,
//   after switching to an admin mode
// session_regenerate_id(true);

// Your own idle timeout, independent of gc_maxlifetime
const IDLE_LIMIT = 1800;   // 30 minutes

if (isset($_SESSION['last_activity'])
    && time() - $_SESSION['last_activity'] > IDLE_LIMIT) {
    $_SESSION = [];
    session_destroy();
    session_start();          // start a fresh, empty session
}
$_SESSION['last_activity'] = time();
Notes
  • On a local XAMPP setup over plain http://localhost, 'secure' => true stops the cookie being sent at all and nothing will work. Drive it from a configuration flag so it is false in development and true on the live site — and never ship the development value.

Choosing Between a Session and a Cookie

The two are often presented as alternatives, but they answer different questions. A session is server-side storage keyed by a cookie; a plain cookie is client-side storage you have handed to the visitor. The decision comes down to two things: does it need to be trustworthy, and does it need to outlive the browser session?

Anything that controls access — who is logged in, what they may do, what is in a cart that will be charged for — goes in the session, because it must not be editable. Anything that is a harmless preference and is pleasant to keep between visits — theme, language, a dismissed notice, a chosen page size — can be a cookie, because the worst case if a visitor edits it is that they change their own experience.

A "remember me" feature is the case that needs both, and it is worth knowing the correct shape even before you build one. You do not store the password or the user id in a long-lived cookie. You generate a long random token, store a hash of it in the database against that user with an expiry, and put the token in a cookie. On a return visit you look the token up, confirm it has not expired, start a proper session, and issue a fresh token. That way the cookie is useless on its own and can be revoked from the server.

  • Session — logged-in user id, permissions, CSRF token, flash messages, checkout state
  • Session — anything a user must not be able to change by editing their browser storage
  • Cookie — theme, language, sidebar collapsed, cookie-consent choice, "do not show this again"
  • Cookie — a remember-me token that is random, hashed in your database, expiring and revocable
  • Neither — passwords, card numbers, API keys; these belong in a database or in server configuration
  • Remember the lifetimes: a session ends when the browser closes or the session expires; a cookie lasts as long as its expires value says
Notes
  • If your site is used by visitors in the EU or falls under India's data protection rules, cookies used for anything beyond strictly necessary functionality generally require consent, and you must be able to explain what you store and why. That is a legal question rather than a coding one, but it is easier to design for at the start than to retrofit.
Ask AI