You don’t need a framework to build a safe JSON API in PHP. This is the pattern I used for OBLIUP, a multi-user invoicing platform with three roles: admin, supervisor and collaborator.
1. Hash passwords with bcrypt
$hash = password_hash($plainPassword, PASSWORD_BCRYPT, ['cost' => 12]);
// Store $hash in users.password_hash (VARCHAR(255)).
if (password_verify($submitted, $user['password_hash'])) {
// correct password
}
Cost 12 makes every guess expensive for an attacker while keeping a login under a few hundred milliseconds.
2. Configure the session cookie before starting the session
// config.php
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']),
'httponly' => true, // JavaScript can't read the cookie
'samesite' => 'Strict', // never sent from other sites (CSRF protection)
]);
session_start();
3. Write the login endpoint
// auth/login.php
require __DIR__ . '/../config.php';
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$stmt = $pdo->prepare('SELECT id, password_hash, role FROM users WHERE login = ?');
$stmt->execute([$data['login'] ?? '']);
$user = $stmt->fetch();
if (!$user || !password_verify($data['password'] ?? '', $user['password_hash'])) {
http_response_code(401);
exit(json_encode(['error' => 'Invalid login or password']));
}
session_regenerate_id(true); // new session id after login: prevents session fixation
$_SESSION['user_id'] = (int) $user['id'];
$_SESSION['role'] = $user['role'];
echo json_encode(['ok' => true, 'role' => $user['role']]);
Return the same message for an unknown login and a wrong password, so the API doesn’t reveal which accounts exist.
4. Add requireAuth and requireRole
// auth/middleware.php
function requireAuth(): array {
if (empty($_SESSION['user_id'])) {
http_response_code(401);
exit(json_encode(['error' => 'Not signed in']));
}
return ['id' => $_SESSION['user_id'], 'role' => $_SESSION['role']];
}
function requireRole(string ...$roles): array {
$user = requireAuth();
if (!in_array($user['role'], $roles, true)) {
http_response_code(403);
exit(json_encode(['error' => 'Not allowed']));
}
return $user;
}
5. Protect each endpoint on its first line
// api/users.php: only admins manage users
require __DIR__ . '/../config.php';
require __DIR__ . '/../auth/middleware.php';
$current = requireRole('admin');
// api/factures.php: every signed-in role can read invoices
$current = requireAuth();
Putting the rule at the top makes it impossible to miss when you read the file, and a missing check is easy to spot in review.
6. Log out properly
$_SESSION = [];
setcookie(session_name(), '', ['expires' => time() - 3600, 'path' => '/', 'httponly' => true, 'samesite' => 'Strict']);
session_destroy();