THU.JUN.18
2026
23:38:24
← back to modules MODULE · 04 · FINISH THE SITE
0 / 10 chapters complete · 0%

The Login Wall — Multi-User Accounts

Right now anyone who can reach this page sees everything and can change anything. By the end you'll have a real multi-user login — accounts with hashed passwords, a session-based login wall, and per-user data so each person gets their own progress, tasks, and practice. This is the MedTrack auth pattern from Part 2, applied to the dashboard.
A users table with password_hash. A login.php / logout.php pair and a require_login() guard at the top of index.php. Add a user_id column to progress/tasks/practice and filter every query by the logged-in user. Generic error messages, session_regenerate_id on login, locked-down cookies. Security is non-negotiable here — go slow.

Why multi-user, and what it changes

You chose accounts over a single shared password, and that's a great call if more than one person will ever touch this — each gets their own streak, their own task list, their own practice grid. It's the same model as MedTrack in Part 2, where each user had their own meds. The structure: a users table, a login flow that sets $_SESSION['user_id'], and a user_id foreign key on every per-user table so queries can filter "just mine."

Here's the mental model for the whole thing: authentication is proving who you are (the login), and authorization is what you're allowed to see (filtering by user_id). This chapter does both. Get them right and the dashboard goes from "a webpage" to "an app with users."

The security non-negotiables

This is the one chapter where "good enough" isn't — auth bugs are how people get genuinely hurt. The rules from the Part 2 sessions chapter all apply, and they're worth repeating because each one prevents a specific, real attack:

  • Never store plaintext passwords. Ever. Use password_hash() to store and password_verify() to check. If your DB leaks, hashed passwords are useless to the attacker; plaintext passwords get your users owned on every other site they reused that password on.
  • Generic error messages. "Wrong email or password" — never "no such user" vs "wrong password." The split version lets an attacker enumerate which emails have accounts.
  • Regenerate the session ID on login with session_regenerate_id(true) to prevent session fixation.
  • Lock down session cookies: httponly (JS can't read them, so an XSS bug can't steal sessions), samesite=Lax (CSRF defense), and secure once you're on HTTPS.

🐍 Python brain: password_hash/password_verify are PHP's built-in equivalent of passlib's pwd_context.hash()/verify(). Both pick a strong algorithm for you (bcrypt) and support transparent upgrades. Same idea, no extra package needed.

The users table + a one-time registration

Reuse the MedTrack users table verbatim:

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,
    role          VARCHAR(20)  NOT NULL DEFAULT 'user',
    created_at    DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP
);

I snuck in a role column — we'll use it next chapter to decide who's allowed to edit content. To make your own account, use a throwaway registration script exactly like the MedTrack one:

<?php
declare(strict_types=1);
require __DIR__ . '/lib/db.php';

$hash = password_hash('pick-a-strong-password', PASSWORD_DEFAULT);
$stmt = db()->prepare(
    "INSERT INTO users (email, name, password_hash, role) VALUES (?, ?, ?, 'admin')"
);
$stmt->execute(['you@example.com', 'Eric', $hash]);
echo "Created. NOW DELETE THIS FILE.";
Hit that script once in your browser, then delete it immediately (rm register.php). Leaving it on the server is a free account-creator — and worse, a free admin-account-creator — for anyone who finds the URL. This is the kind of "I'll clean it up later" that becomes a breach. Delete it now.

The auth helpers

Put these in lib/auth.php — they're the MedTrack pattern, lightly adapted:

<?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'] ?? '',
        'role' => $_SESSION['user_role'] ?? 'user',
    ];
}

function attempt_login(string $email, string $pass): bool {
    $stmt = db()->prepare("SELECT * FROM users WHERE email = ?");
    $stmt->execute([$email]);
    $u = $stmt->fetch();
    if ($u && password_verify($pass, $u['password_hash'])) {
        session_regenerate_id(true);                 // prevent fixation
        $_SESSION['user_id']   = (int) $u['id'];
        $_SESSION['user_name'] = $u['name'];
        $_SESSION['user_role'] = $u['role'];
        return true;
    }
    return false;                                    // caller shows generic error
}

The login.php page is a form that calls attempt_login() and, on success, redirects into the dashboard; on failure it shows the single generic message. logout.php clears the session array, expires the cookie, and calls session_destroy() — all three, exactly like the Part 2 chapter drilled into you.

Gating the dashboard + scoping data per user

Two moves finish the job. First, lock the front door — at the very top of index.php, right after the requires:

require __DIR__ . '/lib/auth.php';
$me = require_login();          // bounces to login.php if not authed

Now every visit either has a logged-in $me or never gets past that line. Show $me['name'] and a logout link in the topbar so it feels personal (the greeting already says "Eric" — now it'll mean it).

Second, make the data theirs. Add a user_id column to progress, tasks, and practice_log (you pre-added it to progress in chapter 4's stretch goal — nice foresight):

ALTER TABLE tasks        ADD COLUMN user_id INT NOT NULL DEFAULT 1;
ALTER TABLE practice_log ADD COLUMN user_id INT NOT NULL DEFAULT 1;

Then thread $me['id'] through every query: include it in INSERTs, and add AND user_id = ? to SELECTs/UPDATEs/DELETEs. Miss one and you've got a bug where users see each other's data — so audit every query touching those tables. This is tedious but mechanical, and it's the difference between "multi-user" and "everyone shares one pile."

Logged out, hitting any dashboard URL bounces you to login.php. Logging in lands you on your dashboard with your name shown. Create a second user and confirm their tasks/progress/practice are completely separate from yours. Logout clears everything and re-protects the pages. If you can still reach the dashboard while logged out, your require_login() isn't at the very top — before any output.

▣ Mini Project: Lock the Dashboard Down

This is the chapter that turns your project into something you'd be comfortable putting on a network where other people exist. It's also the one to take slowly — re-read each security rule as you implement it, because auth is exactly where shortcuts come back to bite.

  1. Create the users table (with the role column).
  2. Register your admin account with the throwaway script — then delete the script.
  3. Build lib/auth.php, login.php, and logout.php using the patterns above.
  4. Add require_login() to the top of index.php; show name + logout in the topbar.
  5. Add user_id to tasks/practice (progress already has it), and filter every per-user query by $me['id'].
  6. Test with two accounts and verify total data separation.

Stretch goals:

  • Add a "remember me" checkbox that extends the session lifetime.
  • Use password_needs_rehash() on login to transparently upgrade old hashes.
  • Rate-limit failed logins (track attempts in the session or a table; block after 5 for a minute).
  • Add a CSRF token to the login form — and to every form, which sets you up perfectly for the editing chapter.

What you flexed: password_hash/password_verify, session hardening and session_regenerate_id, the require_login() guard, generic error messages to prevent enumeration, and per-user data scoping via user_id. Real authentication you wouldn't be embarrassed to ship.