I’ve been hunting access control bugs for over a decade, and let me tell you - they’re everywhere. When OWASP moved broken access control to #1 in 2025 and merged SSRF into this category, I wasn’t surprised. I was relieved that the security community finally caught up to what I’ve been seeing in the wild.
94% of applications tested have broken access control issues. That’s not a typo - it’s a security apocalypse hiding in plain sight.
Quick Answer: What is Broken Access Control?
Broken Access Control occurs when applications fail to properly restrict what authenticated users can access, resulting in:
- Unauthorized data access - viewing other users’ sensitive information
- Privilege escalation - regular users gaining admin capabilities
- Direct object manipulation - changing IDs to access forbidden resources
- Server-side request forgery - making unauthorized requests from the server
- Function-level bypasses - accessing admin functions without proper authorization
Impact: Complete data breaches, account takeovers, and system compromise affecting millions of users.
Why Broken Access Control Became #1
When I first started doing security reviews, access control bugs were treated as “medium severity” issues. Fast forward to 2026, and they’re the #1 threat. Here’s why:
The Perfect Storm
- Modern architectures with microservices make consistent authorization hard
- API-first development exposes more endpoints with inconsistent protection
- Frontend assumptions that security happens in the browser (spoiler: it doesn’t)
- DevOps pressure leading to “we’ll add authorization later” (spoiler: they never do)
The shift to distributed systems means developers are building 20+ services that all need to implement authorization correctly. They don’t.
How SSRF Fits Into Access Control (2025 Update)
OWASP 2025 consolidated Server-Side Request Forgery (SSRF) into A01 because that’s how attacks actually work. I rarely see isolated SSRF - it’s always part of an access control bypass chain.
Real Attack Chain I’ve Seen:
- SSRF - Attacker tricks app into making internal requests
- Network Bypass - Accesses internal services via SSRF
- Privilege Escalation - Uses internal endpoints to elevate permissions
- Data Exfiltration - Downloads sensitive data using newfound access
SSRF isn’t just about reading files - it’s about circumventing the entire access control model by attacking from a “trusted” internal position.
# Vulnerable: User can specify any URL
@app.route('/fetch-image')
def fetch_image():
url = request.args.get('url')
# This is broken access control via SSRF
response = requests.get(url)
return response.content
# Attack: http://localhost:8080/admin/users
# Result: Attacker accesses admin endpoint via SSRF
The Access Control Disaster Categories
1. Insecure Direct Object References (IDOR)
This is the bread and butter of access control bugs. User changes an ID parameter and suddenly they’re looking at someone else’s data.
What I See Constantly:
// Broken: No authorization check
app.get('/api/documents/:id', (req, res) => {
const doc = database.getDocument(req.params.id);
res.json(doc); // Any authenticated user can access any document
});
// Secure: Proper authorization
app.get('/api/documents/:id', (req, res) => {
const doc = database.getDocument(req.params.id);
if (!doc) {
return res.status(404).json({error: 'Not found'});
}
// Check if user owns this document or has permission
if (doc.owner_id !== req.user.id && !req.user.hasRole('admin')) {
return res.status(403).json({error: 'Access denied'});
}
res.json(doc);
});
Real Example from My Reviews:
Found an app where changing /api/invoices/123 to /api/invoices/124 gave access to other companies’ financial data. The developers assumed the frontend would “only show your own invoices.” The API had zero authorization checks.
2. Privilege Escalation
Regular users gaining admin access by manipulating requests or exploiting flawed role checks.
Horizontal vs Vertical Escalation:
# Horizontal: Access other users' data at same privilege level
# URL: /user/profile/456 (should only access your own profile)
# Vertical: Gain higher privileges
# Manipulating role parameter in requests
# POST /api/users/update
# {"user_id": 123, "role": "admin"} # Should not be allowed
Framework-Specific Issues I’ve Found:
# Django - Broken
def user_profile(request, user_id):
user = User.objects.get(id=user_id) # No permission check
return render(request, 'profile.html', {'user': user})
# Django - Secure
def user_profile(request, user_id):
user = get_object_or_404(User, id=user_id)
# Check ownership or admin permission
if user != request.user and not request.user.is_staff:
raise PermissionDenied
return render(request, 'profile.html', {'user': user})
3. Missing Function-Level Access Control
Admin functions that are “hidden” in the frontend but completely unprotected on the backend.
Classic Example:
// Frontend shows admin menu only to admins
if (user.role === 'admin') {
showAdminMenu();
}
// But the API endpoints have no protection
app.delete('/api/users/:id', (req, res) => {
// No role check - any authenticated user can delete anyone
User.deleteOne({_id: req.params.id});
res.status(200).send('User deleted');
});
What Attackers Do:
- Inspect browser dev tools to find hidden API endpoints
- Use tools like Burp Suite to enumerate admin functions
- Guess common admin endpoint patterns (
/admin/,/api/admin/,/manage/)
4. Access Control Via SSRF
The reason OWASP consolidated these categories - SSRF is often just an access control bypass with extra steps.
Attack Patterns I’ve Documented:
# Vulnerable webhook handler
@app.route('/webhook', methods=['POST'])
def handle_webhook():
url = request.json.get('callback_url')
data = {'event': 'processed'}
# Attacker sets callback_url to internal endpoint
# http://localhost:8080/admin/delete-user?id=victim
requests.post(url, json=data)
Cloud Metadata Attacks:
# Attacker provides this URL to SSRF-vulnerable endpoint
http://169.254.169.254/latest/meta-data/iam/security-credentials/
# App makes request from trusted internal position
# Result: Attacker gets AWS credentials with elevated permissions
Real-World Attack Scenarios
Scenario 1: E-commerce Account Takeover
Application: Online store with user accounts and order history
Vulnerability: IDOR in order API endpoint
Attack:
- User logs in and views their orders:
/api/orders?user_id=1234 - Attacker changes parameter:
/api/orders?user_id=5678 - Gets other user’s order history including:
- Shipping addresses
- Payment methods (partial)
- Order patterns and preferences
Impact: Privacy violation, potential identity theft, competitive intelligence
Code Fix:
# Broken
@app.route('/api/orders')
def get_orders():
user_id = request.args.get('user_id')
orders = database.query(f"SELECT * FROM orders WHERE user_id = {user_id}")
return jsonify(orders)
# Fixed
@app.route('/api/orders')
@login_required
def get_orders():
# Only return current user's orders
orders = database.query(
"SELECT * FROM orders WHERE user_id = %s",
(current_user.id,)
)
return jsonify(orders)
Scenario 2: Healthcare Data Breach
Application: Patient portal for medical records
Vulnerability: Missing access control on file download endpoint
Attack:
- Patient downloads their lab result:
/download/lab-result/patient_123_lab_456.pdf - Attacker enumerates:
/download/lab-result/patient_124_lab_457.pdf - Downloads thousands of medical records by incrementing IDs
Impact: HIPAA violation, massive privacy breach, potential blackmail
Prevention:
// Secure file download with proper authorization
@GetMapping("/download/lab-result/{fileName}")
public ResponseEntity<Resource> downloadFile(
@PathVariable String fileName,
Authentication auth) {
// Extract patient ID from filename
String patientId = extractPatientIdFromFilename(fileName);
// Check if current user can access this patient's data
if (!authService.canAccessPatientData(auth.getName(), patientId)) {
throw new AccessDeniedException("Cannot access patient data");
}
// Return file only after authorization check
Resource file = fileService.loadFile(fileName);
return ResponseEntity.ok(file);
}
Scenario 3: Corporate Espionage via SSRF
Application: Document management system with URL preview feature
Vulnerability: SSRF in document preview function
Attack:
- App provides URL preview:
/preview?url=https://example.com/doc.pdf - Attacker submits:
/preview?url=http://internal-wiki.company.com/secrets - App fetches internal documentation from trusted position
- Attacker receives sensitive company information
Impact: Corporate secrets exposed, competitive disadvantage, potential SEC violations
Mitigation:
import ipaddress
from urllib.parse import urlparse
def is_safe_url(url):
"""Validate URL to prevent SSRF"""
try:
parsed = urlparse(url)
# Only allow HTTPS
if parsed.scheme != 'https':
return False
# Block private IP ranges
try:
ip = ipaddress.ip_address(parsed.hostname)
if ip.is_private or ip.is_loopback:
return False
except ValueError:
pass # Hostname, not IP
# Allowlist domains only
allowed_domains = ['trusted-partner.com', 'public-docs.example.com']
if parsed.hostname not in allowed_domains:
return False
return True
except Exception:
return False
@app.route('/preview')
def preview_url():
url = request.args.get('url')
if not is_safe_url(url):
return jsonify({'error': 'Invalid URL'}), 400
response = requests.get(url, timeout=5)
return response.content
Framework-Specific Prevention
Django Security Patterns
# Use Django's built-in permission system
from django.contrib.auth.decorators import login_required, permission_required
from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404
@login_required
@permission_required('myapp.can_view_sensitive_data')
def sensitive_view(request, object_id):
obj = get_object_or_404(MyModel, pk=object_id)
# Additional ownership check
if obj.owner != request.user and not request.user.is_staff:
raise PermissionDenied
return render(request, 'template.html', {'object': obj})
# Model-level security
class Document(models.Model):
title = models.CharField(max_length=100)
content = models.TextField()
owner = models.ForeignKey(User, on_delete=models.CASCADE)
def user_can_access(self, user):
return self.owner == user or user.has_perm('myapp.access_all_documents')
Flask Authorization Patterns
from functools import wraps
from flask import abort, g
def requires_ownership(f):
"""Decorator to check resource ownership"""
@wraps(f)
def decorated_function(*args, **kwargs):
resource_id = kwargs.get('id')
resource = Resource.query.get_or_404(resource_id)
if resource.owner_id != g.current_user.id:
abort(403)
return f(*args, **kwargs)
return decorated_function
@app.route('/documents/<int:id>')
@login_required
@requires_ownership
def view_document(id):
document = Document.query.get_or_404(id)
return render_template('document.html', document=document)
Express.js Security Middleware
const authMiddleware = {
// Ensure user owns resource
requireOwnership: (resourceType) => {
return async (req, res, next) => {
const resourceId = req.params.id;
const resource = await database[resourceType].findById(resourceId);
if (!resource) {
return res.status(404).json({error: 'Resource not found'});
}
if (resource.userId !== req.user.id && !req.user.isAdmin) {
return res.status(403).json({error: 'Access denied'});
}
req.resource = resource;
next();
};
},
// Check specific permission
requirePermission: (permission) => {
return (req, res, next) => {
if (!req.user.permissions.includes(permission)) {
return res.status(403).json({error: 'Insufficient permissions'});
}
next();
};
}
};
// Usage
app.get('/documents/:id',
authenticateUser,
authMiddleware.requireOwnership('documents'),
(req, res) => {
res.json(req.resource); // Pre-authorized in middleware
}
);
Spring Boot Security Configuration
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/user/**").hasRole("USER")
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.build();
}
}
// Method-level security
@RestController
public class DocumentController {
@GetMapping("/api/documents/{id}")
@PreAuthorize("@documentService.canUserAccess(#id, authentication.name)")
public Document getDocument(@PathVariable Long id) {
return documentService.findById(id);
}
}
@Service
public class DocumentService {
public boolean canUserAccess(Long documentId, String username) {
Document doc = documentRepository.findById(documentId);
User user = userRepository.findByUsername(username);
return doc.getOwner().equals(user) || user.hasRole("ADMIN");
}
}
Advanced Attack Techniques
JWT Token Manipulation
// Vulnerable JWT handling
app.get('/admin/users', (req, res) => {
const token = req.headers.authorization.split(' ')[1];
const decoded = jwt.decode(token); // No verification!
if (decoded.role === 'admin') {
// Attacker can craft their own JWT with admin role
return res.json(getAllUsers());
}
res.status(403).send('Admin only');
});
// Secure JWT handling
app.get('/admin/users', (req, res) => {
const token = req.headers.authorization.split(' ')[1];
try {
const decoded = jwt.verify(token, JWT_SECRET); // Proper verification
// Additional role check from database
const user = database.getUserById(decoded.userId);
if (!user || user.role !== 'admin') {
return res.status(403).send('Admin access required');
}
res.json(getAllUsers());
} catch (error) {
res.status(401).send('Invalid token');
}
});
GraphQL Authorization Bypass
// Vulnerable GraphQL resolver
const resolvers = {
Query: {
user: (parent, { id }) => {
// No authorization check
return database.getUserById(id);
}
}
};
// Secure GraphQL with context-based authorization
const resolvers = {
Query: {
user: (parent, { id }, context) => {
// Check if user can access this profile
if (id !== context.currentUser.id && !context.currentUser.isAdmin) {
throw new ForbiddenError('Cannot access other user profiles');
}
return database.getUserById(id);
}
}
};
API Version Bypass
// Newer API version with security
app.get('/api/v2/documents/:id', authorize, (req, res) => {
// Proper authorization implemented
});
// Older API version without security (forgotten)
app.get('/api/v1/documents/:id', (req, res) => {
// No authorization - attackers use old version
const doc = database.getDocument(req.params.id);
res.json(doc);
});
Testing for Access Control Vulnerabilities
Automated Testing Tools
Burp Suite Extensions:
- Autorize - Automatically tests authorization for all requests
- AuthMatrix - Matrix-based authorization testing
- AutoRepeater - Repeats requests with different user contexts
OWASP ZAP Scripts:
# ZAP script to test IDOR
def scan(ps, msg, src):
uri = msg.getRequestHeader().getURI().toString()
# Look for ID parameters
if '?id=' in uri or '/id/' in uri:
# Extract ID
original_id = extract_id_from_uri(uri)
# Try different IDs
for test_id in range(1, 1000):
if test_id != original_id:
test_uri = uri.replace(str(original_id), str(test_id))
# Send request with different ID
test_msg = msg.cloneRequest()
test_msg.getRequestHeader().setURI(test_uri)
response = sender.sendAndReceive(test_msg)
# Check if unauthorized access occurred
if response.getResponseHeader().getStatusCode() == 200:
ps.raiseAlert(
risk=3,
confidence=2,
name="Possible IDOR Vulnerability",
description=f"Access granted to {test_uri}"
)
Manual Testing Checklist
Identity and Authentication:
- Change user ID parameters in URLs and requests
- Try accessing other users’ resources by guessing IDs
- Test with sequential, predictable IDs (1, 2, 3…)
- Test with GUIDs/UUIDs if used
Session Management:
- Try using another user’s session token
- Test session fixation attacks
- Check for session sharing between users
Direct Object References:
- Change file names in download URLs
- Modify document/record IDs in API calls
- Test both numeric and string-based identifiers
Function Level Access:
- Access admin functions with regular user account
- Try hidden admin URLs (common patterns)
- Test API endpoints that might lack authorization
SSRF Testing:
- Submit internal URLs to any URL input fields
- Test localhost, 127.0.0.1, and internal IP ranges
- Try cloud metadata endpoints (169.254.169.254)
- Test file:// protocol for local file access
API Testing:
- Test all API versions for authorization differences
- Check HTTP methods (GET/POST/PUT/DELETE) for consistency
- Test bulk operations for authorization bypasses
Building Robust Access Control
Defense in Depth Strategy
class AccessControlFramework:
def __init__(self):
self.policies = PolicyEngine()
self.audit = AuditLogger()
def check_access(self, user, resource, action):
"""Multi-layer access control check"""
# Layer 1: Authentication
if not user.is_authenticated():
self.audit.log_access_denied(user, resource, "Not authenticated")
return False
# Layer 2: Resource ownership
if hasattr(resource, 'owner') and resource.owner != user:
# Check if user has explicit permission
if not self.has_explicit_permission(user, resource, action):
self.audit.log_access_denied(user, resource, "Not owner")
return False
# Layer 3: Role-based permissions
required_role = self.get_required_role(resource, action)
if not user.has_role(required_role):
self.audit.log_access_denied(user, resource, f"Missing role: {required_role}")
return False
# Layer 4: Attribute-based policies
if not self.policies.evaluate(user, resource, action):
self.audit.log_access_denied(user, resource, "Policy violation")
return False
# Layer 5: Rate limiting
if self.is_rate_limited(user, action):
self.audit.log_access_denied(user, resource, "Rate limited")
return False
self.audit.log_access_granted(user, resource, action)
return True
Principle of Least Privilege
// Bad: Give broad permissions
user.permissions = ['read_all', 'write_all', 'delete_all'];
// Good: Specific, granular permissions
user.permissions = [
'read_own_documents',
'write_own_documents',
'read_shared_documents'
];
// Permission checking
function canUserAccess(user, resource, action) {
const requiredPermission = `${action}_${resource.type}`;
// Check specific permission
if (user.permissions.includes(requiredPermission)) {
return true;
}
// Check ownership-based permission
if (resource.owner === user.id &&
user.permissions.includes(`${action}_own_${resource.type}`)) {
return true;
}
return false;
}
Secure by Default Architecture
# Django model with built-in access control
class SecureModel(models.Model):
"""Base model with automatic access control"""
owner = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
visibility = models.CharField(
max_length=20,
choices=[('private', 'Private'), ('shared', 'Shared'), ('public', 'Public')],
default='private'
)
class Meta:
abstract = True
def user_can_read(self, user):
if self.owner == user:
return True
if self.visibility == 'public':
return True
if self.visibility == 'shared' and user.is_authenticated:
return True
return user.has_perm('app.read_all_objects')
def user_can_write(self, user):
if self.owner == user:
return True
return user.has_perm('app.write_all_objects')
# Custom manager that filters by permissions
class SecureManager(models.Manager):
def for_user(self, user, action='read'):
"""Return only objects user can access"""
if user.has_perm(f'app.{action}_all_objects'):
return self.get_queryset()
if action == 'read':
return self.get_queryset().filter(
Q(owner=user) |
Q(visibility='public') |
Q(visibility='shared', owner__isnull=False)
)
else:
return self.get_queryset().filter(owner=user)
class Document(SecureModel):
title = models.CharField(max_length=200)
content = models.TextField()
objects = SecureManager()
def __str__(self):
return self.title
Monitoring and Detection
Access Control Logging
import logging
from datetime import datetime
from functools import wraps
# Set up access control logger
access_logger = logging.getLogger('access_control')
access_logger.setLevel(logging.INFO)
handler = logging.FileHandler('access_control.log')
formatter = logging.Formatter(
'%(asctime)s - %(levelname)s - %(message)s'
)
handler.setFormatter(formatter)
access_logger.addHandler(handler)
def log_access_attempt(f):
"""Decorator to log all access attempts"""
@wraps(f)
def decorated_function(*args, **kwargs):
request = args[0] if args else None
access_info = {
'timestamp': datetime.utcnow().isoformat(),
'user_id': getattr(request, 'user_id', 'anonymous'),
'ip_address': getattr(request, 'remote_addr', 'unknown'),
'endpoint': f.__name__,
'method': getattr(request, 'method', 'unknown'),
'user_agent': getattr(request, 'user_agent', 'unknown')
}
try:
result = f(*args, **kwargs)
access_info['status'] = 'granted'
access_logger.info(f"ACCESS_GRANTED: {access_info}")
return result
except PermissionDenied as e:
access_info['status'] = 'denied'
access_info['reason'] = str(e)
access_logger.warning(f"ACCESS_DENIED: {access_info}")
raise
except Exception as e:
access_info['status'] = 'error'
access_info['error'] = str(e)
access_logger.error(f"ACCESS_ERROR: {access_info}")
raise
return decorated_function
Anomaly Detection
class AccessAnomalyDetector:
def __init__(self):
self.redis_client = redis.Redis(host='localhost', port=6379, db=0)
def detect_unusual_access(self, user_id, resource_id, action):
"""Detect unusual access patterns"""
# Track access frequency
key = f"access_freq:{user_id}:{action}"
current_count = self.redis_client.incr(key)
self.redis_client.expire(key, 3600) # 1 hour window
# Alert if too many requests
if current_count > 100: # Threshold
self.send_alert(f"User {user_id} made {current_count} {action} requests in last hour")
# Track resource access patterns
resource_key = f"resource_access:{user_id}"
self.redis_client.sadd(resource_key, resource_id)
self.redis_client.expire(resource_key, 86400) # 24 hour window
unique_resources = self.redis_client.scard(resource_key)
# Alert if accessing too many different resources
if unique_resources > 1000: # Threshold
self.send_alert(f"User {user_id} accessed {unique_resources} different resources in 24h")
def detect_privilege_escalation(self, user_id, attempted_action):
"""Detect potential privilege escalation attempts"""
# Track permission escalation attempts
escalation_key = f"escalation_attempts:{user_id}"
# Common admin actions that regular users shouldn't attempt
admin_actions = ['delete_user', 'modify_permissions', 'access_admin_panel']
if attempted_action in admin_actions:
self.redis_client.incr(escalation_key)
self.redis_client.expire(escalation_key, 86400)
attempts = int(self.redis_client.get(escalation_key) or 0)
if attempts >= 5: # Multiple escalation attempts
self.send_alert(f"User {user_id} attempted privilege escalation {attempts} times")
self.temporary_lockout(user_id)
def send_alert(self, message):
"""Send security alert to monitoring system"""
# Integration with your alerting system
print(f"SECURITY ALERT: {message}")
def temporary_lockout(self, user_id):
"""Temporarily lock user account"""
lockout_key = f"lockout:{user_id}"
self.redis_client.set(lockout_key, "locked", ex=1800) # 30 minute lockout
Common Mistakes and How to Avoid Them
Frontend Security Theater
// WRONG: Security only in frontend
function AdminComponent() {
const [user, setUser] = useState(null);
// This check is meaningless for security
if (user.role !== 'admin') {
return <div>Access Denied</div>;
}
return (
<div>
<button onClick={() => deleteAllUsers()}>Delete All Users</button>
</div>
);
}
// CORRECT: Security enforced on backend
app.delete('/api/users', (req, res) => {
// Real security check on server
if (req.user.role !== 'admin') {
return res.status(403).json({error: 'Admin access required'});
}
// Proceed with operation
});
Inconsistent Authorization Across Methods
# WRONG: Only protecting some HTTP methods
@app.route('/api/documents/<int:id>', methods=['GET'])
def get_document(id):
# Authorization check
if not user_can_access_document(current_user, id):
abort(403)
return jsonify(get_document_data(id))
@app.route('/api/documents/<int:id>', methods=['DELETE'])
def delete_document(id):
# No authorization check - VULNERABILITY
delete_document_data(id)
return '', 204
# CORRECT: Consistent authorization
def require_document_access(f):
@wraps(f)
def decorated(*args, **kwargs):
doc_id = kwargs.get('id')
if not user_can_access_document(current_user, doc_id):
abort(403)
return f(*args, **kwargs)
return decorated
@app.route('/api/documents/<int:id>', methods=['GET', 'PUT', 'DELETE'])
@require_document_access
def handle_document(id):
if request.method == 'GET':
return jsonify(get_document_data(id))
elif request.method == 'DELETE':
delete_document_data(id)
return '', 204
Trusting Client-Side Data for Authorization
// WRONG: Trusting role from client request
@PostMapping("/api/users")
public User createUser(@RequestBody CreateUserRequest request) {
User newUser = new User();
newUser.setUsername(request.getUsername());
newUser.setRole(request.getRole()); // Client controls role!
return userRepository.save(newUser);
}
// CORRECT: Server determines role
@PostMapping("/api/users")
public User createUser(@RequestBody CreateUserRequest request,
Authentication auth) {
// Only admins can create users
if (!auth.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_ADMIN"))) {
throw new AccessDeniedException("Admin access required");
}
User newUser = new User();
newUser.setUsername(request.getUsername());
// Server determines role based on creator's permissions
String newUserRole = determineAllowedRole(auth, request.getRole());
newUser.setRole(newUserRole);
return userRepository.save(newUser);
}
The Psychology of Access Control Bugs
Why Developers Get This Wrong
After reviewing hundreds of applications, I’ve noticed patterns in how access control bugs get introduced:
1. The Frontend Fallacy Developers think the UI controls access. “Users can’t see the admin button, so they can’t access admin functions.” This is like leaving your house unlocked because you didn’t put a welcome mat outside.
2. The Complexity Trap Modern applications have dozens of microservices, each implementing their own authorization logic. Consistency becomes impossible.
3. The Performance Excuse “Authorization checks are slow” - so they get skipped or cached incorrectly. I’ve seen apps where admin privileges get cached for hours.
4. The Integration Problem Third-party APIs, legacy systems, and quick integrations often bypass the main application’s authorization entirely.
Building a Security-First Culture
# Make security violations impossible to ignore
class AuthorizationError(Exception):
"""Loud, obvious error that can't be ignored"""
def __init__(self, user, resource, action):
self.user = user
self.resource = resource
self.action = action
# Log immediately
security_logger.error(f"AUTHORIZATION_VIOLATION: User {user.id} attempted {action} on {resource.id}")
# Alert security team
send_security_alert({
'type': 'authorization_violation',
'user_id': user.id,
'resource': resource.__class__.__name__,
'action': action,
'timestamp': datetime.utcnow()
})
super().__init__(f"Access denied: {user} cannot {action} {resource}")
# Make authorization checks mandatory
def require_authorization(resource_class, action):
"""Decorator that makes authorization checks mandatory"""
def decorator(f):
@wraps(f)
def wrapper(*args, **kwargs):
# Extract user and resource from function arguments
user = extract_user_from_args(args, kwargs)
resource_id = extract_resource_id_from_args(args, kwargs)
if not user:
raise AuthenticationError("No authenticated user")
# Load resource
resource = resource_class.objects.get(id=resource_id)
# Check authorization
if not resource.user_can_perform(user, action):
raise AuthorizationError(user, resource, action)
return f(*args, **kwargs)
return wrapper
return decorator
Testing Your Authorization Implementation
Unit Tests for Access Control
import pytest
from django.test import TestCase
from django.contrib.auth.models import User
from myapp.models import Document
class AccessControlTest(TestCase):
def setUp(self):
self.owner = User.objects.create_user('owner', 'owner@test.com', 'pass')
self.other_user = User.objects.create_user('other', 'other@test.com', 'pass')
self.admin = User.objects.create_user('admin', 'admin@test.com', 'pass')
self.admin.is_staff = True
self.admin.save()
self.document = Document.objects.create(
title='Test Doc',
content='Secret content',
owner=self.owner
)
def test_owner_can_read_own_document(self):
"""Test that owners can read their own documents"""
self.assertTrue(self.document.user_can_read(self.owner))
def test_other_user_cannot_read_private_document(self):
"""Test that other users cannot read private documents"""
self.assertFalse(self.document.user_can_read(self.other_user))
def test_admin_can_read_all_documents(self):
"""Test that admins can read all documents"""
self.assertTrue(self.document.user_can_read(self.admin))
def test_unauthorized_access_raises_exception(self):
"""Test that unauthorized access attempts raise proper exceptions"""
with pytest.raises(PermissionDenied):
self.document.get_content_for_user(self.other_user)
def test_idor_protection(self):
"""Test IDOR protection by trying to access other user's document"""
client = Client()
client.login(username='other', password='pass')
# Try to access owner's document
response = client.get(f'/documents/{self.document.id}/')
self.assertEqual(response.status_code, 403)
def test_privilege_escalation_prevention(self):
"""Test that users cannot escalate their privileges"""
client = Client()
client.login(username='other', password='pass')
# Try to access admin endpoint
response = client.get('/admin/users/')
self.assertEqual(response.status_code, 403)
Integration Tests
def test_complete_access_control_flow():
"""Test complete access control flow across multiple services"""
# Create test users
regular_user = create_test_user(role='user')
admin_user = create_test_user(role='admin')
# Test document creation (should work for any authenticated user)
doc = create_document(user=regular_user, title='Test Doc')
assert doc.owner == regular_user
# Test document access (owner should have access)
assert can_access_document(regular_user, doc) == True
# Test IDOR protection (other user should not have access)
other_user = create_test_user(role='user')
assert can_access_document(other_user, doc) == False
# Test admin override (admin should have access)
assert can_access_document(admin_user, doc) == True
# Test deletion (only owner or admin)
assert can_delete_document(regular_user, doc) == True
assert can_delete_document(other_user, doc) == False
assert can_delete_document(admin_user, doc) == True
# Test API access patterns
response = api_client.get(f'/api/documents/{doc.id}',
headers=auth_headers(other_user))
assert response.status_code == 403
response = api_client.get(f'/api/documents/{doc.id}',
headers=auth_headers(regular_user))
assert response.status_code == 200
Conclusion
Broken access control isn’t just a technical vulnerability - it’s a systematic failure that affects 94% of applications. The consolidation of SSRF into this category in OWASP 2025 reflects the reality that attackers don’t think in isolated vulnerability categories - they chain techniques to bypass authorization.
Key Takeaways from 15+ Years of Access Control Bugs:
- Authorization happens on the server, period - Never trust client-side security
- Default deny everything - Require explicit permission for every action
- Test with different user contexts - Your tests should include cross-user scenarios
- Monitor and log access patterns - Unusual activity often indicates exploitation
- Defense in depth - Multiple layers of authorization checks
The patterns are predictable, the solutions are well-understood, and the tools exist. The only reason broken access control remains #1 is because teams don’t prioritize implementing authorization correctly from the start.
Don’t be part of that 94%. Build authorization into your application architecture from day one, test it thoroughly, and monitor it continuously.
Next Steps:
- Implement the authorization patterns shown for your framework
- Set up monitoring for access control violations
- Create tests that verify authorization from an attacker’s perspective
- Review your existing applications with these attack patterns in mind
Remember: attackers don’t care about your user interface - they go straight to your API. Make sure your API cares about authorization.
Ready to tackle the next OWASP category? Continue with our A02: Security Misconfiguration Guide - the vulnerability that jumped from #5 to #2 in 2025.