A multi-user task and kanban application in plain PHP 8.2 that demonstrates ownership-scoped authorization, secure CRUD, and defence-in-depth against the OWASP Top 10.
Task Manager lets registered users create boards, add tasks, move them across todo → doing → done columns, and edit or delete only the records they own. It is a deliberately small, dependency-light reference build: no framework, just Composer/PSR-4 autoloading, a thin router, PDO, and hardened sessions.
The threat model assumes an authenticated but adversarial user population plus anonymous attackers. Concretely we defend against: broken object-level authorization (user A reading/editing user B's task — OWASP A01), SQL injection via task titles and IDs (A03), stored XSS in task descriptions (A03), CSRF on every state-changing action (part of A01), session fixation/hijacking (A07), and credential stuffing against the login form (A07). Out of scope: DDoS, TLS termination (assumed at the reverse proxy), and multi-tenant billing.
Server-rendered MVC with a single front controller. Every request flows through session bootstrap, routing, an auth guard, then a controller that owns validation, an ownership check, and a PDO data-layer call before rendering an escaped template.
flowchart LR
B[Browser] -->|HTTPS| N[nginx]
N -->|FastCGI| F[php-fpm: public/index.php]
F --> R[Router]
R --> G[Auth Guard]
G --> C[Controller]
C --> V[Validator + CSRF check]
V --> M[TaskRepository - PDO]
M --> D[(MySQL 8)]
C --> T[View: htmlspecialchars]
T --> B
Ownership authorization is enforced in the data layer: every read/write is parameterised on user_id, so an attacker who forges a task_id still queries against their own rows and gets nothing.
task-manager/
├── composer.json
├── public/
│ └── index.php # front controller (only web-exposed file)
├── src/
│ ├── Core/
│ │ ├── Router.php
│ │ ├── Database.php # PDO factory (singleton)
│ │ ├── Csrf.php
│ │ ├── Session.php
│ │ └── View.php
│ ├── Controller/
│ │ ├── AuthController.php
│ │ └── TaskController.php
│ ├── Repository/
│ │ ├── UserRepository.php
│ │ └── TaskRepository.php
│ └── Middleware/
│ └── AuthGuard.php
├── views/
│ ├── layout.php
│ ├── board.php
│ └── login.php
├── migrations/
│ └── 001_init.sql
├── tests/
│ └── TaskRepositoryTest.php
├── .env.example
└── docker-compose.yml
CREATE TABLE users (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE tasks (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
title VARCHAR(200) NOT NULL,
description TEXT NULL,
status ENUM('todo','doing','done') NOT NULL DEFAULT 'todo',
position INT UNSIGNED NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_task_user FOREIGN KEY (user_id)
REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_owner_status (user_id, status, position)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;The ON DELETE CASCADE and the composite idx_owner_status index both reflect the design rule that tasks are only ever accessed through their owner.
PDO data layer — a singleton factory forces prepared statements and disables emulation so placeholders are truly server-side.
<?php
declare(strict_types=1);
namespace App\Core;
use PDO;
final class Database
{
private static ?PDO $pdo = null;
public static function conn(): PDO
{
if (self::$pdo === null) {
$dsn = sprintf(
'mysql:host=%s;dbname=%s;charset=utf8mb4',
$_ENV['DB_HOST'],
$_ENV['DB_NAME']
);
self::$pdo = new PDO($dsn, $_ENV['DB_USER'], $_ENV['DB_PASS'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
return self::$pdo;
}
}Ownership-scoped repository — note that every clause binds user_id. This single pattern closes IDOR/BOLA.
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Core\Database;
final class TaskRepository
{
public function forOwner(int $userId): array
{
$stmt = Database::conn()->prepare(
'SELECT id, title, description, status, position
FROM tasks
WHERE user_id = :uid
ORDER BY status, position'
);
$stmt->execute([':uid' => $userId]);
return $stmt->fetchAll();
}
public function create(int $userId, string $title, ?string $desc): int
{
$stmt = Database::conn()->prepare(
'INSERT INTO tasks (user_id, title, description)
VALUES (:uid, :title, :desc)'
);
$stmt->execute([':uid' => $userId, ':title' => $title, ':desc' => $desc]);
return (int) Database::conn()->lastInsertId();
}
/** Returns rows affected; 0 means the caller did not own it. */
public function updateStatus(int $userId, int $taskId, string $status): int
{
$stmt = Database::conn()->prepare(
'UPDATE tasks SET status = :st
WHERE id = :id AND user_id = :uid' // ownership in the WHERE
);
$stmt->execute([':st' => $status, ':id' => $taskId, ':uid' => $userId]);
return $stmt->rowCount();
}
}// VULNERABLE — trusts the client-supplied id, ignores ownership:
// $sql = "UPDATE tasks SET status='$status' WHERE id=$taskId";
// Attacker sends any id and rewrites another user's task.
// FIX: bind :status/:id AND add "AND user_id = :uid" as above.CSRF tokens — per-session secret, constant-time comparison.
<?php
declare(strict_types=1);
namespace App\Core;
final class Csrf
{
public static function token(): string
{
return $_SESSION['csrf'] ??= bin2hex(random_bytes(32));
}
public static function check(?string $sent): void
{
if ($sent === null || !hash_equals($_SESSION['csrf'] ?? '', $sent)) {
http_response_code(419);
exit('CSRF token mismatch');
}
}
}Auth + session hardening — password_hash/password_verify, plus fixation defence on login.
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Core\{Csrf, Session};
use App\Repository\UserRepository;
final class AuthController
{
public function __construct(private UserRepository $users) {}
public function login(): void
{
Csrf::check($_POST['_token'] ?? null);
$email = trim((string)($_POST['email'] ?? ''));
$pass = (string)($_POST['password'] ?? '');
$user = $this->users->findByEmail($email);
if ($user !== null && password_verify($pass, $user['password_hash'])) {
session_regenerate_id(true); // defeat session fixation
$_SESSION['uid'] = (int) $user['id'];
header('Location: /board');
return;
}
// Uniform failure — no user-enumeration oracle.
Session::flash('error', 'Invalid credentials.');
header('Location: /login');
}
}Session cookie flags are set once at bootstrap in public/index.php:
session_set_cookie_params([
'httponly' => true,
'secure' => true,
'samesite' => 'Strict',
'path' => '/',
]);
session_start();Output escaping — the view helper escapes everything by default; there is no raw-echo path.
<?php
declare(strict_types=1);
namespace App\Core;
final class View
{
public static function e(?string $v): string
{
return htmlspecialchars($v ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
}
// In board.php: <h3><?= App\Core\View::e($task['title']) ?></h3>Test the security-critical invariants, not the framework: (1) ownership isolation — a user cannot mutate another user's task; (2) updateStatus rejects an out-of-enum status at the validator; (3) CSRF rejection returns 419; (4) password_verify round-trips. Run repository tests against a disposable SQLite/MySQL test database.
<?php
declare(strict_types=1);
use App\Repository\TaskRepository;
use PHPUnit\Framework\TestCase;
final class TaskRepositoryTest extends TestCase
{
public function testUserCannotUpdateAnotherUsersTask(): void
{
$repo = new TaskRepository();
$ownerTask = $repo->create(userId: 1, title: 'Owned', desc: null);
// Attacker is user 2 targeting user 1's task id.
$rows = $repo->updateStatus(userId: 2, taskId: $ownerTask, status: 'done');
self::assertSame(0, $rows, 'Cross-owner update must affect 0 rows');
}
}| Risk | OWASP 2021 | Mitigation in this build |
|---|---|---|
| IDOR / cross-user task access | A01 | AND user_id = :uid on every read/write; assert rowCount() > 0 |
| SQL injection | A03 | PDO prepared statements, EMULATE_PREPARES=false, no concatenation |
| Stored XSS in title/description | A03 | htmlspecialchars(ENT_QUOTES) default in View::e; CSP header |
| CSRF on create/move/delete | A01 | Per-session token, hash_equals, SameSite=Strict cookie |
| Session fixation / hijacking | A07 | session_regenerate_id(true), HttpOnly+Secure+SameSite |
| Weak / stolen credentials | A07 | password_hash (bcrypt), uniform login error, rate-limit at proxy |
| Sensitive config in repo | A05 | .env (gitignored), never committed; .env.example only |
| Verbose errors leaking schema | A05 | display_errors=Off in prod; log to file, generic 500 page |
Run locally with Docker Compose: an nginx container fronts php-fpm, with mysql:8 as the data store. nginx passes only public/ and routes everything to index.php; src/, .env, and migrations/ are never web-reachable.
server {
root /app/public;
index index.php;
location / { try_files $uri /index.php?$query_string; }
location ~ \.php$ {
fastcgi_pass php:9000;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
add_header Content-Security-Policy "default-src 'self'" always;
add_header X-Content-Type-Options "nosniff" always;
}cp .env.example .env # set DB creds, never commit .env
docker compose up -d --build
docker compose exec php php migrations/run.php # applies 001_init.sql
# App at http://localhost:8080Set PDO with a least-privilege DB user (SELECT/INSERT/UPDATE/DELETE on the app schema only — no DDL), and run php-fpm as a non-root user with a read-only code mount.
- Add board sharing: a
board_membersjoin table and a policy that grants collaborators write access — re-derive the ownership checks so they honour membership, not justuser_id. - Introduce optimistic locking with a
versioncolumn so two tabs cannot silently overwrite each other's status change. - Add login rate limiting in PHP (per-IP + per-account leaky bucket) instead of relying solely on the proxy.
- Swap server-rendered moves for a JSON drag-and-drop API; keep CSRF via a custom header + double-submit cookie.
- Add an audit log table recording who changed which task, immutably (append-only, no UPDATE/DELETE grant).
- OWASP Top 10 (2021) — A01 Broken Access Control, A03 Injection, A07 Identification & Auth Failures
- OWASP Cheat Sheets — Cross-Site Request Forgery Prevention, Session Management, Password Storage
- PHP Manual — PDO prepared statements,
password_hash,session_set_cookie_params - PHP-FIG — PSR-4 Autoloading, PSR-12 Coding Style
- Prepared-Statements — the PDO pattern that makes the whole data layer injection-proof
- Role-Based-Access-Control — generalises the per-row ownership check into shared boards
- CSRF-Tokens — the token/
hash_equalsmechanism guarding every state change here - Password-Based-Login — the
password_hash/password_verifyflow reused byAuthController - Building-a-CRUD-API — the JSON variant referenced in Exercise 4
- Docker-for-PHP — the nginx + php-fpm + MySQL stack this project deploys on
- Security-Audit-Checklist — verifies the mitigations in the Security Review table