THU.JUN.18
2026
23:37:52
← back to modules MODULE · 02 · PHP PART 2
0 / 10 chapters complete · 0%

Sessions & Cookies — Remembering Users

Quick fundamentals fact: HTTP is "stateless." Every request your browser sends is independent. The server doesn't naturally remember anything between requests. So if you log in, then click another link, the server has no idea you're the same person who just logged in. Cookies and sessions are the clever workaround that make "logged in" possible.
Cookies = tiny strings in the browser. Sessions = data on the server, keyed by a cookie. Always password_hash + password_verify. session_regenerate_id on login. POST-redirect-GET on logout. Use the require_auth guard on every protected page.

Cookies — the basic mechanism

A cookie is a tiny string the server tells the browser to remember. On every subsequent request to the same site, the browser sends the cookie back. That's how the server can recognize "oh, this is the same visitor I saw earlier."

Real-world analogy: cookies are like the hand stamp at a club. When you enter, the bouncer stamps your hand. Later, when you come back from a smoke break, the bouncer sees the stamp and lets you in without asking for ID again. The stamp itself is small but it proves continuity.

// Set a cookie (must be called BEFORE any output)
setcookie('theme', 'dark', [
    'expires'  => time() + 86400 * 30,    // 30 days
    'path'     => '/',
    'secure'   => true,                   // HTTPS only
    'httponly' => true,                   // JS can't read
    'samesite' => 'Lax',                  // CSRF protection
]);

// Read a cookie on subsequent requests
$theme = $_COOKIE['theme'] ?? 'light';

// Delete (set with past expiry)
setcookie('theme', '', ['expires' => time() - 3600, 'path' => '/']);

Those flags actually matter for security:

  • secure — only send the cookie over HTTPS. Set on anything sensitive.
  • httponly — JavaScript can't read the cookie via document.cookie. Absolutely critical for session cookies — without it, an XSS bug lets attackers steal user sessions.
  • samesite — controls whether the cookie is sent on cross-site requests. Lax is the sensible default; Strict for ultra-paranoid mode. Helps with CSRF.

Cookies max out at 4KB and ship on every request to the domain — keep them small. For anything bigger, use sessions.

Sessions — server-side state, cookie-keyed

Sessions are the next-level upgrade. Instead of storing data in the cookie itself, store it on the server. The cookie only contains a random ID that points to the server-side data. So the cookie is tiny (just an ID), but the actual data can be as big as you want. The browser never sees the data — it just keeps the ID like a coat-check ticket.

session_start();              // before any output

$_SESSION['user_id'] = 42;
$_SESSION['name']    = 'Eric';

// Next page
echo $_SESSION['user_id'];    // 42

// Logout
$_SESSION = [];
session_destroy();

Three rules to internalize:

  • session_start() must be called BEFORE any HTML output. The session cookie is sent as an HTTP header, and headers have to go out before the body.
  • The session cookie is automatic — PHP handles setting and reading it. You don't setcookie for sessions yourself.
  • Anything you put in $_SESSION must be serializable. Scalars, arrays, simple objects — yes. Database connections, file handles, anonymous functions — no.

Hardening session config

Add these to php.ini (or use ini_set at the top of a bootstrap file):

session.cookie_httponly = 1
session.cookie_secure   = 1     ; HTTPS only
session.cookie_samesite = "Lax"
session.use_strict_mode = 1     ; reject session IDs the server didn't create

That last one (strict_mode) prevents an attack called session fixation, where an attacker tricks you into adopting a session ID they already know. With strict mode on, PHP rejects any session ID it didn't generate itself. Cheap to turn on, zero downside.

Password hashing — the only way

Important rule: NEVER store passwords in plaintext. Ever. Not in any column called "password" or "pwd" or any variation. If your database gets compromised, every user's password is leaked — and since people reuse passwords across sites, that exposes them on other services too. Catastrophic.

Instead, store a hash — a one-way transformation of the password. You can verify a password against a hash, but you can't reverse the hash back to the original password.

// When registering a new user
$hash = password_hash($plaintext_password, PASSWORD_DEFAULT);
// Store $hash in the database. NEVER store the plaintext password.

// When verifying a login
if (password_verify($plaintext_input, $stored_hash)) {
    // matches — let them in
}

PASSWORD_DEFAULT currently maps to bcrypt (a well-established secure hashing algorithm) and might upgrade to a stronger one in future PHP versions. The right move is to let PHP pick — that way your code automatically benefits from improvements without you lifting a finger.

🐍 Python: password_hash/password_verifypasslib's pwd_context.hash() / pwd_context.verify(). Both libraries pick the algorithm for you and support transparent upgrades. PHP's is built into the stdlib; Python's passlib is a separate package but the API is nearly identical.

The complete login pattern

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

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

    if ($user && password_verify($pass, $user['password_hash'])) {
        // SUCCESS — regenerate session ID to prevent fixation
        session_regenerate_id(true);
        $_SESSION['user_id']   = (int) $user['id'];
        $_SESSION['user_name'] = $user['name'];

        header('Location: /dashboard.php');
        exit;
    } else {
        // FAIL — generic message, don't reveal whether email exists
        $error = 'Wrong email or password.';
    }
}

Things worth noticing because each one is a deliberate choice:

  • password_verify uses constant-time comparison — no timing attacks.
  • session_regenerate_id(true) generates a new session ID and deletes the old one. Prevents session fixation where attackers know your pre-login session ID.
  • Error message says "wrong email or password" — never "user not found" vs "wrong password." The split-message version leaks which emails are registered, which helps attackers enumerate accounts.

The require-login guard

function require_login(): array {
    if (session_status() !== PHP_SESSION_ACTIVE) session_start();
    if (empty($_SESSION['user_id'])) {
        header('Location: /login.php');
        exit;
    }
    return ['id' => $_SESSION['user_id'], 'name' => $_SESSION['user_name']];
}

// At top of every protected page:
$me = require_login();

One line at the top of every protected page ($me = require_login();). That's the right amount of friction — explicit, clear, and easy to spot when reading.

Flash messages

"Item saved" or "Login failed" messages that should appear once and disappear. Pairs beautifully with POST-Redirect-GET:

// after action
$_SESSION['flash'] = 'Profile saved.';
header('Location: /profile.php');
exit;

// on next page
session_start();
if (isset($_SESSION['flash'])) {
    echo '<p class="flash">' . e($_SESSION['flash']) . '</p>';
    unset($_SESSION['flash']);   // one-shot
}

The receiving page reads the flash, displays it, immediately deletes it. Shows up exactly once and never again — even if the user refreshes.

Logout — clear three things

To log a user out, you have to clear three things: the in-memory session data, the cookie on the browser, and the server-side session file:

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: /');
exit;

All three steps matter. Skip the cookie and the browser keeps sending the dead ID. Skip the server destroy and data lingers. Skip the array clear and weird state stays in the current request. Do all three.

Create the users table in MariaDB:

sudo mariadb medtrack
CREATE TABLE users (
    id INT PRIMARY KEY AUTO_INCREMENT,
    email VARCHAR(150) UNIQUE NOT NULL,
    name VARCHAR(100) NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
EXIT;

Then ALTER the meds table to add a user_id:

sudo mariadb medtrack -e "ALTER TABLE meds ADD COLUMN user_id INT NOT NULL DEFAULT 1;"

Build: Lock MedTrack Behind Login

Goal: only logged-in users can see and manage their meds. Each user has their own meds. Real multi-user authentication.

  1. Create a one-time registration script /home/erictey/server/register.php to make your account:
    <?php
    declare(strict_types=1);
    require __DIR__ . '/lib/db.php';
    
    $email = 'you@example.com';
    $name  = 'Eric';
    $pass  = 'pick-a-strong-password';
    
    $hash = password_hash($pass, PASSWORD_DEFAULT);
    $stmt = db()->prepare("INSERT INTO users (email, name, password_hash) VALUES (?, ?, ?)");
    $stmt->execute([$email, $name, $hash]);
    
    echo "Created user. Now DELETE THIS FILE.";
    Hit it ONCE in your browser, then rm register.php. Seriously delete it — leaving it sitting there is a free account creator for whoever finds it.
  2. Create /home/erictey/server/lib/auth.php:
    <?php
    declare(strict_types=1);
    require_once __DIR__ . '/db.php';
    
    function require_login(): array {
        if (session_status() !== PHP_SESSION_ACTIVE) session_start();
        if (empty($_SESSION['user_id'])) {
            header('Location: /login.php');
            exit;
        }
        return [
            'id'   => (int) $_SESSION['user_id'],
            'name' => $_SESSION['user_name'] ?? '',
        ];
    }
  3. Create /home/erictey/server/login.php using the complete login pattern from earlier in this chapter.
  4. Update add-med.php — at top: $me = require_login();. In the INSERT, include user_id from $me['id']. In the SELECT, filter WHERE user_id = ? with $me['id'].
  5. Add a logout link at the top of the page and create /home/erictey/server/logout.php using the logout pattern.
  6. Test the full flow: visit add-med.php while logged out → bounce to login → enter credentials → land on add-med.php with your name shown → add meds (saved with your user_id) → click logout → bounce back to login.

Stretch goals:

  • Add a "remember me" checkbox that extends the session lifetime.
  • Implement password_needs_rehash on login to auto-upgrade old password hashes.
  • Add a proper registration form (carefully — validate email uniqueness, prevent enumeration).

What you flexed: password_hash and password_verify, session_regenerate_id, the require_login guard pattern across multiple pages, generic error messages (don't leak which emails exist), proper cookie cleanup on logout, ALTER TABLE for schema evolution. Real authentication that wouldn't embarrass you in a production codebase.