Broken Access Control: OWASP #1 for a Reason
TL;DR
Broken Access Control topped OWASP's 2021 list and remains the most critical web security risk. Learn about IDOR, privilege escalation, and the access control patterns that actually work.
In OWASP's 2021 Top 10, Broken Access Control claimed the #1 spot, moving up from fifth place in 2017. This wasn't surprising to security professionals—access control failures are everywhere, they're easy to exploit, and their impact is devastating.
Access control determines who can do what in your application. When it breaks, attackers can view other users' data, modify records they shouldn't touch, or perform admin actions as regular users. Let's explore the common patterns, real-world examples, and how to build access control that actually works.
Understanding Access Control
Access control has three main components:
Authentication: Verifying who the user is ("You are user123")
Authorization: Determining what the user can do ("User123 can view their own profile")
Access Control Enforcement: Actually blocking unauthorized actions ("Request denied: you cannot view user456's profile")
Broken access control occurs when any of these fail. Most commonly, it's the enforcement layer—developers assume that if a button is hidden, users won't access the functionality.
IDOR: The Most Common Access Control Flaw
Insecure Direct Object Reference (IDOR) occurs when an application exposes internal object references (like database IDs) without proper access checks.
// Vulnerable endpoint - no authorization check
app.get('/api/invoices/:id', async (req, res) => {
const invoice = await Invoice.findById(req.params.id);
res.json(invoice); // Anyone can view any invoice!
});
// An attacker simply changes the ID
// GET /api/invoices/12345 -> Their invoice
// GET /api/invoices/12346 -> Someone else's invoice!The fix requires checking that the authenticated user has permission to access the specific resource:
// Secure endpoint - authorization check included
app.get('/api/invoices/:id', async (req, res) => {
const invoice = await Invoice.findById(req.params.id);
if (!invoice) {
return res.status(404).json({ error: 'Not found' });
}
// Check if user owns this invoice
if (invoice.userId !== req.user.id) {
return res.status(403).json({ error: 'Access denied' });
}
res.json(invoice);
});Vertical Privilege Escalation
Vertical escalation occurs when a regular user accesses admin functionality. This often happens when developers rely on UI-based security—hiding admin buttons rather than enforcing permissions server-side.
// Frontend "security" - easily bypassed
{user.role === 'admin' && (
<button onClick={deleteUser}>Delete User</button>
)}
// Backend vulnerability - no role check
app.delete('/api/admin/users/:id', async (req, res) => {
await User.findByIdAndDelete(req.params.id);
res.json({ success: true });
// Any authenticated user can delete any user!
});The fix: Always enforce permissions on the server:
// Proper authorization middleware
const requireAdmin = (req, res, next) => {
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
};
app.delete('/api/admin/users/:id', requireAdmin, async (req, res) => {
await User.findByIdAndDelete(req.params.id);
res.json({ success: true });
});Horizontal Privilege Escalation
Horizontal escalation occurs when a user accesses another user's resources at the same privilege level. This is the classic IDOR scenario—user A accessing user B's data.
Common vulnerable patterns include:
Predictable user IDs in URLs: /users/1001/settings, /users/1002/settings
Email in query parameters: /reset-password?email=victim@example.com
Object IDs in request bodies: {"orderId": "other-users-order"}
Real-World Case Studies
Parler Data Breach (2021): Parler's API used sequential post IDs without authentication. Researchers downloaded 99% of the platform's content, including deleted posts and GPS metadata, by simply incrementing IDs.
US Census Bureau (2020): A vulnerability allowed access to other users' survey responses by manipulating survey IDs in the URL, exposing sensitive demographic data.
Multiple Healthcare Portals: Numerous HIPAA violations have occurred when patient portals allowed access to other patients' medical records through simple ID manipulation.
Patterns That Work
1. Deny by Default
Start with no access and explicitly grant permissions. Never assume that because a user is authenticated, they can access a resource.
// Every endpoint should require explicit permission
const checkPermission = async (userId, resource, action) => {
const permission = await Permission.findOne({
userId,
resource,
action
});
return !!permission; // False if not found
};2. Use UUIDs Instead of Sequential IDs
While not a security control on its own, UUIDs make enumeration attacks impractical. Combined with proper authorization, they add defense in depth.
// Sequential ID: easily guessable
/api/documents/12345
/api/documents/12346
// UUID: impractical to enumerate
/api/documents/7b3e8f2a-5c1d-4e9f-a2b8-1c3d5e7f9a0b3. Implement Ownership Checks at the Data Layer
Include ownership in your database queries rather than checking after retrieval:
// Better: Filter at the database level
const invoice = await Invoice.findOne({
_id: req.params.id,
userId: req.user.id // Only returns if user owns it
});
if (!invoice) {
return res.status(404).json({ error: 'Not found' });
}4. Centralize Authorization Logic
Don't scatter access control checks throughout your codebase. Use a centralized authorization service or policy engine.
// Centralized policy engine
class AuthorizationService {
async canAccess(user, resource, action) {
// All authorization logic in one place
const policies = await this.loadPolicies(user.role);
return policies.evaluate(user, resource, action);
}
}
// Usage in routes
app.get('/api/invoices/:id', async (req, res) => {
const invoice = await Invoice.findById(req.params.id);
if (!await authService.canAccess(req.user, invoice, 'read')) {
return res.status(403).json({ error: 'Access denied' });
}
res.json(invoice);
});Testing for Access Control Flaws
Access control testing requires creating multiple test accounts with different roles and systematically verifying that each can only access what they should.
Testing checklist:
Can user A access user B's resources by changing IDs? Can a regular user access admin endpoints? Can unauthenticated users access protected resources? Can users perform actions after logout? Are deleted resources still accessible?
Automate these tests in your CI/CD pipeline. Every new endpoint should have access control tests.
Common Anti-Patterns to Avoid
Security through obscurity: "Nobody will guess this URL" is not access control.
Client-side only checks: Hiding UI elements doesn't prevent API access.
Role in JWT claims only: Always verify roles server-side; JWTs can be tampered with if using weak algorithms.
Trusting referer headers: These are easily spoofed by attackers.
Conclusion
Broken access control is #1 on the OWASP Top 10 because it's pervasive and impactful. Every feature that handles user data or actions needs explicit access control checks.
The good news is that access control done right isn't complicated—it just requires discipline. Check every request. Verify every resource access. Never trust the client.
Practice finding and exploiting access control flaws with AliceSec's IDOR and privilege escalation challenges.
Get the weekly vulnerability breakdown
New challenges, exploit techniques, and security tips. No spam.
Unsubscribe anytime. No spam, ever.