Some links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.
Clicking a hyperlink does not automatically change a PHP session. A link normally sends a value such as project_id in the URL. The destination script can read that value and optionally save it in $_SESSION for UI state. That session value is not an authorization mechanism, however. If users can change project_id and see another customer’s documents, the fix is to check project ownership in every database query—and to apply the same check when serving the actual file.
What happens when the link is clicked?
This link creates a new HTTP request:
<a href="project.php?project_id=<?= urlencode((string) $project['project_id']) ?>">
<?= htmlspecialchars($project['project_name'], ENT_QUOTES, 'UTF-8') ?>
</a>
The browser requests project.php?project_id=42. PHP receives the parameter through $_GET; the link itself does not write server-side session data.
Use the authenticated identity from the session, not a client name supplied in the URL. Query parameters are controlled by the user and can be changed before the request reaches your server.
If you genuinely need to remember the selection
Set the session value in the destination script, after starting or resuming the session:
#1 Best Overall
<?php
session_start();
$projectId = filter_input(INPUT_GET, 'project_id', FILTER_VALIDATE_INT);
if ($projectId === false || $projectId === null || $projectId < 1) {
http_response_code(400);
exit('Invalid project ID.');
}
$_SESSION['selected_project_id'] = $projectId;
On a later request:
<?php
session_start();
$projectId = $_SESSION['selected_project_id'] ?? null;
PHP sessions persist per-user state between requests after session_start() initializes the session (PHP documentation). This is suitable for remembering the last project viewed, a multi-step form, a flash message, or a return URL. It is not proof that the user may access that project.
The real vulnerability: trusting the ID
This pattern is unsafe:
SELECT * FROM documents WHERE project_id = :project_id
An authenticated user can replace project_id in the address bar and retrieve another client’s records. This is broken object-level authorization (often called IDOR or BOLA). Authentication proves who the user is; it does not prove that the user owns the requested object. OWASP recommends enforcing authorization on the server for every requested object (OWASP Authorization Cheat Sheet).
Rank #2
Authorize the project in SQL
Store an immutable database user ID in the session at login, preferably after regenerating the session ID:
Free tools Windows power users keep installed
One-click scans. No signup required.
session_start();
// After verifying the password:
session_regenerate_id(true);
$_SESSION['user_id'] = (int) $user['user_id'];
PHP documents caveats around session-ID regeneration, so account for the behavior of your deployed PHP version.
Then filter both the requested project and the logged-in owner:
SELECT
p.project_id,
p.project_name,
d.document_id,
d.document_name,
d.filename
FROM projects AS p
LEFT JOIN documents AS d ON d.project_id = p.project_id
WHERE p.project_id = :project_id
AND p.client_id = :user_id
ORDER BY d.document_name;
If the project belongs to another client, this query returns no rows. Treat that as “not found” or a generic authorization failure; do not first fetch its name with an unrestricted query.
Rank #4
Complete PDO detail-page example
<?php
session_start();
if (!isset($_SESSION['user_id'])) {
http_response_code(401);
exit('Please sign in.');
}
$projectId = filter_input(INPUT_GET, 'project_id', FILTER_VALIDATE_INT);
if ($projectId === false || $projectId === null || $projectId < 1) {
http_response_code(400);
exit('Invalid project.');
}
$userId = (int) $_SESSION['user_id'];
$pdo = new PDO(
'mysql:host=localhost;dbname=app;charset=utf8mb4',
$dbUser,
$dbPassword,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
$stmt = $pdo->prepare(
'SELECT p.project_id, p.project_name,
d.document_id, d.document_name, d.filename
FROM projects AS p
LEFT JOIN documents AS d ON d.project_id = p.project_id
WHERE p.project_id = :project_id
AND p.client_id = :user_id
ORDER BY d.document_name'
);
$stmt->execute([
'project_id' => $projectId,
'user_id' => $userId,
]);
$rows = $stmt->fetchAll();
if (!$rows) {
http_response_code(404);
exit('Project not found.');
}
$projectName = $rows[0]['project_name'];
echo '<h1>' . htmlspecialchars($projectName, ENT_QUOTES, 'UTF-8') . '</h1>';
Use prepared statements for request and session values. PDO and MySQLi are both appropriate when used with parameter binding; the old mysql_* API from the historical PHP 4 era is removed and should not be copied. The original SitePoint discussion used PHP 4.3.11 and MySQL 4.1.14, which are historical context, not a supported modern deployment (original discussion). See PHP’s supported versions for current support information.
Do not pass the client in the URL
A URL such as project.php?project_id=123&client=alice is misleading and unsafe. Ignore client parameters supplied by the browser. Derive the owner from $_SESSION['user_id']. If your legacy schema only has usernames, join against that username as a temporary measure, then migrate to an immutable numeric ID:
SELECT p.project_id, p.project_name
FROM projects AS p
JOIN clients AS c ON c.client_id = p.client_id
WHERE p.project_id = :project_id
AND c.username = :username
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Protect document downloads too
Securing project.php is insufficient if files remain directly reachable at /uploads/report.pdf. Prefer storing private uploads outside the public web root and serving them through an authorized controller:
SELECT d.filename, d.document_name, d.document_type
FROM documents AS d
JOIN projects AS p ON p.project_id = d.project_id
WHERE d.document_id = :document_id
AND p.client_id = :user_id
Run this query before reading the file. Never trust a filename supplied by the URL; obtain it from the authorized database row, verify the resolved path is a file, send the appropriate headers, and then stream it. A protected endpoint is useful even when files cannot be moved outside the web root.
Common mistakes to remove
- Filtering only the project list: users can bypass the list and request the detail URL directly.
- Adding
(int)and stopping: type conversion is not an ownership check. - Using
$_REQUEST: read the expected source explicitly withfilter_input(INPUT_GET, ...)(PHP reference). - Fetching project metadata without ownership filtering: this can disclose another customer’s project name.
- Leaving output unescaped: use
htmlspecialchars(..., ENT_QUOTES, 'UTF-8')in HTML. - Continuing after redirects: call
exitafterheader('Location: ...'). - Suppressing database errors with
@: log details privately and show a generic error.
Behavior to test
- An unauthenticated request is rejected.
- A valid user can open their own project.
- Changing
project_idto another customer’s ID returns no project and reveals no title. - Changing a
clientorclient_idparameter has no effect. - Missing, nonnumeric, or nonpositive IDs return a controlled
400. - A bookmarked project is authorized again on every request.
- Changing
document_idcannot download another user’s file. - Filenames are obtained server-side, and private upload paths are not public URLs.
- Project and document names are HTML-escaped.
- SQL errors are logged privately, not printed to the browser.
If projects can belong to multiple clients, authorize through a linking table such as project_clients(project_id, client_id) rather than assuming a single projects.client_id.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

