Skip to content

CWE-566: Authorization Bypass Through User-Controlled Key - C#

Overview

Authorization bypass through user-controlled keys, commonly known as Insecure Direct Object Reference (IDOR), is a critical vulnerability in C# applications where developers use user-supplied identifiers - such as order IDs, user IDs, or document IDs - in database queries or business logic without verifying that the authenticated user has authorization to access those resources. This vulnerability enables horizontal privilege escalation, allowing attackers to access, modify, or delete other users' data by manipulating request parameters.

ASP.NET Core applications, particularly those using Entity Framework Core, are susceptible to IDOR vulnerabilities when developers rely on convenience methods like FindAsync() or FirstOrDefaultAsync() without implementing proper authorization checks. The prevalence of RESTful API patterns in .NET (where resource IDs appear directly in URLs like /api/orders/{orderId}) creates numerous opportunities for this vulnerability if authorization is not consistently enforced at the data access layer.

ASP.NET Core Identity and authorization middleware provide robust authentication mechanisms, but they do NOT automatically enforce object-level authorization. Many developers mistakenly believe that [Authorize] attribute is sufficient protection, when in reality it only confirms the user is logged in - not that they own the requested resource. This gap between authentication and authorization is the root cause of most C# IDOR vulnerabilities. Additionally, EF Core's navigation properties and lazy loading can inadvertently expose related entities without authorization checks.

Real-world C# IDOR vulnerabilities have resulted in significant data breaches, including exposure of financial records, healthcare information, and customer data. E-commerce platforms, SaaS applications, and enterprise systems built with ASP.NET Core have been compromised through sequential ID enumeration and parameter manipulation. Given C#'s dominance in enterprise applications and cloud services (Azure), implementing proper object-level authorization is business-critical.

Primary Defence: Create repository methods or service layer functions that enforce ownership by combining resource ID with user ID in LINQ queries: context.Orders.FirstOrDefaultAsync(o => o.Id == orderId && o.UserId == currentUserId). Never use bare FindAsync(id) or FirstOrDefaultAsync(o => o.Id == id) without subsequent authorization checks. Leverage ASP.NET Core's authorization policies with resource-based authorization handlers to verify ownership before granting access.

Common Vulnerable Patterns

ASP.NET Core Controller Without Authorization Check

// VULNERABLE - No ownership verification in ASP.NET Core API Controller
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly ApplicationDbContext _context;

    public OrdersController(ApplicationDbContext context)
    {
        _context = context;
    }

    [HttpGet("{orderId}")]
    [Authorize] // Only checks if user is authenticated!
    public async Task<ActionResult<OrderDto>> GetOrder(int orderId)
    {
        // Directly retrieves ANY order by ID - no authorization!
        var order = await _context.Orders
            .FirstOrDefaultAsync(o => o.Id == orderId);

        if (order == null)
            return NotFound();

        return Ok(new OrderDto(order));
    }
}

// Attack example:
// User A's order: GET /api/orders/1001 → Returns User A's order
// Attacker tries: GET /api/orders/1002 → Returns User B's order!
// Attacker tries: GET /api/orders/1003 → Returns User C's order!
// Sequential enumeration exposes ALL orders in the system

Why this is vulnerable: The [Authorize] attribute only verifies that the user is authenticated (logged in), not that they own the requested order. The LINQ query FirstOrDefaultAsync(o => o.Id == orderId) retrieves any order matching the ID without checking the UserId or OwnerId property. Any authenticated user can access any order by manipulating the orderId route parameter, enabling complete horizontal privilege escalation across all user data.

Entity Framework Core FindAsync Without Authorization

// VULNERABLE - Using FindAsync without ownership check
[ApiController]
[Route("api/[controller]")]
public class DocumentsController : ControllerBase
{
    private readonly ApplicationDbContext _context;

    public DocumentsController(ApplicationDbContext context)
    {
        _context = context;
    }

    [HttpGet("{id}")]
    [Authorize]
    public async Task<ActionResult<Document>> GetDocument(int id)
    {
        // FindAsync has NO ownership filtering!
        var document = await _context.Documents.FindAsync(id);

        if (document == null)
            return NotFound();

        return document; // Returns ANY document, regardless of owner
    }
}

// Attack example:
// Any authenticated user can call this endpoint with ANY document ID
// Result: Horizontal privilege escalation - access to all documents

Why this is vulnerable: Entity Framework Core's FindAsync() method is a convenience function that retrieves entities by primary key only - it cannot filter by additional properties like OwnerId or UserId. This means any document can be retrieved if the attacker knows or guesses the ID. The method bypasses any ownership validation, and the [Authorize] attribute only confirms authentication, not authorization to access this specific resource.

Service Layer Without User Context

// VULNERABLE - Service method doesn't verify ownership
public class UserService
{
    private readonly ApplicationDbContext _context;

    public UserService(ApplicationDbContext context)
    {
        _context = context;
    }

    public async Task<User> GetUserProfile(int userId)
    {
        // User ID comes from request parameter - no verification!
        return await _context.Users
            .Where(u => u.Id == userId)
            .Select(u => new User
            {
                Id = u.Id,
                Email = u.Email,        // PII exposure!
                PhoneNumber = u.PhoneNumber,
                SSN = u.SSN,            // Critical data leak!
                CreditCardNumber = u.CreditCardNumber
            })
            .FirstOrDefaultAsync();
    }
}

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    private readonly UserService _userService;

    public UsersController(UserService userService)
    {
        _userService = userService;
    }

    [HttpGet("{userId}/profile")]
    [Authorize]
    public async Task<ActionResult<User>> GetProfile(int userId)
    {
        // No check if current user can access this profile
        var user = await _userService.GetUserProfile(userId);

        if (user == null)
            return NotFound();

        return user;
    }
}

// Attack example:
// Attacker enumerates: GET /api/users/1/profile → User 1's SSN & CC
// GET /api/users/2/profile → User 2's SSN & CC
// Mass PII theft through sequential access

Why this is vulnerable: The service layer accepts a userId parameter and queries the database for that user without verifying that the currently authenticated user has permission to view that profile. The controller blindly passes the route parameter to the service without any authorization check. This enables any authenticated user to enumerate all user profiles by incrementing the userId, exposing highly sensitive PII including SSNs and credit card numbers for every user in the system.

File Download Without Authorization

// VULNERABLE - File download using user-provided filename
[ApiController]
[Route("api/[controller]")]
public class FilesController : ControllerBase
{
    private readonly string _uploadPath = @"C:\Uploads\";

    [HttpGet("download/{filename}")]
    [Authorize]
    public IActionResult DownloadFile(string filename)
    {
        var filePath = Path.Combine(_uploadPath, filename);

        if (!System.IO.File.Exists(filePath))
            return NotFound();

        // No ownership check - anyone can download any file!
        var fileBytes = System.IO.File.ReadAllBytes(filePath);
        return File(fileBytes, "application/octet-stream", filename);
    }
}

// Attack example:
// User uploads: invoice_user123.pdf
// Attacker guesses: GET /download/invoice_user124.pdf
// Attacker tries: GET /download/invoice_user125.pdf
// Path traversal: GET /download/../../Windows/System32/config/SAM
// Result: Downloads other users' files or system files!

Why this is vulnerable: The code constructs a file path using the user-controlled filename parameter and serves the file without any authorization check. There is no verification that the current user owns the file or has permission to access it. An attacker can download any file in the uploads directory by guessing filenames, and without proper path sanitization, could potentially use path traversal attacks (../) to access files outside the intended directory, including system files.

Bulk Operations Without Per-Item Authorization

// VULNERABLE - Bulk delete without individual authorization checks
[ApiController]
[Route("api/[controller]")]
public class DocumentsController : ControllerBase
{
    private readonly ApplicationDbContext _context;

    public DocumentsController(ApplicationDbContext context)
    {
        _context = context;
    }

    [HttpPost("bulk-delete")]
    [Authorize]
    public async Task<ActionResult<BulkDeleteResponse>> BulkDelete(
        [FromBody] BulkDeleteRequest request)
    {
        // Deletes ALL specified documents without ownership verification!
        var documentsToDelete = await _context.Documents
            .Where(d => request.DocumentIds.Contains(d.Id))
            .ToListAsync();

        _context.Documents.RemoveRange(documentsToDelete);
        await _context.SaveChangesAsync();

        return Ok(new BulkDeleteResponse
        {
            DeletedCount = documentsToDelete.Count,
            Message = "Documents deleted"
        });
    }
}

// Attack example:
// POST /api/documents/bulk-delete
// Body: {"documentIds": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]}
// Result: Deletes ANY documents, including other users' documents!
// Attacker can wipe entire database by enumerating IDs

Why this is vulnerable: The bulk delete operation uses Where(d => request.DocumentIds.Contains(d.Id)) which retrieves all documents matching the provided IDs without verifying ownership for each document. The query lacks an ownership filter (e.g., && d.UserId == currentUserId), allowing an attacker to delete any documents in the system by including their IDs in the request. This enables mass data destruction attacks where an attacker can enumerate IDs and delete the entire database, affecting all users.

ASP.NET Core Authorization Policy Misconfiguration

// VULNERABLE - Policy only checks role, not ownership
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddAuthorization(options =>
        {
            // This only checks if user has "User" role
            options.AddPolicy("UserPolicy", policy =>
                policy.RequireRole("User"));
        });
    }
}

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly ApplicationDbContext _context;

    public OrdersController(ApplicationDbContext context)
    {
        _context = context;
    }

    [HttpGet("{orderId}")]
    [Authorize(Policy = "UserPolicy")] // Only checks role!
    public async Task<ActionResult<Order>> GetOrder(int orderId)
    {
        var order = await _context.Orders.FindAsync(orderId);

        if (order == null)
            return NotFound();

        return order; // Any user with "User" role can access ANY order
    }
}

// Attack example:
// User logs in → Has "User" role → Policy passes
// User requests: GET /api/orders/999 (belongs to another user)
// Policy check passes because user HAS the role
// Result: Returns another user's order details

Why this is vulnerable: The authorization policy RequireRole("User") only verifies that the user has the "User" role, not that they own the requested order. This is a common misconception - ASP.NET Core's policy-based authorization provides role/claim checking, but object-level authorization must be implemented separately. Any user with the correct role can pass this check and retrieve any order by manipulating the orderId parameter, enabling horizontal privilege escalation across all users with the same role.

Secure Patterns

LINQ Query with Ownership Filter (Primary Pattern)

// SECURE - Query includes ownership check
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly ApplicationDbContext _context;
    private readonly IHttpContextAccessor _httpContextAccessor;

    public OrdersController(
        ApplicationDbContext context,
        IHttpContextAccessor httpContextAccessor)
    {
        _context = context;
        _httpContextAccessor = httpContextAccessor;
    }

    [HttpGet("{orderId}")]
    [Authorize]
    public async Task<ActionResult<OrderDto>> GetOrder(int orderId)
    {
        var currentUserId = GetCurrentUserId();

        // Query filters by BOTH id AND user ownership
        var order = await _context.Orders
            .Where(o => o.Id == orderId && o.UserId == currentUserId)
            .FirstOrDefaultAsync();

        if (order == null)
        {
            // Return 404 for both non-existent and unauthorized
            return NotFound();
        }

        return Ok(new OrderDto(order));
    }

    [HttpGet]
    [Authorize]
    public async Task<ActionResult<List<OrderDto>>> GetAllOrders()
    {
        var currentUserId = GetCurrentUserId();

        // Only returns orders belonging to current user
        var orders = await _context.Orders
            .Where(o => o.UserId == currentUserId)
            .Select(o => new OrderDto(o))
            .ToListAsync();

        return Ok(orders);
    }

    private string GetCurrentUserId()
    {
        return _httpContextAccessor.HttpContext?.User
            .FindFirst(ClaimTypes.NameIdentifier)?.Value
            ?? throw new UnauthorizedAccessException("User not authenticated");
    }
}

Why this works: The LINQ query combines the resource ID lookup with the ownership check by including && o.UserId == currentUserId in the WHERE clause. The database ensures that only orders belonging to the authenticated user can be retrieved, even if an attacker knows another user's order ID. The GetCurrentUserId() method extracts the user ID from the authenticated claims (from JWT token or session), which cannot be manipulated by the client. Returning 404 for both non-existent and unauthorized resources prevents information leakage about which order IDs exist. This prevents horizontal privilege escalation at the database layer before the data ever reaches the application code.

Repository Pattern with Authorization

// SECURE - Repository with built-in ownership filtering
public interface IDocumentRepository
{
    Task<Document> GetByIdAsync(int documentId, string userId);
    Task<List<Document>> GetAllForUserAsync(string userId);
    Task<bool> DeleteAsync(int documentId, string userId);
}

public class DocumentRepository : IDocumentRepository
{
    private readonly ApplicationDbContext _context;

    public DocumentRepository(ApplicationDbContext context)
    {
        _context = context;
    }

    public async Task<Document> GetByIdAsync(int documentId, string userId)
    {
        // Authorization check built into repository method
        return await _context.Documents
            .Where(d => d.Id == documentId && d.UserId == userId)
            .FirstOrDefaultAsync();
    }

    public async Task<List<Document>> GetAllForUserAsync(string userId)
    {
        // Scoped to current user
        return await _context.Documents
            .Where(d => d.UserId == userId)
            .ToListAsync();
    }

    public async Task<bool> DeleteAsync(int documentId, string userId)
    {
        var document = await GetByIdAsync(documentId, userId);

        if (document == null)
            return false;

        _context.Documents.Remove(document);
        await _context.SaveChangesAsync();
        return true;
    }
}

[ApiController]
[Route("api/[controller]")]
public class DocumentsController : ControllerBase
{
    private readonly IDocumentRepository _documentRepository;

    public DocumentsController(IDocumentRepository documentRepository)
    {
        _documentRepository = documentRepository;
    }

    [HttpGet("{documentId}")]
    [Authorize]
    public async Task<ActionResult<Document>> GetDocument(int documentId)
    {
        var currentUserId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;

        // Repository enforces authorization
        var document = await _documentRepository.GetByIdAsync(documentId, currentUserId);

        if (document == null)
            return NotFound();

        return Ok(document);
    }

    [HttpDelete("{documentId}")]
    [Authorize]
    public async Task<IActionResult> DeleteDocument(int documentId)
    {
        var currentUserId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;

        var deleted = await _documentRepository.DeleteAsync(documentId, currentUserId);

        if (!deleted)
            return NotFound();

        return NoContent();
    }
}

Why this works: The Repository pattern encapsulates all data access logic and enforces authorization at the repository level. Every repository method requires both the resource ID and the user ID as parameters, ensuring authorization cannot be bypassed. The repository methods include ownership filters in all queries, making it impossible to retrieve resources belonging to other users. This pattern centralizes authorization logic, prevents code duplication across controllers, and ensures consistent enforcement throughout the application. Controllers simply pass the authenticated user ID from the claims to the repository, separating authorization concerns from HTTP handling.

ASP.NET Core Resource-Based Authorization

// SECURE - Resource-based authorization with IAuthorizationService
public class DocumentAuthorizationHandler : 
    AuthorizationHandler<OperationAuthorizationRequirement, Document>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        OperationAuthorizationRequirement requirement,
        Document resource)
    {
        var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;

        if (userId == null)
            return Task.CompletedTask;

        // Check ownership
        if (resource.UserId == userId)
        {
            context.Succeed(requirement);
            return Task.CompletedTask;
        }

        // Check shared access for read operations
        if (requirement.Name == Operations.Read.Name)
        {
            // Could check shared permissions here
            // if (resource.SharedWith.Contains(userId))
            //     context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

public static class Operations
{
    public static OperationAuthorizationRequirement Create =
        new OperationAuthorizationRequirement { Name = nameof(Create) };
    public static OperationAuthorizationRequirement Read =
        new OperationAuthorizationRequirement { Name = nameof(Read) };
    public static OperationAuthorizationRequirement Update =
        new OperationAuthorizationRequirement { Name = nameof(Update) };
    public static OperationAuthorizationRequirement Delete =
        new OperationAuthorizationRequirement { Name = nameof(Delete) };
}

// Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<IAuthorizationHandler, DocumentAuthorizationHandler>();
}

[ApiController]
[Route("api/[controller]")]
public class DocumentsController : ControllerBase
{
    private readonly ApplicationDbContext _context;
    private readonly IAuthorizationService _authorizationService;

    public DocumentsController(
        ApplicationDbContext context,
        IAuthorizationService authorizationService)
    {
        _context = context;
        _authorizationService = authorizationService;
    }

    [HttpGet("{documentId}")]
    [Authorize]
    public async Task<ActionResult<Document>> GetDocument(int documentId)
    {
        var document = await _context.Documents.FindAsync(documentId);

        if (document == null)
            return NotFound();

        // Authorize access to this specific document
        var authResult = await _authorizationService
            .AuthorizeAsync(User, document, Operations.Read);

        if (!authResult.Succeeded)
            return Forbid();

        return Ok(document);
    }

    [HttpDelete("{documentId}")]
    [Authorize]
    public async Task<IActionResult> DeleteDocument(int documentId)
    {
        var document = await _context.Documents.FindAsync(documentId);

        if (document == null)
            return NotFound();

        // Authorize delete on this specific document
        var authResult = await _authorizationService
            .AuthorizeAsync(User, document, Operations.Delete);

        if (!authResult.Succeeded)
            return Forbid();

        _context.Documents.Remove(document);
        await _context.SaveChangesAsync();

        return NoContent();
    }
}

Why this works: ASP.NET Core's IAuthorizationService with resource-based authorization provides a framework-level mechanism for object-level authorization. The DocumentAuthorizationHandler encapsulates the authorization logic, checking if the current user owns the document or has shared access. The handler is called with the actual document resource, allowing fine-grained permission checks based on the resource's properties. The AuthorizeAsync method evaluates the authorization requirements and either succeeds or fails before the controller action proceeds. This pattern separates authorization logic from controllers, makes it reusable across the application, and provides a consistent authorization model that's easy to test and maintain.

Service Layer with User Context Injection

// SECURE - Service layer with enforced user context
public interface IOrderService
{
    Task<Order> GetOrderAsync(int orderId, string currentUserId);
    Task<List<Order>> GetUserOrdersAsync(string currentUserId);
    Task<Order> UpdateOrderAsync(int orderId, OrderUpdateDto update, string currentUserId);
    Task<bool> DeleteOrderAsync(int orderId, string currentUserId);
}

public class OrderService : IOrderService
{
    private readonly ApplicationDbContext _context;

    public OrderService(ApplicationDbContext context)
    {
        _context = context;
    }

    public async Task<Order> GetOrderAsync(int orderId, string currentUserId)
    {
        // Authorization check in service layer
        return await _context.Orders
            .Where(o => o.Id == orderId && o.UserId == currentUserId)
            .FirstOrDefaultAsync();
    }

    public async Task<List<Order>> GetUserOrdersAsync(string currentUserId)
    {
        // Scoped to current user
        return await _context.Orders
            .Where(o => o.UserId == currentUserId)
            .OrderByDescending(o => o.CreatedAt)
            .ToListAsync();
    }

    public async Task<Order> UpdateOrderAsync(
        int orderId, 
        OrderUpdateDto update, 
        string currentUserId)
    {
        var order = await GetOrderAsync(orderId, currentUserId);

        if (order == null)
            throw new UnauthorizedAccessException("Order not found or access denied");

        // Update fields
        order.ShippingAddress = update.ShippingAddress;
        order.Notes = update.Notes;

        await _context.SaveChangesAsync();
        return order;
    }

    public async Task<bool> DeleteOrderAsync(int orderId, string currentUserId)
    {
        var order = await GetOrderAsync(orderId, currentUserId);

        if (order == null)
            return false;

        _context.Orders.Remove(order);
        await _context.SaveChangesAsync();
        return true;
    }
}

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly IOrderService _orderService;

    public OrdersController(IOrderService orderService)
    {
        _orderService = orderService;
    }

    [HttpGet("{orderId}")]
    [Authorize]
    public async Task<ActionResult<Order>> GetOrder(int orderId)
    {
        var currentUserId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        var order = await _orderService.GetOrderAsync(orderId, currentUserId);

        if (order == null)
            return NotFound();

        return Ok(order);
    }

    [HttpPut("{orderId}")]
    [Authorize]
    public async Task<ActionResult<Order>> UpdateOrder(
        int orderId, 
        [FromBody] OrderUpdateDto update)
    {
        var currentUserId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;

        try
        {
            var order = await _orderService.UpdateOrderAsync(orderId, update, currentUserId);
            return Ok(order);
        }
        catch (UnauthorizedAccessException)
        {
            return NotFound();
        }
    }
}

Why this works: The service layer enforces authorization by requiring the currentUserId parameter for all methods and including ownership checks in database queries. Controllers extract the user ID from authenticated claims and pass it to the service layer, which cannot be manipulated by clients. This pattern provides centralized business logic with built-in authorization, ensures consistent enforcement across multiple controllers or API versions, and makes authorization explicit in method signatures. The service methods return null or throw exceptions when authorization fails, allowing controllers to handle responses appropriately while keeping authorization logic in the service layer.

Using GUIDs with Authorization (Defense in Depth)

// SECURE - GUID primary keys + authorization checks
public class Document
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public string UserId { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }
    public DateTime CreatedAt { get; set; }
}

public class DocumentsDbContext : DbContext
{
    public DbSet<Document> Documents { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Document>()
            .Property(d => d.Id)
            .HasDefaultValueSql("NEWID()"); // SQL Server
            // Or use .ValueGeneratedOnAdd() for code-generated GUIDs
    }
}

[ApiController]
[Route("api/[controller]")]
public class DocumentsController : ControllerBase
{
    private readonly DocumentsDbContext _context;

    public DocumentsController(DocumentsDbContext context)
    {
        _context = context;
    }

    [HttpGet("{documentId:guid}")]
    [Authorize]
    public async Task<ActionResult<Document>> GetDocument(Guid documentId)
    {
        var currentUserId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;

        // GUIDs prevent enumeration, but STILL need authorization!
        var document = await _context.Documents
            .Where(d => d.Id == documentId && d.UserId == currentUserId)
            .FirstOrDefaultAsync();

        if (document == null)
            return NotFound();

        return Ok(document);
    }
}

// Example document IDs:
// Instead of: /api/documents/1, /api/documents/2, /api/documents/3
// Use: /api/documents/550e8400-e29b-41d4-a716-446655440000
// GUIDs are cryptographically random - enumeration is computationally infeasible

Why this works: GUIDs (Globally Unique Identifiers) are 128-bit values with approximately 2^122 possible unique combinations, making sequential enumeration attacks computationally infeasible. Unlike auto-incrementing integer IDs (1, 2, 3...), attackers cannot guess valid GUIDs through pattern matching or incrementing. However, GUIDs are NOT a security control by themselves - they only prevent brute-force enumeration. The code still includes explicit authorization checks (&& d.UserId == currentUserId) because GUIDs can be exposed through logs, shared URLs, browser history, API responses, or social engineering. This defense-in-depth approach combines obscurity (GUIDs make guessing hard) with proper access control (ownership verification prevents access even with known GUIDs) for maximum security.

Bulk Operations with Per-Item Authorization

// SECURE - Verify ownership for each item in bulk operation
[ApiController]
[Route("api/[controller]")]
public class DocumentsController : ControllerBase
{
    private readonly ApplicationDbContext _context;

    public DocumentsController(ApplicationDbContext context)
    {
        _context = context;
    }

    [HttpPost("bulk-delete")]
    [Authorize]
    public async Task<ActionResult<BulkDeleteResponse>> BulkDelete(
        [FromBody] BulkDeleteRequest request)
    {
        var currentUserId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;

        // Rate limiting
        if (request.DocumentIds == null || 
            request.DocumentIds.Count == 0 || 
            request.DocumentIds.Count > 100)
        {
            return BadRequest("Invalid request");
        }

        // Only delete documents owned by current user
        var documentsToDelete = await _context.Documents
            .Where(d => request.DocumentIds.Contains(d.Id) 
                     && d.UserId == currentUserId) // Authorization check!
            .ToListAsync();

        _context.Documents.RemoveRange(documentsToDelete);
        await _context.SaveChangesAsync();

        return Ok(new BulkDeleteResponse
        {
            DeletedCount = documentsToDelete.Count,
            RequestedCount = request.DocumentIds.Count
        });
    }

    // Alternative with detailed feedback
    [HttpPost("bulk-delete-detailed")]
    [Authorize]
    public async Task<ActionResult<DetailedBulkDeleteResponse>> BulkDeleteDetailed(
        [FromBody] BulkDeleteRequest request)
    {
        var currentUserId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;

        var result = new DetailedBulkDeleteResponse
        {
            Deleted = new List<int>(),
            NotFound = new List<int>(),
            NotAuthorized = new List<int>()
        };

        foreach (var docId in request.DocumentIds.Take(100))
        {
            var document = await _context.Documents.FindAsync(docId);

            if (document == null)
            {
                result.NotFound.Add(docId);
                continue;
            }

            // Check ownership
            if (document.UserId != currentUserId)
            {
                result.NotAuthorized.Add(docId);
                continue;
            }

            _context.Documents.Remove(document);
            result.Deleted.Add(docId);
        }

        await _context.SaveChangesAsync();

        return Ok(result);
    }
}

public class BulkDeleteRequest
{
    public List<int> DocumentIds { get; set; }
}

public class BulkDeleteResponse
{
    public int DeletedCount { get; set; }
    public int RequestedCount { get; set; }
}

public class DetailedBulkDeleteResponse
{
    public List<int> Deleted { get; set; }
    public List<int> NotFound { get; set; }
    public List<int> NotAuthorized { get; set; }
}

Why this works: Bulk operations are secured by including the ownership filter (&& d.UserId == currentUserId) in the LINQ query used for deletion. Entity Framework only deletes documents that match both the ID list AND the ownership requirement, automatically excluding any documents the user doesn't own. This prevents attackers from deleting other users' documents by including unauthorized IDs in the batch. The rate limiting (maximum 100 items) prevents abuse, and returning the actual deleted count vs. requested count provides transparency without revealing which specific IDs were unauthorized. The alternative implementation with detailed feedback allows legitimate users to understand authorization failures while still preventing unauthorized bulk operations.

Remediation Steps

Locate the Finding

Review the security scan report to identify the data flow:

  • Source: User-controlled input containing resource ID
    • ASP.NET Core: [FromRoute] int orderId, [FromQuery] int userId, [FromBody] properties
    • Route parameters: {orderId}, {documentId}
  • Sink: Database query or resource access without authorization
    • _context.Orders.FindAsync(orderId)
    • _context.Documents.FirstOrDefaultAsync(d => d.Id == id)
    • LINQ queries without user ID filter
  • Missing Control: No comparison of entity.UserId with authenticated user

Understand the Data Flow

Trace how the user-controlled ID flows through the code:

  • Identify where the ID enters: route parameter, query string, request body
  • Follow the ID to the DbContext query or repository call
  • Check if any ownership validation exists
  • Verify if [Authorize] attribute is present (authentication)
  • Look for authorization checks comparing owner with current user
  • Check if User.Claims are being used to get authenticated user ID

Identify the Pattern

Match the vulnerable code to one of these patterns:

  • Direct FindAsync: FindAsync(id) → No ownership check
  • LINQ without user filter: Where(e => e.Id == id) → Missing && e.UserId == userId
  • Bulk operation: Where(e => ids.Contains(e.Id)) → No per-item authorization
  • Policy authorization only: [Authorize(Policy = "...")] → Checks role/claim, not ownership

Apply the Fix

Choose the appropriate remediation approach based on your architecture:

Option 1: Add ownership filter to LINQ query (Recommended - best performance)

  • Include ownership condition directly in the Entity Framework query
  • Pattern: Combine resource ID lookup with user ID filter in same WHERE clause
  • Use .Where(e => e.Id == id && e.UserId == currentUserId).FirstOrDefaultAsync()
  • Avoid bare FindAsync(id) or .FirstOrDefaultAsync(e => e.Id == id) without ownership check

Option 2: Use repository pattern with authorization (Best for reusability)

  • Create repository methods that enforce ownership filtering
  • Repository methods require both resource ID and current user ID as parameters
  • Encapsulates authorization logic in data access layer
  • Provides consistent enforcement across all controllers

Option 3: Use ASP.NET Core resource-based authorization (Framework approach)

  • Leverage IAuthorizationService with authorization handlers
  • Create handlers that check ownership of specific resource instances
  • Use AuthorizeAsync(User, resource, operation) before allowing access
  • Provides declarative authorization with built-in ASP.NET Core integration

Option 4: Service layer with user context (Centralized business logic)

  • Implement service methods that accept current user ID as parameter
  • Service layer queries database with ownership filters
  • Controllers extract user ID from claims and pass to service
  • Separates authorization from HTTP handling

See the Secure Patterns section for detailed implementation examples of each approach.

Verify the Fix

Test the remediation thoroughly:

  • Test authorized access: Verify legitimate users can access their own resources
  • Test unauthorized access: Confirm users cannot access others' resources (IDOR)
  • Test with malicious inputs:
    • Sequential IDs: Test IDs before and after legitimate resource
    • SQL injection: 1' OR '1'='1', 1; DROP TABLE Orders--
    • Negative IDs: -1, 0
    • Large numbers: 2147483647 (int.MaxValue)
  • Run automated tests: Execute unit/integration tests covering authorization scenarios
  • Rescan with security tool: Verify the finding is resolved
  • Manual testing: Use tools like Postman to test different user contexts

Check for Similar Issues

Search the codebase for related vulnerabilities:

# Find FindAsync calls
grep -r "FindAsync(" --include="*.cs"

# Find FirstOrDefaultAsync without user filter
grep -r "FirstOrDefaultAsync" --include="*.cs"

# Find route parameters with IDs
grep -r "\[HttpGet.*{.*Id.*}\]" --include="*.cs"

# Find [Authorize] without resource-based checks
grep -r "\[Authorize\]" --include="*.cs"

Review all endpoints that:

  • Accept user-controlled IDs in routes, query params, or request bodies
  • Perform database lookups based on those IDs
  • Update or delete resources
  • Return lists of resources that should be filtered by ownership

Security Checklist

Use this checklist to verify your authorization implementation is secure:

Authorization Implementation

  • All resource access endpoints verify ownership or permissions before returning data
  • LINQ queries include ownership filters (e.g., .Where(e => e.Id == id && e.UserId == currentUserId))
  • No direct FindAsync() or FirstOrDefaultAsync() without authorization checks
  • Authorization uses authenticated user from ClaimsPrincipal, never from request parameters
  • Bulk operations verify ownership for each item individually
  • File operations verify user owns the file before serving/deleting

Entity Framework Core Query Security

  • Use .Where(e => e.Id == id && e.UserId == userId) instead of bare FindAsync(id)
  • Repository methods accept userId parameter and filter by ownership
  • All list queries scoped to current user (no global queries)
  • Navigation properties don't inadvertently expose unauthorized related entities
  • Include statements don't bypass authorization for related data

ASP.NET Core Authorization

  • [Authorize] attribute present on all resource endpoints
  • User ID extracted from User.FindFirst(ClaimTypes.NameIdentifier) or similar
  • Resource-based authorization handlers implement ownership checks
  • Authorization policies use IAuthorizationService for object-level checks, not just roles
  • Shared access permissions checked if applicable (read/write/delete levels)

Controller & Service Layer

  • Controllers extract authenticated user ID from claims, not route/query parameters
  • Service methods require currentUserId or User parameter
  • DTOs don't expose internal IDs or sensitive ownership data unnecessarily
  • Input validation on all ID parameters
  • Authorization failures return 404 (not 403) to prevent information leakage

Response Security

  • Return 404 for both non-existent and unauthorized resources (don't leak existence)
  • No sensitive data in exception messages
  • Consistent response structure for authorized and unauthorized requests
  • Global exception handling for authorization exceptions

Testing Recommendations

  • Test that users can access their own resources
  • Test that users CANNOT access other users' resources (IDOR prevention)
  • Test sequential ID enumeration is blocked
  • Test unauthenticated requests are rejected (401)
  • Test resource-based authorization handlers work correctly
  • Test bulk operations only affect owned items
  • Test with malformed IDs (don't return 500 errors)
  • Test update/delete operations require ownership
  • Use GUID primary keys instead of sequential integers
  • Rate limiting on resource access endpoints
  • Audit logging for authorization failures
  • Anti-forgery tokens on state-changing operations
  • CORS configured properly for API endpoints

Code Review Focus Areas

  • Search for FindAsync, FirstOrDefaultAsync without ownership filters
  • Review all [HttpGet], [HttpPost], [HttpPut], [HttpDelete] with route parameters
  • Check [Authorize] attributes - should have resource-based authorization
  • Verify service methods accepting IDs also accept user ID parameter
  • Look for LINQ queries with ID filters but no user ID filters

Additional Resources