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();