Educational Content: This article is written for educational purposes to help developers and cybersecurity students understand software concepts. Always follow ethical guidelines and applicable laws.
Let me tell you about a conversation I had with a developer whose application had just been reported to him by a security researcher. The researcher had found that by changing a single number in a URL — going from /invoice/download?id=1042 to /invoice/download?id=1043 — they could download any invoice from any customer in the database. Every invoice. Every customer. Thousands of financial documents, sitting there accessible to anyone who could count.
The developer's response when I told him what had happened was something I've heard more times than I can count: "But they have to be logged in to even reach that page." He had confused authentication with authorization. Being logged in proved who you were. It said nothing about what you were allowed to access.
That distinction — the gap between authentication and authorization — is at the heart of the number one web application vulnerability in the world right now. It has been number one on the OWASP Top 10 list for consecutive editions. According to OWASP's own research, 100% of tested applications have some form of it. Not most. Not the majority. Every single one.
This post is a thorough, practical guide to understanding Broken Access Control in all its forms — because it shows up in more ways than most developers realize — and fixing every variation of it in PHP code.
Authentication Versus Authorization: The Distinction That Changes Everything
Before getting into specific attack patterns, it's worth spending a moment on this distinction because it's the conceptual foundation of everything that follows.
Authentication answers: "Who are you?" It's the login process. Passwords, tokens, OAuth flows, biometrics — all of these are mechanisms for establishing identity. When a user successfully authenticates, your application knows which user it's dealing with.
Authorization answers: "What are you allowed to do?" It's the set of rules that determines which resources and actions are available to a given user. Authorization happens after authentication — you first establish who someone is, then determine what they're permitted to access.
Broken Access Control happens when authorization is missing, incomplete, or incorrectly implemented. The user is correctly authenticated — your application knows exactly who they are — but then fails to enforce the rules about what that specific user is allowed to see and do.
A helpful mental model: authentication is the lock on the front door of a building. Authorization is the access card system that determines which rooms you're allowed to enter once you're inside. Most applications have a decent front door lock. Many of them have no card system at all — once you're inside, you can walk into any room.
The Six Most Common Forms of Broken Access Control
Broken Access Control isn't a single vulnerability pattern — it's a family of related failures. Understanding each one separately helps you identify them in your own code.
1. Insecure Direct Object Reference (IDOR)
This is the one from the opening story. IDOR happens when an application uses a user-controlled identifier — an ID in a URL parameter, a POST field, a cookie value — to directly retrieve a database record without verifying that the requesting user owns or has permission to access that record.
It shows up constantly in applications that were built to work correctly before being built to work securely. The developer writes code that retrieves a record by ID and it works perfectly in testing, because they're always testing with their own records. The authorization check that verifies ownership simply never gets written.
<?php
// Vulnerable — retrieves any invoice regardless of ownership
$invoice_id = $_GET['id'];
$stmt = $db->prepare("SELECT * FROM invoices WHERE id = ?");
$stmt->execute([$invoice_id]);
$invoice = $stmt->fetch();
if (!$invoice) {
http_response_code(404);
exit;
}
// Generates and returns the invoice PDF
generateInvoicePDF($invoice);
If the user in session has invoice ID 1042, they can change the URL to request ID 1043 and get someone else's invoice. The query returns it happily because the query doesn't know or care about session state.
<?php
// Correct — ownership verified before returning data
$invoice_id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if (!$invoice_id) {
http_response_code(400);
exit('Invalid request');
}
// The AND user_id = ? clause is the critical addition
$stmt = $db->prepare(
"SELECT * FROM invoices WHERE id = ? AND user_id = ?"
);
$stmt->execute([$invoice_id, $_SESSION['user_id']]);
$invoice = $stmt->fetch();
if (!$invoice) {
// Return 404 — don't reveal whether the invoice exists but belongs to someone else
http_response_code(404);
exit('Not found');
}
generateInvoicePDF($invoice);
The fix is the AND user_id = ? clause. The database query itself enforces ownership — if the invoice doesn't belong to the requesting user, the query returns nothing, and the user gets a 404. They can't tell whether the invoice exists or just isn't theirs, which is the correct behavior.
One important note on the 404 response: always return the same error for "not found" and "not yours." Returning a different error code — 403 Forbidden when the record exists but isn't accessible — leaks information about the existence of records that shouldn't be visible at all.
2. Missing Function Level Access Control
This pattern appears when an application has pages or endpoints that are only supposed to be accessible to certain user roles — admins, moderators, premium subscribers — but the access check is either missing entirely or only enforced on the frontend.
A common version of this: the navigation menu hides the admin link for non-admin users. But the admin page itself has no access check. Any user who knows the URL — or who discovers it by guessing, reading JavaScript, or using a directory scanner — can reach it directly.
<?php
// admin/users.php — no access check at all
// Any logged-in user can reach this by navigating directly to the URL
$stmt = $db->query("SELECT id, email, role, created_at FROM users");
$users = $stmt->fetchAll();
// Displays all user accounts
foreach ($users as $user) {
echo $user['email'] . ' — ' . $user['role'] . '<br>';
}
<?php
// admin/users.php — correct version with role check
session_start();
// Check authentication first
if (empty($_SESSION['user_id'])) {
header('Location: /login');
exit;
}
// Then check authorization — is this user an admin?
if ($_SESSION['role'] !== 'admin') {
http_response_code(403);
// Log this — a non-admin trying to access admin pages is worth knowing about
error_log('[ACCESS VIOLATION] User ' . $_SESSION['user_id'] .
' attempted to access admin/users at ' . date('Y-m-d H:i:s'));
exit('Access denied');
}
// Only reaches here if authenticated AND admin
$stmt = $db->query("SELECT id, email, role, created_at FROM users");
$users = $stmt->fetchAll();
The logging line is worth highlighting. When a non-admin user hits an admin endpoint directly, that's unusual. It might be an accidental navigation, or it might be someone actively probing your application. Either way, you want to know about it. An access control violation that gets silently rejected without logging is a missed signal.
For applications with multiple roles and many protected endpoints, repeating this check manually on every page becomes error-prone. A cleaner approach is to centralize it in a middleware function that runs before every request and checks the required permissions for the current route.
<?php
// middleware/auth.php
function requireRole($required_role) {
session_start();
if (empty($_SESSION['user_id'])) {
header('Location: /login?redirect=' . urlencode($_SERVER['REQUEST_URI']));
exit;
}
$role_hierarchy = ['user' => 1, 'moderator' => 2, 'admin' => 3];
$user_level = $role_hierarchy[$_SESSION['role']] ?? 0;
$required_level = $role_hierarchy[$required_role] ?? 99;
if ($user_level < $required_level) {
error_log('[ACCESS VIOLATION] User ' . $_SESSION['user_id'] .
' (role: ' . $_SESSION['role'] . ') tried to access ' .
$_SERVER['REQUEST_URI']);
http_response_code(403);
exit('You do not have permission to access this page.');
}
}
// Usage — one line at the top of any protected page
requireRole('admin'); // Only admins
requireRole('moderator'); // Moderators and admins
requireRole('user'); // Any authenticated user
3. Privilege Escalation
Privilege escalation happens when a user can grant themselves higher permissions than they're supposed to have. Vertical privilege escalation means a regular user gains admin access. Horizontal privilege escalation means a user accesses another user's resources at the same permission level (this overlaps with IDOR).
A classic vertical escalation pattern in PHP looks like this:
<?php
// Vulnerable user update endpoint
$user_id = $_SESSION['user_id'];
$data = $_POST; // Everything the user submitted
// Builds the UPDATE from whatever fields were submitted
$fields = [];
$values = [];
foreach ($data as $key => $value) {
$fields[] = "$key = ?";
$values[] = $value;
}
$values[] = $user_id;
$stmt = $db->prepare(
"UPDATE users SET " . implode(', ', $fields) . " WHERE id = ?"
);
$stmt->execute($values);
This is called a mass assignment vulnerability. The developer intended for users to update their display name or email. But this code passes every submitted field directly to the UPDATE statement — including fields the developer never intended to be user-editable, like role, is_admin, or account_balance.
An attacker submits: POST /account/update with body name=John&role=admin. The query becomes UPDATE users SET name = 'John', role = 'admin' WHERE id = ?. They just made themselves an admin.
<?php
// Correct — explicit whitelist of allowed fields
$allowed_fields = ['display_name', 'email', 'bio', 'avatar_url'];
$fields = [];
$values = [];
foreach ($allowed_fields as $field) {
if (isset($_POST[$field])) {
$fields[] = "$field = ?";
$values[] = $_POST[$field];
}
}
if (empty($fields)) {
http_response_code(400);
exit('No valid fields provided');
}
$values[] = $_SESSION['user_id'];
$stmt = $db->prepare(
"UPDATE users SET " . implode(', ', $fields) . " WHERE id = ?"
);
$stmt->execute($values);
The whitelist means that even if an attacker submits a role field in their request, it simply gets ignored — it's not in the allowed fields array so it never reaches the query.
4. Forceful Browsing
Forceful browsing (sometimes called forced browsing) is when a user accesses a URL they shouldn't have access to by navigating there directly, without following any application flow that would normally gate access.
Examples: accessing /admin without being an admin. Accessing /checkout/confirmation without completing a payment. Accessing /account/premium-content without a premium subscription. Accessing /reports/annual-2026.pdf by guessing the filename.
The last one is particularly common with file downloads. Applications that store sensitive files in a web-accessible directory with predictable naming are vulnerable to anyone who guesses or enumerates the filenames.
<?php
// Vulnerable — files are in web root with predictable names
// /public_html/reports/invoice_1042.pdf is directly accessible via URL
// Correct approach — files outside web root, served through PHP
function serveProtectedFile($filename, $user_id, $db) {
// Verify the user has access to this file
$stmt = $db->prepare(
"SELECT file_path FROM user_documents
WHERE filename = ? AND user_id = ?"
);
$stmt->execute([$filename, $user_id]);
$doc = $stmt->fetch();
if (!$doc) {
http_response_code(404);
exit('File not found');
}
$full_path = '/var/private_files/' . $doc['file_path'];
if (!file_exists($full_path)) {
http_response_code(404);
exit('File not found');
}
// Serve the file through PHP — the actual path is never exposed
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="' .
basename($doc['file_path']) . '"');
readfile($full_path);
exit;
}
The critical detail is the file storage location: /var/private_files/ is outside the web root, completely inaccessible via URL. PHP acts as the gatekeeper — it checks authorization first, then serves the file if permitted. The URL the user accesses is something like /download?file=invoice_1042.pdf, and that PHP script does the authorization check before touching the file.
5. Insecure API Endpoints
Modern applications have two types of pages: the HTML pages users see and the API endpoints those pages call in the background. A common and serious mistake is properly securing the HTML pages but forgetting to apply the same authorization checks to the API endpoints.
This happens because the developer thinks: "users can only reach the API through the frontend, and the frontend is secured." That thinking is wrong. API endpoints are accessible directly with any HTTP client — curl, Postman, a custom script. Any authorization that matters must be enforced at the API endpoint itself, not assumed from the frontend.
<?php
// api/get-user-data.php
// Developer assumed only the dashboard page calls this endpoint
header('Content-Type: application/json');
// No session check. No authorization check.
$user_id = $_GET['user_id'];
$stmt = $db->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$user_id]);
echo json_encode($stmt->fetch());
<?php
// api/get-user-data.php — correct version
header('Content-Type: application/json');
session_start();
// Verify authenticated
if (empty($_SESSION['user_id'])) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
$requested_id = filter_input(INPUT_GET, 'user_id', FILTER_VALIDATE_INT);
// Users can only access their own data
// Admins can access any user's data
if ($_SESSION['role'] !== 'admin' && $requested_id !== $_SESSION['user_id']) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden']);
exit;
}
$stmt = $db->prepare(
"SELECT id, email, display_name, created_at FROM users WHERE id = ?"
);
$stmt->execute([$requested_id]);
$user = $stmt->fetch();
if (!$user) {
http_response_code(404);
echo json_encode(['error' => 'User not found']);
exit;
}
echo json_encode($user);
6. JWT and Token Claim Manipulation
Applications that use JSON Web Tokens for authorization sometimes make the mistake of trusting claims embedded in the token without proper validation. If a token contains a role or user_id claim and the server doesn't properly verify the token's signature, a user can modify those claims and change their own authorization level.
<?php
// Vulnerable — decodes token without signature verification
$token = $_COOKIE['auth_token'];
$parts = explode('.', $token);
$payload = json_decode(base64_decode($parts[1]), true);
// Trusts role from token without verification
if ($payload['role'] === 'admin') {
// Show admin panel
}
// Correct — always verify signature before trusting any claim
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
try {
$decoded = JWT::decode(
$token,
new Key($_ENV['JWT_SECRET'], 'HS256')
);
// $decoded->role and $decoded->user_id are now verified
if ($decoded->role === 'admin') {
// Safe to use
}
} catch (Exception $e) {
http_response_code(401);
exit('Invalid token');
}
Building a Systematic Defense: The Access Control Checklist
The challenge with Broken Access Control is that it's not a single pattern you can scan for — it requires making deliberate authorization decisions for every endpoint and every resource in your application. Here's a systematic approach.
List every endpoint in your application. Every PHP file that processes a request, every API route. For each one, answer two questions: who is allowed to reach this endpoint, and which records are they allowed to access through it?
Default to deny. An endpoint should reject requests unless it can positively confirm authorization — not accept requests unless it can positively confirm they're unauthorized. The default state is "no access" and the code explicitly grants access when appropriate.
<?php
// Wrong default — assumes access unless a reason to deny is found
function handleRequest($user, $resource_id) {
$authorized = true; // start as authorized
if ($user->is_banned) $authorized = false;
if ($resource->is_deleted) $authorized = false;
if ($authorized) {
return $resource;
}
}
// Right default — denies access unless a reason to allow is found
function handleRequest($user, $resource_id, $db) {
// Query enforces ownership — returns nothing if not authorized
$stmt = $db->prepare(
"SELECT * FROM resources
WHERE id = ? AND owner_id = ? AND deleted_at IS NULL"
);
$stmt->execute([$resource_id, $user->id]);
$resource = $stmt->fetch();
if (!$resource) {
return null; // Access denied — no further checks needed
}
return $resource; // Explicitly authorized by the query result
}
Enforce authorization at the data layer, not just the view layer. The most reliable access control is a database query that simply cannot return data the user isn't authorized to see — because the ownership condition is baked into the query itself. A check at the display layer can be forgotten or bypassed. A condition in the SQL WHERE clause cannot.
Verify ownership on every write operation too. Access control failures on write operations — updating, deleting, modifying — are often more dangerous than read failures. Verify that the record being modified belongs to the requesting user just as carefully as you would for a read.
<?php
// Dangerous — updates a post by ID with no ownership check
$stmt = $db->prepare(
"UPDATE posts SET content = ? WHERE id = ?"
);
$stmt->execute([$_POST['content'], $_POST['post_id']]);
// Correct — ownership enforced in the UPDATE query itself
$stmt = $db->prepare(
"UPDATE posts SET content = ? WHERE id = ? AND author_id = ?"
);
$stmt->execute([
$_POST['content'],
$_POST['post_id'],
$_SESSION['user_id']
]);
// Check affected rows — if 0, either post doesn't exist or doesn't belong to this user
if ($stmt->rowCount() === 0) {
http_response_code(404);
echo json_encode(['error' => 'Post not found']);
exit;
}
Test as a different user. The most reliable way to find IDOR and access control failures is to create two test accounts and log in as account A, create a resource, note its ID, then log in as account B and try to access, modify, and delete that resource. If account B can do any of those things, you have an access control failure.
Logging Access Control Events
Good access control isn't just about blocking unauthorized requests — it's about knowing when they're happening. An application that silently rejects unauthorized attempts without logging them is missing critical security intelligence.
<?php
function logAccessViolation($user_id, $attempted_resource, $reason) {
$log_entry = sprintf(
"[%s] ACCESS VIOLATION - User: %d | Resource: %s | Reason: %s | IP: %s | UA: %s\n",
date('Y-m-d H:i:s'),
$user_id,
$attempted_resource,
$reason,
$_SERVER['REMOTE_ADDR'],
$_SERVER['HTTP_USER_AGENT'] ?? 'unknown'
);
error_log($log_entry, 3, '/var/log/access_violations.log');
// Optional: if a user triggers many violations rapidly,
// consider flagging or rate limiting them
}
// Usage
if (!$authorized) {
logAccessViolation(
$_SESSION['user_id'],
'invoice:' . $invoice_id,
'not_owner'
);
http_response_code(404);
exit;
}
A user who triggers ten access control violations in five minutes is probably probing your application systematically. Without logging, you'd never know. With logging, you can detect patterns, investigate incidents, and act before damage escalates.
Why This Stays at Number One
It's worth asking: if this vulnerability is so well documented and has been the top risk for years, why does it keep showing up in 100% of tested applications?
Part of the answer is that access control failures are much harder to detect automatically than other vulnerability types. SQL injection and XSS have recognizable patterns that scanners can detect. An IDOR vulnerability looks identical to a legitimate request — the difference is in the business logic, which only the developer (and the attacker) understands.
Part of it is the way applications are built. Features get added incrementally, by different developers, at different times. Authorization logic that was carefully implemented for the first version of a feature gets partially replicated, slightly wrong, in the fifth version. New endpoints get added under deadline pressure and the security review that would catch the missing ownership check doesn't happen.
And part of it is simply the authentication/authorization confusion I started this post with. Developers who understand authentication — who have thought carefully about login flows and session management — sometimes haven't made the mental shift to thinking about what happens after authentication. Being logged in is the starting point of the security question, not the end of it.
A Final Word
Access control is where security becomes personal. A SQL injection vulnerability might leak your whole database. A Broken Access Control vulnerability lets one of your users see another user's private data — their invoices, their medical records, their messages, their financial history. The damage is both real and intimate, and the people most affected aren't the developers who made the mistake. They're the users who trusted you with their information.
The good news is that fixing these issues is genuinely not complicated — it's a question of being systematic. Deny by default. Enforce ownership in your queries. Verify authorization on every endpoint, not just the ones that feel sensitive. Log violations. Test as another user.
Build those habits and Broken Access Control stops being the thing that happens to your application. It becomes the thing your application is specifically built to prevent.
— Skand K.