A small, security-first JSON REST API in plain PHP — stateless bearer-token authentication, PDO prepared statements, strict input validation, per-client rate limiting, correct status codes and security headers, and error responses that never leak internals.
This project demonstrates the API-specific security concerns that a browser-form project does not face: there is no session cookie and no CSRF token, so authentication moves into an Authorization: Bearer <token> header, and every response is machine-readable JSON. The same server-side discipline still applies — parameterized queries, validated input, least-privilege authorization, and no stack traces on the wire.
Threat model and the concepts it demonstrates:
- Broken authentication — every protected route requires a bearer token; tokens are stored hashed (
hash('sha256', ...)) so a database leak does not hand over live credentials. - Broken object-level authorization (BOLA/IDOR) — a row is only returned/mutated when it belongs to the authenticated user.
- SQL injection — PDO prepared statements everywhere;
PDO::ATTR_EMULATE_PREPARES => false. - Injection via body — JSON is parsed with
JSON_THROW_ON_ERRORand each field is type- and range-checked before use. - Abuse / brute force — a per-token (or per-IP) rate limit returns
429with aRetry-Afterheader. - Information disclosure — a global handler returns generic JSON errors and correct status codes; details go to the log, never the client.
- Authenticate stateless API requests with an
Authorization: Bearer <token>header instead of a session cookie. - Store API tokens hashed (
sha256) so a database leak yields no usable credentials. - Scope every query by
user_idto defend against IDOR/BOLA. - Safely decode and strictly validate JSON request bodies (
JSON_THROW_ON_ERROR+ per-field type/length checks). - Enforce per-client rate limiting and return the correct HTTP status codes (
401/404/422/429). - Return generic JSON errors and set security headers so responses never leak internals.
Client ──HTTP + Authorization: Bearer <token>──▶ public/index.php (front controller)
│
┌─────────────────────────────────────┼─────────────────────────────────────┐
▼ ▼ ▼ ▼ ▼
Security headers Auth::user() RateLimiter Router (method+path) JSON body parse
(CSP/HSTS/nosniff) (hash token → row) (429 + Retry-After) → Controller (JSON_THROW_ON_ERROR)
└─────────────────────────────────────┴─────────────────────────────────────┘
▼
Controller → PDO prepared statements (scoped to user_id)
▼
Response::json($data, $status) (+ correct status code)
rest-api/
├── public/
│ └── index.php # front controller; the ONLY served file
├── src/
│ ├── Database.php # PDO connection factory
│ ├── Auth.php # bearer-token verification
│ ├── RateLimiter.php # per-token throttle
│ ├── Response.php # JSON responses + security headers
│ └── NoteController.php # example resource (CRUD, scoped to owner)
├── config/
│ └── config.php # DSN + credentials (outside public/)
└── sql/
└── schema.sql
Route all requests to public/index.php (Apache .htaccess shown below); nothing else is web-reachable.
# public/.htaccess — send every request to the front controller
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [QSA,L]CREATE TABLE users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(254) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- API tokens are stored HASHED; the plaintext is shown to the user once at issue time.
CREATE TABLE api_tokens (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the bearer token
expires_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_token (token_hash),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE notes (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL, -- owner; every query is scoped by this
title VARCHAR(200) NOT NULL,
body TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;<?php
final class Response
{
public static function json(mixed $data, int $status = 200): never
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff'); // no MIME sniffing
header('Cache-Control: no-store'); // never cache token-scoped data
header("Content-Security-Policy: default-src 'none'"); // API returns no HTML
echo json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
exit;
}
public static function error(string $message, int $status): never
{
self::json(['error' => $message], $status); // generic; no internals
}
}<?php
final class Auth
{
public function __construct(private PDO $db) {}
/** Returns the authenticated user id, or sends 401 and exits. */
public function requireUser(): int
{
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/^Bearer\s+(\S+)$/', $header, $m)) {
Response::error('Missing or malformed Authorization header', 401);
}
// Look the token up by its HASH — the plaintext is never stored.
$stmt = $this->db->prepare(
'SELECT user_id FROM api_tokens
WHERE token_hash = :h AND (expires_at IS NULL OR expires_at > NOW())
LIMIT 1'
);
$stmt->execute([':h' => hash('sha256', $m[1])]);
$row = $stmt->fetch();
if (!$row) {
Response::error('Invalid or expired token', 401);
}
return (int) $row['user_id'];
}
}[!warning] Never store API tokens in plaintext A token in the
Authorizationheader is a bearer credential — whoever holds it is the user. Store onlyhash('sha256', $token)server-side and compare hashes, exactly as you would for a password (though tokens are high-entropy, so a fast hash is acceptable here). If the table leaks, the attacker gets hashes, not usable tokens. Also require HTTPS so the token is never sent in clear text.
<?php
final class RateLimiter
{
private const MAX = 100; // requests ...
private const WINDOW = 60; // ... per minute, per key
public function __construct(private PDO $db) {}
/** Sends 429 with Retry-After when the caller exceeds the budget. */
public function enforce(string $key): void
{
// A production system would use Redis/APCu; this keeps a per-window counter.
$stmt = $this->db->prepare(
'INSERT INTO rate_counter (rk, window_start, hits) VALUES (:k, :w, 1)
ON DUPLICATE KEY UPDATE
hits = IF(window_start = :w, hits + 1, 1),
window_start = :w'
);
$window = (int) (time() / self::WINDOW) * self::WINDOW;
$stmt->execute([':k' => hash('sha256', $key), ':w' => $window]);
$count = (int) $this->db->query(
'SELECT hits FROM rate_counter WHERE rk = ' .
$this->db->quote(hash('sha256', $key))
)->fetchColumn();
if ($count > self::MAX) {
header('Retry-After: ' . (($window + self::WINDOW) - time()));
Response::error('Rate limit exceeded', 429);
}
}
}<?php
final class NoteController
{
public function __construct(private PDO $db, private int $userId) {}
public function index(): never
{
$stmt = $this->db->prepare('SELECT id, title, body, created_at FROM notes WHERE user_id = :u');
$stmt->execute([':u' => $this->userId]);
Response::json(['data' => $stmt->fetchAll()]);
}
public function show(int $id): never
{
// IDOR defence: the WHERE clause is scoped to the owner, so another
// user's id simply returns nothing (404), never their row.
$stmt = $this->db->prepare(
'SELECT id, title, body, created_at FROM notes WHERE id = :id AND user_id = :u'
);
$stmt->execute([':id' => $id, ':u' => $this->userId]);
$note = $stmt->fetch();
$note ? Response::json(['data' => $note]) : Response::error('Not found', 404);
}
public function create(array $input): never
{
// Strict validation of the decoded JSON body.
$title = trim((string) ($input['title'] ?? ''));
$body = trim((string) ($input['body'] ?? ''));
if ($title === '' || mb_strlen($title) > 200 || $body === '') {
Response::error('Invalid payload', 422); // Unprocessable Entity
}
$stmt = $this->db->prepare(
'INSERT INTO notes (user_id, title, body) VALUES (:u, :t, :b)'
);
$stmt->execute([':u' => $this->userId, ':t' => $title, ':b' => $body]);
Response::json(['id' => (int) $this->db->lastInsertId()], 201); // Created
}
public function delete(int $id): never
{
$stmt = $this->db->prepare('DELETE FROM notes WHERE id = :id AND user_id = :u');
$stmt->execute([':id' => $id, ':u' => $this->userId]);
Response::json(null, 204); // No Content
}
}<?php
declare(strict_types=1);
// Fail closed: log details, never echo them.
set_exception_handler(static function (Throwable $e): void {
error_log($e->getMessage()); // to the server log only
Response::error('Internal server error', 500);
});
require __DIR__ . '/../src/Database.php';
require __DIR__ . '/../src/Response.php';
require __DIR__ . '/../src/Auth.php';
require __DIR__ . '/../src/RateLimiter.php';
require __DIR__ . '/../src/NoteController.php';
$db = Database::get();
$uid = (new Auth($db))->requireUser(); // 401 if unauthenticated
// Rate-limit by the presented token (falls back to IP).
(new RateLimiter($db))->enforce($_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['REMOTE_ADDR'] ?? ''));
$method = $_SERVER['REQUEST_METHOD'];
$path = trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?? '', '/'); // e.g. "notes/5"
$parts = $path === '' ? [] : explode('/', $path);
$ctrl = new NoteController($db, $uid);
// Decode a JSON body once, safely.
$input = [];
if (in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
try {
$raw = file_get_contents('php://input');
$input = $raw === '' ? [] : json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
Response::error('Malformed JSON', 400);
}
if (!is_array($input)) {
Response::error('Malformed JSON', 400);
}
}
// Minimal router: /notes and /notes/{id}
match (true) {
$parts === ['notes'] && $method === 'GET' => $ctrl->index(),
$parts === ['notes'] && $method === 'POST' => $ctrl->create($input),
count($parts) === 2 && $parts[0] === 'notes' && ctype_digit($parts[1]) && $method === 'GET'
=> $ctrl->show((int) $parts[1]),
count($parts) === 2 && $parts[0] === 'notes' && ctype_digit($parts[1]) && $method === 'DELETE'
=> $ctrl->delete((int) $parts[1]),
default => Response::error('Not found', 404),
};[!warning] Do not leak exceptions to the client Returning
['error' => $e->getMessage()]or lettingdisplay_errorsrender a stack trace exposes SQL, file paths, and library versions — a reconnaissance gift. Register a global exception handler that logs the detail server-side and returns a generic{"error":"Internal server error"}with a500. Setdisplay_errors = Offin productionphp.ini.
| Risk | Mitigation in this project |
|---|---|
| Broken authentication | Authorization: Bearer required on every route; tokens stored as sha256 hashes, checked against expiry. |
| IDOR / BOLA | Every query is scoped by user_id; another user's id yields 404, never their data. |
| SQL injection | PDO prepared statements throughout; PDO::ATTR_EMULATE_PREPARES => false. |
| Malformed / injected body | json_decode(..., JSON_THROW_ON_ERROR) + per-field type/length validation → 400/422. |
| Brute force / abuse | Per-token rate limit returns 429 with Retry-After. |
| Information disclosure | Global exception handler logs detail, returns generic 500; display_errors = Off. |
| Response tampering / caching | X-Content-Type-Options: nosniff, Cache-Control: no-store, restrictive CSP. |
| Credential interception | Requires HTTPS/HSTS so bearer tokens are never sent in clear text. |
See the OWASP REST Security cheat sheet and the API Security Top 10.
- Swap opaque tokens for short-lived JWTs plus refresh tokens (validate signature and
exp; never trustalg: none). - Add scopes/roles for fine-grained authorization beyond ownership.
- Move rate limiting to Redis/APCu for accuracy under concurrency.
- Add request/response schema validation (JSON Schema / OpenAPI) and content negotiation.
- Emit structured audit logs (who, what, when) without recording secrets or bodies.
- Add pagination,
ETag/conditional requests, and429/503back-off guidance.
A secure REST API is the browser-form discipline minus the session: parameterized PDO queries, strict input validation, and owner-scoped authorization stay the same, while authentication shifts to a hashed bearer token and every response is generic JSON carrying security headers and the correct status code. Fail closed — log detail server-side, never on the wire — and require HTTPS so tokens are never exposed in transit.
- OWASP Cheat Sheet — REST Security
- OWASP API Security Top 10
- PHP Manual — json_decode() / JSON_THROW_ON_ERROR
- PHP Manual — PDO prepared statements
- Prepared-Statements — parameterized PDO queries used by every endpoint
- SQL-Injection-Prevention — why concatenated queries are fatal in an API too
- Password-Hashing — issuing accounts whose credentials mint API tokens
- Security-Related-Headers — the
nosniff/ CSP / cache headers set on each response - Error-Logging-Best-Practices — logging failures without leaking them to clients
- User-Management-CRUD — companion project sharing the
Database/Authlayer - Mini Projects — index
- Secure PHP Development — course home