Градење безбедно PHP API: Ротација на токени, ограничување на стапката и ревизија
A secure API is not defined by a clever token format. It is defined by what happens after a token leaks, two refresh requests race, a client floods an endpoint, or an operator needs to reconstruct an incident six weeks later.
This tutorial builds a compact PHP 8.3 API with opaque access tokens, single-use refresh-token rotation, replay detection, PostgreSQL-backed rate limiting, and transactional audit events. The example exposes login, refresh, profile, and logout endpoints behind Nginx and PHP-FPM.
Prerequisites and architecture
You need Docker Engine with Compose support and curl. The containers use PHP 8.3, Nginx, and PostgreSQL. No PHP package manager or third-party application library is required.
The API deliberately uses opaque random tokens instead of JWTs. Only SHA-256 token digests reach PostgreSQL, so a database disclosure does not immediately reveal bearer credentials. Every authenticated request performs a database lookup, which costs more than local JWT verification but provides immediate revocation and straightforward account disabling.
Access tokens live for 15 minutes. Refresh tokens live for 30 days and may be used exactly once. Each successful refresh creates a replacement token in the same family. Reusing an older token revokes the entire family, including its access tokens.
Rate limits use atomic PostgreSQL upserts. This keeps the example consistent across multiple PHP replicas without introducing Redis, although a very high-traffic API would usually move counters to a dedicated low-latency service. The fixed-window algorithm is intentionally simple and can permit bursts near a window boundary.
Project structure
secure-api/
├── compose.yaml
├── Dockerfile
├── nginx.conf
├── public/
│ └── index.php
├── src/
│ └── bootstrap.php
└── sql/
└── schema.sql
Create the database schema
The audit table is append-oriented rather than magically tamper-proof. In production, the migration owner should retain schema privileges while the application role receives only the operations it needs. Exporting events to separately controlled storage provides stronger evidence than keeping the sole copy beside application data.
CREATE TABLE users (
id text PRIMARY KEY,
email text NOT NULL UNIQUE,
password_hash text NOT NULL,
enabled boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE access_tokens (
id text PRIMARY KEY,
user_id text NOT NULL REFERENCES users(id),
family_id text NOT NULL,
token_hash char(64) NOT NULL UNIQUE,
expires_at timestamptz NOT NULL,
revoked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX access_tokens_user_idx
ON access_tokens (user_id, expires_at);
CREATE TABLE refresh_tokens (
id text PRIMARY KEY,
user_id text NOT NULL REFERENCES users(id),
family_id text NOT NULL,
parent_id text REFERENCES refresh_tokens(id),
token_hash char(64) NOT NULL UNIQUE,
expires_at timestamptz NOT NULL,
used_at timestamptz,
revoked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX refresh_tokens_family_idx
ON refresh_tokens (family_id);
CREATE TABLE rate_buckets (
subject text NOT NULL,
route text NOT NULL,
window_start bigint NOT NULL,
hits integer NOT NULL,
PRIMARY KEY (subject, route, window_start)
);
CREATE TABLE audit_events (
id bigserial PRIMARY KEY,
occurred_at timestamptz NOT NULL DEFAULT now(),
actor_user_id text REFERENCES users(id),
event_type text NOT NULL,
request_id text NOT NULL,
source_ip inet,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX audit_events_actor_time_idx
ON audit_events (actor_user_id, occurred_at DESC);
Implement the security core
Create src/bootstrap.php. PostgreSQL receives explicit statement and lock timeouts through connection options. A connection timeout alone would not bound a slow query, so both concerns are configured separately.
<?php
declare(strict_types=1);
const ACCESS_TTL = 900;
const REFRESH_TTL = 2592000;
const RATE_WINDOW = 60;
function db(): PDO
{
static $pdo;
if ($pdo instanceof PDO) {
return $pdo;
}
$pdo = new PDO(
(string) getenv('DATABASE_DSN'),
(string) getenv('DATABASE_USER'),
(string) getenv('DATABASE_PASSWORD'),
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_TIMEOUT => 3,
]
);
$pdo->exec("SET statement_timeout = '2000ms'");
$pdo->exec("SET lock_timeout = '500ms'");
return $pdo;
}
function identifier(): string
{
return bin2hex(random_bytes(16));
}
function opaqueToken(): string
{
return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
}
function tokenHash(string $token): string
{
return hash('sha256', $token);
}
function requestId(): string
{
static $id;
return $id ??= identifier();
}
function clientIp(): string
{
return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
}
function input(): array
{
$raw = file_get_contents('php://input');
$value = json_decode($raw === '' ? '{}' : $raw, true, 32, JSON_THROW_ON_ERROR);
if (!is_array($value)) {
throw new InvalidArgumentException('JSON object required');
}
return $value;
}
function respond(int $status, array $payload): never
{
http_response_code($status);
header('Content-Type: application/json');
header('X-Request-ID: ' . requestId());
echo json_encode($payload, JSON_THROW_ON_ERROR);
exit;
}
function audit(?string $userId, string $type, array $metadata = []): void
{
$sql = 'INSERT INTO audit_events
(actor_user_id, event_type, request_id, source_ip, metadata)
VALUES (:u, :t, :r, CAST(:m AS jsonb))';
db()->prepare($sql)->execute([
'u' => $userId,
't' => $type,
'r' => requestId(),
'm' => json_encode($metadata, JSON_THROW_ON_ERROR),
]);
}
function rateLimit(string $subject, string $route, int $maximum): void
{
$bucket = intdiv(time(), RATE_WINDOW);
$sql = 'INSERT INTO rate_buckets
(subject, route, window_start, hits)
VALUES (:s, :r, :w, 1)
ON CONFLICT (subject, route, window_start)
DO UPDATE SET hits = rate_buckets.hits + 1
WHERE rate_buckets.hits < :maximum
RETURNING hits';
$statement = db()->prepare($sql);
$statement->execute([
's' => $subject,
'r' => $route,
'w' => $bucket,
'maximum' => $maximum,
]);
header('X-RateLimit-Limit: ' . $maximum);
if ($statement->fetchColumn() === false) {
header('Retry-After: ' . (RATE_WINDOW - (time() % RATE_WINDOW)));
respond(429, ['error' => 'rate_limit_exceeded']);
}
}
function issuePair(string $userId, string $familyId, ?string $parentId): array
{
$access = opaqueToken();
$refresh = opaqueToken();
$accessId = identifier();
$refreshId = identifier();
db()->prepare(
"INSERT INTO access_tokens
(id, user_id, family_id, token_hash, expires_at)
VALUES (:id, :u, :f, :h, now() + interval '15 minutes')"
)->execute([
'id' => $accessId, 'u' => $userId,
'f' => $familyId, 'h' => tokenHash($access),
]);
db()->prepare(
"INSERT INTO refresh_tokens
(id, user_id, family_id, parent_id, token_hash, expires_at)
VALUES (:id, :u, :f, :p, :h, now() + interval '30 days')"
)->execute([
'id' => $refreshId, 'u' => $userId, 'f' => $familyId,
'p' => $parentId, 'h' => tokenHash($refresh),
]);
return [
'access_token' => $access,
'token_type' => 'Bearer',
'expires_in' => ACCESS_TTL,
'refresh_token' => $refresh,
'refresh_expires_in' => REFRESH_TTL,
];
}
function rotate(string $token): ?array
{
$pdo = db();
$pdo->beginTransaction();
$statement = $pdo->prepare(
'SELECT * FROM refresh_tokens WHERE token_hash = :h FOR UPDATE'
);
$statement->execute(['h' => tokenHash($token)]);
$row = $statement->fetch();
if (!$row) {
$pdo->rollBack();
audit(null, 'refresh_rejected', ['reason' => 'unknown']);
return null;
}
if ($row['used_at'] !== null || $row['revoked_at'] !== null) {
$pdo->prepare(
'UPDATE refresh_tokens SET revoked_at = COALESCE(revoked_at, now())
WHERE family_id = :f'
)->execute(['f' => $row['family_id']]);
$pdo->prepare(
'UPDATE access_tokens SET revoked_at = COALESCE(revoked_at, now())
WHERE family_id = :f'
)->execute(['f' => $row['family_id']]);
audit($row['user_id'], 'refresh_reuse_detected');
$pdo->commit();
return null;
}
if (strtotime($row['expires_at']) <= time()) {
$pdo->prepare(
'UPDATE refresh_tokens SET revoked_at = now() WHERE id = :id'
)->execute(['id' => $row['id']]);
audit($row['user_id'], 'refresh_expired');
$pdo->commit();
return null;
}
$pdo->prepare(
'UPDATE refresh_tokens SET used_at = now() WHERE id = :id'
)->execute(['id' => $row['id']]);
$pair = issuePair($row['user_id'], $row['family_id'], $row['id']);
audit($row['user_id'], 'refresh_rotated');
$pdo->commit();
return $pair;
}
function authenticate(): array
{
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/^Bearer ([A-Za-z0-9_-]+)$/', $header, $matches)) {
respond(401, ['error' => 'missing_bearer_token']);
}
$sql = 'SELECT a.id AS access_id, a.user_id, u.email
FROM access_tokens a
JOIN users u ON u.id = a.user_id
WHERE a.token_hash = :h
AND a.expires_at > now()
AND a.revoked_at IS NULL
AND u.enabled = true';
$statement = db()->prepare($sql);
$statement->execute(['h' => tokenHash($matches[1])]);
$identity = $statement->fetch();
if (!$identity) {
respond(401, ['error' => 'invalid_access_token']);
}
return $identity;
}
The row lock in rotate() serializes concurrent uses of the same refresh token. One request succeeds; the next observes used_at and revokes the family. This is intentionally strict: a client must serialize refresh attempts and must durably replace its stored refresh token.
Add the HTTP routes
Create public/index.php. Authentication failures use a generic response so callers cannot enumerate accounts. Security mutations and their successful audit records share transactions.
<?php
declare(strict_types=1);
require __DIR__ . '/../src/bootstrap.php';
try {
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
if ($method === 'POST' && $path === '/login') {
rateLimit(clientIp(), 'login', 10);
$body = input();
$email = strtolower(trim((string) ($body['email'] ?? '')));
$password = (string) ($body['password'] ?? '');
$statement = db()->prepare(
'SELECT id, password_hash FROM users
WHERE email = :email AND enabled = true'
);
$statement->execute(['email' => $email]);
$user = $statement->fetch();
if (!$user || !password_verify($password, $user['password_hash'])) {
audit($user['id'] ?? null, 'login_failed');
respond(401, ['error' => 'invalid_credentials']);
}
db()->beginTransaction();
$pair = issuePair($user['id'], identifier(), null);
audit($user['id'], 'login_succeeded');
db()->commit();
respond(200, $pair);
}
if ($method === 'POST' && $path === '/refresh') {
rateLimit(clientIp(), 'refresh', 20);
$token = (string) (input()['refresh_token'] ?? '');
if ($token === '') {
respond(400, ['error' => 'refresh_token_required']);
}
$pair = rotate($token);
$pair === null
? respond(401, ['error' => 'invalid_refresh_token'])
: respond(200, $pair);
}
if ($method === 'GET' && $path === '/me') {
$identity = authenticate();
rateLimit($identity['user_id'], 'me', 60);
respond(200, [
'id' => $identity['user_id'],
'email' => $identity['email'],
]);
}
if ($method === 'POST' && $path === '/logout') {
$identity = authenticate();
rateLimit($identity['user_id'], 'logout', 20);
db()->beginTransaction();
db()->prepare(
'UPDATE access_tokens SET revoked_at = now() WHERE id = :id'
)->execute(['id' => $identity['access_id']]);
audit($identity['user_id'], 'logout');
db()->commit();
respond(204, []);
}
respond(404, ['error' => 'not_found']);
} catch (JsonException | InvalidArgumentException) {
respond(400, ['error' => 'malformed_request']);
} catch (Throwable $error) {
if (db()->inTransaction()) {
db()->rollBack();
}
error_log(requestId() . ' ' . $error->getMessage());
respond(500, ['error' => 'internal_error']);
}
Containerize the API
The PHP image needs only the first-party PDO PostgreSQL extension. Create the following Dockerfile:
FROM php:8.3-fpm-bookworm
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq-dev \
&& docker-php-ext-install pdo_pgsql \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /var/www
COPY public/ public/
COPY src/ src/
USER www-data
CMD ["php-fpm", "-F"]
Create nginx.conf:
server {
listen 8080;
server_name _;
root /var/www/public;
client_max_body_size 32k;
client_body_timeout 5s;
send_timeout 10s;
location / {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/public/index.php;
fastcgi_param HTTP_AUTHORIZATION $http_authorization;
fastcgi_pass app:9000;
fastcgi_connect_timeout 2s;
fastcgi_read_timeout 5s;
}
}
Create compose.yaml:
services:
db:
image: postgres:16-bookworm
environment:
POSTGRES_DB: api
POSTGRES_USER: api
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
- ./sql/schema.sql:/docker-entrypoint-initdb.d/001-schema.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U api -d api"]
interval: 5s
timeout: 3s
retries: 10
app:
build: .
environment:
DATABASE_DSN: pgsql:host=db;port=5432;dbname=api
DATABASE_USER: api
DATABASE_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
depends_on:
db:
condition: service_healthy
nginx:
image: nginx:1.26-alpine
ports:
- "127.0.0.1:8080:8080"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
volumes:
pgdata:
Create a local secret without overwriting an existing file, start the stack, and provision the first account. The password prompt avoids placing the account password directly in the command history.
test ! -e .env || { echo ".env already exists"; exit 1; }
umask 077
printf 'POSTGRES_PASSWORD=%s\n' "$(openssl rand -hex 32)" > .env
docker compose up --build -d
read -rsp 'Initial API password: ' API_PASSWORD
echo
docker compose exec -e API_PASSWORD="$API_PASSWORD" app php -r '
require "/var/www/src/bootstrap.php";
$hash = password_hash(getenv("API_PASSWORD"), PASSWORD_ARGON2ID);
db()->prepare(
"INSERT INTO users (id, email, password_hash)
VALUES (:id, :email, :hash)"
)->execute([
"id" => bin2hex(random_bytes(16)),
"email" => "[email protected]",
"hash" => $hash
]);
'
unset API_PASSWORD
Test rotation, replay, and revocation
Log in, call the protected route, rotate the refresh token, and then deliberately replay the old refresh token:
read -rsp 'API password: ' API_PASSWORD
echo
PAIR=$(curl -fsS http://127.0.0.1:8080/login \
-H 'Content-Type: application/json' \
--data "{\"email\":\"[email protected]\",\"password\":\"$API_PASSWORD\"}")
unset API_PASSWORD
ACCESS=$(printf '%s' "$PAIR" | php -r \
'$j=json_decode(stream_get_contents(STDIN),true); echo $j["access_token"];')
REFRESH=$(printf '%s' "$PAIR" | php -r \
'$j=json_decode(stream_get_contents(STDIN),true); echo $j["refresh_token"];')
curl -i http://127.0.0.1:8080/me \
-H "Authorization: Bearer $ACCESS"
ROTATED=$(curl -fsS http://127.0.0.1:8080/refresh \
-H 'Content-Type: application/json' \
--data "{\"refresh_token\":\"$REFRESH\"}")
curl -i http://127.0.0.1:8080/refresh \
-H 'Content-Type: application/json' \
--data "{\"refresh_token\":\"$REFRESH\"}"
The final request should return 401. Because replay revokes the family, the access token in ROTATED must subsequently fail too. Repeated login calls should eventually return 429 with Retry-After.
Production security and observability
Terminate TLS at a hardened reverse proxy or load balancer and expose only ports 80 and 443 publicly. Port 8080 is deliberately bound to loopback. Do not expose PostgreSQL through the host firewall.
If another proxy sits in front of Nginx, configure an explicit trusted-proxy list before accepting forwarded client addresses. Blindly trusting X-Forwarded-For lets callers evade IP limits and forge audit data.
Browser clients should keep refresh tokens in secure, HTTP-only, same-site cookies and add CSRF defenses to refresh and logout. Native and server clients should use an operating-system credential store. Bearer tokens do not belong in URLs, logs, analytics events, or browser local storage.
Export structured application logs using the request ID returned in X-Request-ID. Alert on refresh replays, sustained login failures, database timeout errors, and unusual 429 rates. Audit IP addresses are personal data in many jurisdictions, so define retention and access policies rather than preserving them indefinitely.
Schedule bounded cleanup batches for expired tokens and old rate buckets. Large unbounded deletes create locks, write amplification, and replication pressure. High-volume audit tables benefit from time partitioning and asynchronous export, while authentication-state changes should keep their local transactional audit event.
Common failures
- Legitimate refresh requests trigger replay detection: the client is refreshing concurrently or retrying after losing the successful response. Serialize refreshes and persist the replacement before releasing waiting requests.
- Every request appears to have one IP: the proxy chain is not passing or safely resolving the original address.
- Database connections accumulate: PHP-FPM worker counts exceed PostgreSQL capacity. Bound the pool indirectly through FPM sizing or add a transaction-pooling proxy.
- Requests fail after two seconds: inspect query plans before increasing
statement_timeout. Missing indexes and lock contention are usually more important than a larger budget. - Schema changes do not run after editing the SQL file: PostgreSQL initialization scripts execute only for a new data volume. Production deployments need explicit, versioned migrations.
Final verification checklist
- Invalid login responses do not reveal whether an email exists.
- Only token hashes, never bearer tokens, appear in PostgreSQL and logs.
- A refresh token succeeds once, and replay revokes its complete family.
- Logout immediately invalidates the presented access token.
- Rate-limit updates remain atomic across multiple PHP replicas.
- Connection, query, lock, proxy read, and request-body limits are independently bounded.
- Authentication mutations and their success audits commit together.
- TLS, firewall rules, secret injection, backups, retention, and alerting are defined before public exposure.
The durable lesson is that authentication is a lifecycle, not a signature check. Short-lived access, strict rotation, bounded abuse, and useful evidence turn inevitable credential mistakes into contained incidents. That containment is the real security feature.