A page that should be private must check on the server that the visitor is logged in, before it shows anything.
A require-login function
Put the check in one function, and call it at the top of every private page.
<?php
function requireLogin(array $session): int
{
$userId = $session["user_id"] ?? null;
if (!is_int($userId)) {
// header("Location: login.php"); exit; <- in a real page
throw new RuntimeException("Login required");
}
return $userId;
}
try {
requireLogin([]);
} catch (RuntimeException $e) {
echo $e->getMessage();
}
echo " / ";
echo requireLogin(["user_id" => 7]);
Login required / 7
A real version redirects and stops the script.
<?php
declare(strict_types=1);
function requireLogin(): int
{
if (!isset($_SESSION["user_id"]) || !is_int($_SESSION["user_id"])) {
header("Location: login.php");
exit; // IMPORTANT: stop the script, or the page content is still sent
}
return $_SESSION["user_id"];
}
<?php
declare(strict_types=1);
session_start();
require __DIR__ . "/auth.php";
$userId = requireLogin(); // everything below runs only for logged-in users
Do not forget exit
header("Location: ...") only asks the browser to go elsewhere. The script keeps running unless you call exit, and the private content may still be sent in the response body.
Role checks
Different users may do different things. A simple way is a role column.
<?php
function hasRole(array $user, string ...$roles): bool
{
return in_array($user["role"] ?? "", $roles, true);
}
$admin = ["id" => 1, "role" => "admin"];
$member = ["id" => 2, "role" => "user"];
var_dump(hasRole($admin, "admin"));
var_dump(hasRole($member, "admin"));
var_dump(hasRole($member, "admin", "user"));
bool(true)
bool(false)
bool(true)
On an admin page:
<?php
$user = currentUser($pdo, $_SESSION);
if ($user === null) {
header("Location: login.php");
exit;
}
if (!hasRole($user, "admin")) {
http_response_code(403);
exit("You do not have access to this page.");
}
401 means “not logged in”, 403 means “logged in, but not allowed”. A redirect to the login page is the friendlier answer to a missing login.
Ownership checks
Roles are not enough. A normal user may edit their own posts but not other people’s. Check the owner in the SQL or in the code.
<?php
function canEdit(array $user, array $post): bool
{
return $user["role"] === "admin" || (int) $post["user_id"] === (int) $user["id"];
}
$post = ["id" => 10, "user_id" => 2];
var_dump(canEdit(["id" => 2, "role" => "user"], $post));
var_dump(canEdit(["id" => 3, "role" => "user"], $post));
var_dump(canEdit(["id" => 1, "role" => "admin"], $post));
bool(true)
bool(false)
bool(true)
Without it, a user can change a number in the URL (post-edit.php?id=11) and edit someone else’s post. This weakness is called IDOR (insecure direct object reference). It is one of the most common real-world bugs.
Hiding a link is not protection
Hiding the “Admin” menu item from normal users is good design. It is not security. Anyone can type the address. Every protected page and action must check on the server.
Protect actions, not only pages
A POST handler (delete, update) needs the same checks: logged in, allowed, valid CSRF token. Do not assume that “the form is only shown to admins”.
Pages in one folder
If a whole folder is private (/admin/), include the check in a shared file at the top of every page there, for example admin/bootstrap.php. If you forget one file, that page is open.