User Accounts
SQLite, password hashing, and an auth guard. Real accounts in 50 lines.
You will learn
- Store user data in SQLite with PDO
- Hash passwords properly (never store plaintext)
- Build register and login forms
- Guard pages so only logged-in users can access them
The problem
sequenceDiagram
participant B as Browser
participant API as API endpoint
participant A as Auth.php
participant DB as SQLite
participant S as Session
rect rgb(255, 251, 235)
Note over B,S: Register
B->>API: POST username + password
API->>A: Auth::register(db, user, pass)
A->>A: password_hash(pass)
A->>DB: INSERT INTO users
DB-->>A: id = 7
A-->>API: user id 7
API->>S: session user_id = 7
API-->>B: redirect to notes
end
rect rgb(236, 253, 245)
Note over B,S: Login
B->>API: POST username + password
API->>A: Auth::login(db, user, pass)
A->>DB: SELECT password_hash
DB-->>A: stored hash
A->>A: password_verify() OK
A-->>API: user id 7
API->>S: session user_id = 7
API-->>B: redirect to notes
end
rect rgb(254, 242, 242)
Note over B,S: Auth guard (every protected page)
B->>API: GET /learn/notes
API->>S: session user_id?
S-->>API: 7
API->>DB: SELECT * FROM users WHERE id = 7
DB-->>API: user row
Note over API: user found, show content
end
Sessions remember this browser, but they don't know who is using it. Anyone who opens your app can see anyone else's data. You need real user accounts: a username, a password, and a way to prove "I am who I say I am."
Step 1: A database
You need a place to store users. SQLite is perfect for this — it's a database in a single file. No server to install, no credentials to configure. PHP includes PDO (PHP Data Objects) for talking to databases.
// src/Learn/DB.php — open the database and create tables
$pdo = new \PDO('sqlite:' . __DIR__ . '/../../storage/learn.db');
$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
$pdo->query('PRAGMA journal_mode = WAL');
$pdo->query('PRAGMA foreign_keys = ON');
$pdo->query("CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
)");
WAL mode lets multiple coroutines read the database simultaneously (important for ZealPHP).
foreign_keys enforces data integrity when we add notes in the next lesson.
Step 2: Register a user
The key insight: never store passwords as plaintext. PHP's password_hash()
generates a one-way hash that can't be reversed. Even if someone steals your database, they can't
read the passwords.
// src/Learn/Auth.php — register
public static function register(\PDO $db, string $username, string $password): ?int
{
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $db->prepare(
'INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)'
);
$stmt->execute([$username, $hash, time()]);
return (int) $db->lastInsertId();
}
Think of password_hash() like a safe with a one-way lock. You put the
password in, the safe locks, and even you can't open it to see what's inside. But you can check
whether a new password matches the one inside — that's password_verify().
Step 3: Log in
// src/Learn/Auth.php — login
public static function login(\PDO $db, string $username, string $password): ?int
{
$stmt = $db->prepare('SELECT id, password_hash FROM users WHERE username = ?');
$stmt->execute([$username]);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password_hash'])) {
return null;
}
return (int) $user['id'];
}
password_verify() checks whether the password matches the hash without ever decrypting it.
If it matches, store the user ID in the session — now the server knows who you are on every request.
Step 4: The auth guard
Any page that needs a logged-in user checks the session at the top:
$user = Auth::currentUser();
if (!$user) {
// Show login form
} else {
// Show the protected content
}
Auth::currentUser() reads $g->session['user_id'], looks up the user in
SQLite, and returns the user row or null. If the session has a stale user_id (e.g.,
after a database reset), it returns null too.
Architecture: proper OOP
The auth logic lives in ZealPHP\Learn\Auth — already in your vendor/ directory via the framework dependency. The API endpoint is a thin wrapper that delegates to it:
// api/learn/register.php — thin endpoint
$register = function () {
$g = \ZealPHP\G::instance();
$creds = Auth::readCredentials($g);
$userId = Auth::register(DB::open(), $creds['username'], $creds['password']);
// ... set session, redirect
};
Business logic in src/, endpoints in api/. The endpoint delegates;
the class does the work. This pattern scales — your API files stay under 20 lines each.
Showing errors: register() returns null, and htmx's 2xx rule
Two things go wrong on the way to a new account, and both are easy to swallow silently.
First, the username might be taken. The users.username column is
UNIQUE, so a duplicate INSERT throws a PDOException. That's why
register()'s return type is ?int — catch the constraint violation and return
null so the caller can tell "created" from "already exists":
// src/Learn/Auth.php — register(), the real version
public static function register(\PDO $db, string $username, string $password): ?int
{
try {
$stmt = $db->prepare(
'INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)'
);
$stmt->execute([$username, password_hash($password, PASSWORD_DEFAULT), time()]);
return (int) $db->lastInsertId();
} catch (\PDOException $e) {
return null; // UNIQUE constraint → username already exists
}
}
The endpoint turns that null into an honest HTTP status and a one-line message — a real
409 Conflict, not a 200 with the error buried in the body:
// api/learn/register.php — fail with the right status
$userId = Auth::register(DB::open(), $creds['username'], $creds['password']);
if ($userId === null) {
header('Content-Type: text/html; charset=utf-8');
return $this->response('<p class="auth-error">That username is already taken.</p>', 409);
}
Second — the subtle one — htmx won't show that message by default. The form posts with
hx-post and swaps the response into a feedback <div>:
<form hx-post="/api/learn/register" hx-target="#auth-feedback-reg" hx-swap="innerHTML">
…
<div id="auth-feedback-reg"></div>
</form>
htmx 2.x only swaps 2xx responses. A 4xx is treated as an error: the body is
discarded and htmx:responseError fires instead. So the 409 arrives, the
message is right there in the response — and the form shows nothing. Don't "fix" it by returning
200 (your JSON clients depend on the real status); instead opt these two endpoints back into
swapping with a one-time htmx:beforeSwap listener:
// public/js/site-nav.js — let auth errors render in the form
document.addEventListener('htmx:beforeSwap', (e) => {
const path = (e.detail.requestConfig && e.detail.requestConfig.path) || '';
const status = e.detail.xhr ? e.detail.xhr.status : 0;
if (status >= 400 && /^\/api\/learn\/(login|register)$/.test(path)) {
e.detail.shouldSwap = true; // swap the <p class="auth-error"> into the target
e.detail.isError = false; // a handled validation message, not a console error
}
});
Now a duplicate registration — or a wrong password on the login form below — shows a red message in
place, no page reload, while the API still returns a correct 409/401 for
non-browser clients. That's the htmx way to do form validation: honest status codes on the wire, a
single beforeSwap opt-in on the client.
Pick a username and password. This creates a real account stored in SQLite. You'll use it in the next three lessons to save notes and chat with the AI.
Already have an account?
Challenge 1: change password
Build a "change password" feature. You'll need: a form with old password and new password fields, an endpoint that verifies the old password with password_verify(), then updates the hash with password_hash().
Hint 1
Use a prepared UPDATE statement: UPDATE users SET password_hash = ? WHERE id = ?
Hint 2
Always verify the old password first — never trust the client to send only valid requests
Challenge 2: rate-limit failed logins
Right now /api/learn/login happily accepts unlimited password guesses. Add a per-IP rate limit: after 5 failed attempts in 60 seconds, return 429 for that IP for the next 5 minutes. Use Store for the counter — see Foundations → Sharing State for the table-allocation pattern.
Hint 1
Allocate the table at boot: Store::make('login_fails', 10000, ['count' => [Store::TYPE_INT, 4], 'reset_at' => [Store::TYPE_INT, 4]])
Hint 2
Key by $request->server['REMOTE_ADDR']. Bump count only when the password check fails — never on success.
Hint 3
On lockout, set the status and header separately (status() returns bool, not $response), then return an error body: $response->status(429); $response->header('Retry-After', '300'); return ['error' => 'Too many failed attempts'];
Challenge 3: invalidate the session on logout
The current /api/learn/logout probably just clears $g->session['user_id']. That leaves the session file alive with whatever else was in it. Switch to session_destroy() and re-generate the session id on the user’s next request, so the old PHPSESSID can't be replayed even if it leaked into a log.
Hint 1
session_destroy() wipes the server-side file but leaves $_SESSION alive in this request. Pair with session_unset().
Hint 2
On login (not logout) call session_regenerate_id(true) — that's where session fixation attacks land; true deletes the old file.
Hint 3
Set the PHPSESSID cookie's Max-Age to 0 in the logout response so the browser drops its copy too.
Challenge 4: password reset via email (mocked)
Build a password-reset flow. User enters an email; if it exists, you generate a single-use token, store it with a 30-min expiry, and "email" it (just log the URL for now). The reset link points at /reset?token=.... Submitting that page with a new password verifies the token, updates the hash, and invalidates the token.
Hint 1
Token storage: a password_resets table with columns token (unique), user_id, expires_at. Or use Store::make('pw_resets', 1024, [...]) for in-memory.
Hint 2
random_bytes(32) + bin2hex() gives you a 64-char token. Constant-time compare on lookup.
Hint 3
"Send the email" = elog('Reset link: https://yourapp/reset?token=' . $token). Wire real email later via swiftmailer or a transactional-mail API.
Hint 4
Delete the token row the moment it's redeemed — never leave it around for replay.
Wire your auth into ZealAPI
ZealAPI handlers (the file-based API layer at api/) have built-in helpers for guarding endpoints — $this->isAuthenticated(), $this->isAdmin(), $this->getUsername(), and the composite $this->requirePostAuth(). But ZealPHP doesn't know what your auth system looks like, so by default these return fail-closed values (false, false, null). Endpoints guarded by requirePostAuth() reject everything until you wire the hooks up.
Three one-liners in app.php tell ZealPHP how to consult your auth state. Configure once, every API handler downstream gets the answer:
<?php
use ZealPHP\App;
use ZealPHP\Learn\Auth; // the src/Learn/Auth.php class from the tutorial
App::authChecker(fn(): bool => Auth::currentUser() !== null);
App::usernameProvider(fn(): ?string => Auth::currentUser()['username'] ?? null);
$app = App::init('0.0.0.0', 8080);
$app->run();
Now any handler under api/ can guard itself with one line:
<?php
// Illustrative path: api/notes/delete.php → POST /api/notes/delete
// (The framework ships its demo notes API at api/learn/notes.php — adapt the pattern to your own api/ file.)
$delete = function() {
// POST + authenticated guard. Emits 403 JSON and returns false on failure.
if (!$this->requirePostAuth()) return;
$user = \ZealPHP\Learn\Auth::currentUser();
$g = \ZealPHP\G::instance();
\ZealPHP\Learn\Notes::delete(
\ZealPHP\Learn\DB::open(),
$user['user_id'],
(int) $g->post['note_id']
);
return ['ok' => true];
};
No subclassing, no monkey-patching ZealAPI. The framework asks your code at request time via a function pointer — adds no per-request cost when no checker is registered. Full surface: Pluggable auth hooks on the API reference page.
Key Takeaways
- SQLite + PDO gives you a full database in a single file — no server setup
password_hash()andpassword_verify()handle passwords safely- Store the user ID in
$g->sessionafter login — the session cookie handles the rest - Business logic in
src/classes, thin endpoint wrappers inapi/ - Register
App::authChecker / adminChecker / usernameProvideronce inapp.phpto wire ZealAPI handlers to your auth code — no per-endpoint plumbing