Broken Access Control: Authorization Vulnerabilities

Authentication answers 'who are you?' Authorization answers 'what can you do?' Broken access control occurs when your application fails to properly enforce the latter, allowing users to access...

Key Insights

  • Broken access control is the #1 vulnerability in the OWASP Top 10, affecting 94% of tested applications, yet it remains one of the easiest flaws to exploit through simple parameter manipulation.
  • Authorization must always be enforced server-side with centralized logic—client-side checks, hidden fields, and predictable IDs provide zero security against determined attackers.
  • Every endpoint needs explicit authorization verification; defaulting to “deny all” and requiring explicit grants prevents the accidental exposure that leads to most access control breaches.

What is Broken Access Control?

Authentication answers “who are you?” Authorization answers “what can you do?” Broken access control occurs when your application fails to properly enforce the latter, allowing users to access resources or perform actions beyond their intended permissions.

This vulnerability consistently holds the #1 spot on the OWASP Top 10, appearing in 94% of applications tested. The business impact is severe: unauthorized data access, account takeover, privilege escalation, and complete system compromise. The 2021 Parler data breach exposed 70TB of user data through a simple IDOR vulnerability. Facebook’s 2018 breach affected 50 million accounts due to access token mishandling.

The core problem is straightforward: developers authenticate users but forget to verify whether authenticated users should access specific resources. An authenticated user isn’t an authorized user.

Common Vulnerability Patterns

Insecure Direct Object References (IDOR)

IDOR occurs when applications expose internal object references without validating user authorization. Here’s a vulnerable endpoint:

// VULNERABLE: No authorization check
app.get('/api/users/:userId/profile', async (req, res) => {
  const user = await User.findById(req.params.userId);
  res.json({
    email: user.email,
    ssn: user.ssn,
    salary: user.salary,
    address: user.address
  });
});

Any authenticated user can access any other user’s sensitive data by changing the userId parameter. The application trusts the client-supplied ID without verification.

Privilege Escalation

Horizontal escalation lets users access other users’ resources at the same privilege level. Vertical escalation lets users gain higher privileges entirely.

// VULNERABLE: Client-side role check
app.post('/api/admin/delete-user', async (req, res) => {
  // Trusting a flag sent from the client
  if (req.body.isAdmin) {
    await User.deleteOne({ _id: req.body.targetUserId });
    return res.json({ success: true });
  }
  res.status(403).json({ error: 'Forbidden' });
});

An attacker simply adds "isAdmin": true to their request body. Never trust client-supplied authorization data.

Missing Function-Level Access Control

Administrative endpoints often lack proper protection:

# VULNERABLE: No authorization on admin endpoint
@app.route('/admin/users', methods=['GET'])
def list_all_users():
    # Assumes only admins know this URL exists
    users = User.query.all()
    return jsonify([u.to_dict() for u in users])

Security through obscurity isn’t security. Attackers routinely scan for common admin paths.

Metadata Manipulation

JWT tokens and hidden form fields are frequent targets:

// VULNERABLE: Trusting JWT claims without server verification
const token = jwt.decode(req.headers.authorization); // decode, not verify!
if (token.role === 'admin') {
  // Grant admin access
}

Attackers can forge tokens with elevated privileges when you decode without cryptographic verification.

Real-World Attack Scenarios

IDOR Enumeration Attack

Here’s how attackers exploit predictable IDs:

import requests

base_url = "https://vulnerable-app.com/api/orders"
session = requests.Session()
session.cookies.set('session', 'attacker_session_token')

# Enumerate order IDs to find other users' orders
for order_id in range(1000, 2000):
    response = session.get(f"{base_url}/{order_id}")
    if response.status_code == 200:
        order_data = response.json()
        print(f"Found order {order_id}: {order_data['customer_email']}")
        print(f"  Items: {order_data['items']}")
        print(f"  Address: {order_data['shipping_address']}")

This script systematically harvests order data belonging to other customers. With sequential IDs, attackers can enumerate entire databases.

Privilege Escalation via Parameter Tampering

# Original request as regular user
curl -X PUT https://api.example.com/users/profile \
  -H "Authorization: Bearer user_token" \
  -H "Content-Type: application/json" \
  -d '{"name": "John Doe", "email": "john@example.com"}'

# Attacker adds role parameter
curl -X PUT https://api.example.com/users/profile \
  -H "Authorization: Bearer user_token" \
  -H "Content-Type: application/json" \
  -d '{"name": "John Doe", "email": "john@example.com", "role": "admin"}'

If the backend blindly accepts and applies all submitted fields, the attacker just granted themselves admin privileges.

Forced Browsing

# Discovering admin endpoints through common paths
curl -I https://target.com/admin
curl -I https://target.com/admin/dashboard
curl -I https://target.com/api/admin/users
curl -I https://target.com/management/console
curl -I https://target.com/actuator/env  # Spring Boot actuator

Automated tools like DirBuster or ffuf can test thousands of paths in minutes.

Secure Authorization Implementation

Centralized Authorization Middleware

// authorization.middleware.js
const authorize = (allowedRoles) => {
  return async (req, res, next) => {
    // 1. Verify user is authenticated
    if (!req.user) {
      return res.status(401).json({ error: 'Authentication required' });
    }

    // 2. Fetch fresh user data from database (don't trust cached roles)
    const user = await User.findById(req.user.id).select('role status');
    
    if (!user || user.status !== 'active') {
      return res.status(401).json({ error: 'Invalid user' });
    }

    // 3. Check role authorization
    if (!allowedRoles.includes(user.role)) {
      // Log the attempt for security monitoring
      logger.warn('Authorization failure', {
        userId: req.user.id,
        requiredRoles: allowedRoles,
        userRole: user.role,
        endpoint: req.originalUrl
      });
      return res.status(403).json({ error: 'Insufficient permissions' });
    }

    req.authorizedUser = user;
    next();
  };
};

// Resource ownership verification
const authorizeOwnership = (resourceFetcher) => {
  return async (req, res, next) => {
    const resource = await resourceFetcher(req);
    
    if (!resource) {
      return res.status(404).json({ error: 'Resource not found' });
    }

    // Check ownership OR admin override
    if (resource.ownerId !== req.user.id && req.authorizedUser.role !== 'admin') {
      return res.status(403).json({ error: 'Access denied' });
    }

    req.resource = resource;
    next();
  };
};

// Usage
app.get('/api/orders/:orderId',
  authenticate,
  authorize(['user', 'admin']),
  authorizeOwnership((req) => Order.findById(req.params.orderId)),
  (req, res) => {
    res.json(req.resource);
  }
);

Python Decorator Pattern

from functools import wraps
from flask import request, g, jsonify

def require_roles(*allowed_roles):
    def decorator(f):
        @wraps(f)
        def decorated_function(*args, **kwargs):
            if not g.current_user:
                return jsonify({'error': 'Authentication required'}), 401
            
            # Fetch fresh role from database
            user = User.query.get(g.current_user.id)
            if not user or user.role not in allowed_roles:
                return jsonify({'error': 'Insufficient permissions'}), 403
            
            return f(*args, **kwargs)
        return decorated_function
    return decorator

def require_ownership(resource_class, id_param='id'):
    def decorator(f):
        @wraps(f)
        def decorated_function(*args, **kwargs):
            resource_id = kwargs.get(id_param) or request.view_args.get(id_param)
            resource = resource_class.query.get(resource_id)
            
            if not resource:
                return jsonify({'error': 'Not found'}), 404
            
            if resource.owner_id != g.current_user.id:
                return jsonify({'error': 'Access denied'}), 403
            
            g.resource = resource
            return f(*args, **kwargs)
        return decorated_function
    return decorator

# Usage
@app.route('/api/documents/<int:id>', methods=['DELETE'])
@require_roles('user', 'admin')
@require_ownership(Document)
def delete_document(id):
    g.resource.delete()
    return jsonify({'success': True})

Testing and Detection Strategies

Unit Testing Authorization Logic

import pytest
from app import create_app
from models import User, Order

class TestOrderAuthorization:
    @pytest.fixture
    def app(self):
        return create_app('testing')
    
    @pytest.fixture
    def user_a(self, app):
        return User.create(email='a@test.com', role='user')
    
    @pytest.fixture
    def user_b(self, app):
        return User.create(email='b@test.com', role='user')
    
    @pytest.fixture
    def admin(self, app):
        return User.create(email='admin@test.com', role='admin')

    def test_user_cannot_access_others_order(self, client, user_a, user_b):
        order = Order.create(owner=user_a, total=100)
        
        # User B tries to access User A's order
        response = client.get(
            f'/api/orders/{order.id}',
            headers={'Authorization': f'Bearer {user_b.token}'}
        )
        
        assert response.status_code == 403

    def test_admin_can_access_any_order(self, client, user_a, admin):
        order = Order.create(owner=user_a, total=100)
        
        response = client.get(
            f'/api/orders/{order.id}',
            headers={'Authorization': f'Bearer {admin.token}'}
        )
        
        assert response.status_code == 200

    def test_unauthenticated_request_rejected(self, client, user_a):
        order = Order.create(owner=user_a, total=100)
        
        response = client.get(f'/api/orders/{order.id}')
        
        assert response.status_code == 401

Security Test Matrix

Endpoint Anonymous User (Own) User (Other) Admin
GET /orders/:id 401 200 403 200
PUT /orders/:id 401 200 403 200
DELETE /orders/:id 401 200 403 200
GET /admin/users 401 403 403 200

Automate this matrix as integration tests. Every cell represents a test case.

Framework-Specific Safeguards

Django Permission Classes

from rest_framework import permissions

class IsOwnerOrAdmin(permissions.BasePermission):
    def has_object_permission(self, request, view, obj):
        if request.user.is_staff:
            return True
        return obj.owner == request.user

class OrderViewSet(viewsets.ModelViewSet):
    permission_classes = [permissions.IsAuthenticated, IsOwnerOrAdmin]
    
    def get_queryset(self):
        # Query scoping: users only see their own orders
        if self.request.user.is_staff:
            return Order.objects.all()
        return Order.objects.filter(owner=self.request.user)

Spring Security Method-Level Authorization

@RestController
@RequestMapping("/api/documents")
public class DocumentController {

    @PreAuthorize("hasRole('USER')")
    @GetMapping("/{id}")
    public Document getDocument(@PathVariable Long id) {
        Document doc = documentService.findById(id);
        
        // Programmatic ownership check
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (!doc.getOwnerId().equals(auth.getName()) && 
            !auth.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_ADMIN"))) {
            throw new AccessDeniedException("Not authorized");
        }
        
        return doc;
    }

    @PreAuthorize("hasRole('ADMIN')")
    @DeleteMapping("/admin/purge")
    public void purgeAllDocuments() {
        documentService.purgeAll();
    }
}

Summary and Checklist

Authorization Checklist:

  • Deny by default—require explicit grants for all resources
  • Enforce authorization server-side on every request
  • Use centralized authorization logic, not scattered checks
  • Verify resource ownership, not just authentication
  • Use unpredictable identifiers (UUIDs) for sensitive resources
  • Scope database queries to authorized resources
  • Log authorization failures for security monitoring
  • Test authorization with automated security test matrices
  • Re-validate permissions on sensitive operations
  • Never trust client-supplied role or permission data

Further Reading:

Broken access control vulnerabilities persist because authorization is harder than authentication. Authentication is binary—you’re logged in or you’re not. Authorization is contextual, resource-specific, and easy to overlook. Build your defenses with centralized, tested, server-side enforcement, and assume every endpoint is a target.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.