CWE-566: Authorization Bypass Through User-Controlled SQL Primary Key - C#
Overview
Authorization bypass through user-controlled keys, commonly known as Insecure Direct Object Reference (IDOR), happens when C# code uses a user-supplied identifier - an order ID, a user ID, a document ID - in a database query or in business logic without checking that the authenticated user is allowed to reach that resource. An attacker changes the request parameter and reads, modifies, or deletes another user's data: horizontal privilege escalation.
ASP.NET Core applications, particularly those using Entity Framework Core, are exposed to this when code reaches for convenience methods like FindAsync() or FirstOrDefaultAsync() with no ownership check alongside them. REST routing in .NET puts the resource ID straight in the URL (/api/orders/{orderId}), so every such route is a place the check has to be enforced at the data access layer.
ASP.NET Core Identity and the authorization middleware handle authentication, but they do NOT automatically enforce object-level authorization. The [Authorize] attribute is often taken as sufficient protection when it only confirms the user is logged in - not that they own the requested resource. That gap between authentication and authorization is where C# IDOR bugs come from. EF Core's navigation properties and lazy loading widen it, because a related entity can be loaded and returned without a check of its own.
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. Use 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 read any user's order by manipulating the orderId route parameter.
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. Any document can be retrieved if the attacker knows or guesses the ID, and the [Authorize] attribute above the action only confirms authentication, not authorization to access this specific document.
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 located by a caller-supplied row ID, with no ownership check
[ApiController]
[Route("api/[controller]")]
public class FilesController : ControllerBase
{
private readonly ApplicationDbContext _context;
public FilesController(ApplicationDbContext context) => _context = context;
[HttpGet("download/{fileId:int}")]
[Authorize]
public async Task<IActionResult> DownloadFile(int fileId)
{
// Primary key straight from the route - the row is fetched for
// whoever asks, and only then handed to the file system
var record = await _context.UploadedFiles.FindAsync(fileId);
if (record == null)
return NotFound();
// No ownership check - any authenticated user gets any file
var fileBytes = await System.IO.File.ReadAllBytesAsync(record.StoragePath);
return File(fileBytes, record.ContentType, record.OriginalName);
}
}
// Attack example:
// User uploads their invoice, which is stored as file 123
// Attacker requests: GET /api/files/download/124
// Attacker requests: GET /api/files/download/125
// Result: downloads every other user's uploads by walking the ID
Why this is vulnerable: FindAsync(fileId) looks the row up by primary key alone. The [Authorize] attribute establishes that the caller is signed in, not that this file is theirs, so the only thing standing between an attacker and the whole upload table is knowing that IDs are sequential. Fix it the same way as any other row - .FirstOrDefaultAsync(f => f.Id == fileId && f.UserId == currentUserId) - so the file system is only reached for a row the caller could already have.
Two related mistakes are not CWE-566 and need their own fix. Taking the filename from the request rather than from the row makes the endpoint a path-traversal read (CWE-22) regardless of the ownership check. And serving the upload directory as static content, or handing out long-lived pre-signed URLs, routes around this controller entirely - an ownership check here is correct and irrelevant if the bytes are reachable without it.
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), so an attacker who enumerates IDs can delete every document in the system, whoever owns it.
ASP.NET Core Authorization Policy Misconfiguration
// VULNERABLE - Policy only checks role, not ownership
// Program.cs (.NET 6+ minimal hosting)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthorization(options =>
{
// This only checks if user has "User" role
options.AddPolicy("UserPolicy", policy => policy.RequireRole("User"));
});
// OrdersController.cs
[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. ASP.NET Core's policy-based authorization provides role and claim checking; object-level authorization has to be implemented separately. Any user holding the role passes this check and can retrieve any order by manipulating the orderId parameter.
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;
public OrdersController(ApplicationDbContext context)
{
_context = context;
}
[HttpGet("{orderId}")]
[Authorize]
public async Task<ActionResult<OrderDto>> GetOrder(int orderId)
{
var currentUserId = User.GetUserId();
// 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 = User.GetUserId();
// 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);
}
}
// One guarded reader for the caller's identity, used by every controller
// below. ControllerBase already exposes the principal as User, so injecting
// IHttpContextAccessor to reach the same claim adds a dependency without
// adding a check. The ?? throw matters under nullable reference types:
// FindFirst(...)?.Value is string?, and a null flowing into an ownership
// query matches no rows, which reads as "not found" rather than as the
// configuration error it is. [Authorize] guarantees a principal, not a claim
public static class ClaimsPrincipalExtensions
{
public static string GetUserId(this ClaimsPrincipal user) =>
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 User.GetUserId() extension extracts the user ID from the authenticated claims (from JWT token or session), which cannot be manipulated by the client, and throws rather than returning null so a missing claim cannot silently become a query that matches nothing. Returning 404 for both non-existent and unauthorized resources prevents information leakage about which order IDs exist.
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.GetUserId();
// 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.GetUserId();
var deleted = await _documentRepository.DeleteAsync(documentId, currentUserId);
if (!deleted)
return NotFound();
return NoContent();
}
}
Why this works: The Repository pattern encapsulates data access and enforces authorization for callers that use these methods. Every repository method requires both the resource ID and the user ID as parameters, and every query carries the ownership filter, so these access paths cannot retrieve another user's resources and a controller has less opportunity to omit the check. Controllers pass the authenticated user ID from the claims to the repository, which keeps the authorization logic in one place rather than duplicated across controllers, and separates it 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) };
}
// Program.cs (.NET 6+ minimal hosting) - register the handler
builder.Services.AddSingleton<IAuthorizationHandler, DocumentAuthorizationHandler>();
// DocumentsController.cs
[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);
// 404, not Forbid() - a 403 here tells the caller the document
// exists, which is the enumeration the check was added to stop
if (!authResult.Succeeded)
return NotFound();
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 NotFound();
_context.Documents.Remove(document);
await _context.SaveChangesAsync();
return NoContent();
}
}
Why this works: ASP.NET Core's IAuthorizationService gives object-level authorization a framework-level home. The DocumentAuthorizationHandler holds the logic, checking whether the current user owns the document or has shared access, and it is handed the loaded document, so the decision can turn on the resource's own properties. AuthorizeAsync evaluates the requirement before the action returns anything, and the action answers 404 when it fails. Keeping the logic in a handler rather than in the controller makes it reusable wherever the same resource is reached, and testable on its own.
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.GetUserId();
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.GetUserId();
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. Because that parameter sits in the signature, authorization is explicit at every call site and enforcement is identical for every caller, whether that is a second controller or a later API version. The service methods return null or throw when authorization fails, leaving controllers to choose the response while the check itself stays 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.GetUserId();
// 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
// Version 4 GUIDs or other high-entropy IDs make enumeration computationally infeasible
Why this works: Version 4 GUIDs and other high-entropy opaque identifiers make sequential enumeration attacks computationally infeasible. Unlike auto-incrementing integer IDs (1, 2, 3...), attackers cannot guess valid random GUIDs through pattern matching or incrementing. However, GUIDs are NOT a security control by themselves - they only reduce brute-force enumeration risk. 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 - and when one of those leaks an ID, the ownership check is what still refuses the request.
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.GetUserId();
// Cap the batch size - a limit on blast radius, not rate limiting
if (request.DocumentIds is null or { 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 per-ID feedback
[HttpPost("bulk-delete-detailed")]
[Authorize]
public async Task<ActionResult<DetailedBulkDeleteResponse>> BulkDeleteDetailed(
[FromBody] BulkDeleteRequest request)
{
var currentUserId = User.GetUserId();
// Reject an over-length batch rather than silently truncating it -
// a caller that sends 150 IDs and is told nothing about the 50 that
// were dropped will read the response as "all handled"
if (request.DocumentIds is null or { Count: 0 } ||
request.DocumentIds.Count > 100)
{
return BadRequest("Invalid request");
}
var requestedIds = request.DocumentIds;
// Ownership is part of the query, so a document belonging to another
// user is never loaded and cannot be distinguished from a missing one
var owned = await _context.Documents
.Where(d => requestedIds.Contains(d.Id) && d.UserId == currentUserId)
.ToListAsync();
_context.Documents.RemoveRange(owned);
await _context.SaveChangesAsync();
var deletedIds = owned.Select(d => d.Id).ToHashSet();
return Ok(new DetailedBulkDeleteResponse
{
Deleted = deletedIds.ToList(),
// SECURE - a single "skipped" bucket. Separate "no such document"
// and "not yours" lists would turn one batch request into an
// existence oracle for the entire table
Skipped = requestedIds.Where(id => !deletedIds.Contains(id)).ToList()
});
}
}
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> Skipped { 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 batch-size cap (maximum 100 items) bounds how many rows one request can touch - it limits blast radius, and is not rate limiting, which belongs in front of the endpoint. Returning the deleted count against the requested count is safe because the difference does not say which IDs were rejected, or whether they existed. The per-ID variant is only safe because it reports one Skipped list: splitting that into NotFound and NotAuthorized would tell the caller which IDs exist, letting a single batch request enumerate the table that the ownership filter was added to protect.
Common Pitfalls
- Adding
[Authorize(Roles = "User")]and treating that as the ownership fix. A role check confirms the caller is authenticated as a user in that role, not that they're authorized for this specific resource - it's authentication dressed up as authorization. - Adding an EF Core global query filter (
HasQueryFilter) to scope entities by user or tenant, which is a strong centralized primary defense, but forgetting that any query calling.IgnoreQueryFilters()- common in admin tooling, background jobs, or a "temporary" debug endpoint - silently bypasses it with no compiler warning. - Applying an ownership check in the
GETaction of a controller but not in a pairedPUT/PATCH/DELETEaction added later, or in a nested route (/api/orders/{orderId}/items/{itemId}) that loads the child entity directly without re-verifying the parent order's ownership. - Registering a resource-based
IAuthorizationHandlerfor the main MVC controller but not for a separate OAuth/OIDC external-login callback action, a minimal API endpoint, or a Razor Page handler that queries the sameDbContextthrough a different code path.
Testing
Authorization bugs are invisible to a scanner, because only the application knows which user is supposed to own which record. Every test below therefore needs at least two users and a resource belonging to one of them:
- A user can read, update and delete their own resource.
- User B's request for user A's resource ID is indistinguishable from a request for an ID that was never issued: same status, same body, no timing tell. What the fix has to remove is the difference between the two, so assert on the pair rather than on either one alone.
- 404 for both is the default and the easiest to keep true. A uniform 403 also passes, provided nothing in the handler answers 403 only when the record happens to exist - which is the usual way this regresses.
- Sequential IDs around a known-good one are not reachable.
- Bulk operations submitted with a mix of owned and unowned IDs affect only the owned ones, and do not partially apply before failing.
- Unauthenticated requests are rejected with 401.
- Malformed IDs (non-numeric, negative, oversized, null) return 400, not 500. A 500 usually means the ID reached EF Core before any check.
- Resource-based authorization handlers are actually invoked. An
[Authorize]attribute without a requirement only proves authentication, so assert the denial, not just the allow. - Where sharing exists, a user granted read access cannot write.
Run these against the service layer as well as the controller. A check that lives only in the controller is bypassed by any other caller of the service.
Additional Resources
- ASP.NET Core Security Documentation
- CWE-566: Authorization Bypass Through User-Controlled SQL Primary Key
- Entity Framework Core Documentation
- OWASP API Security Top 10 - API1:2023 Broken Object Level Authorization
- OWASP IDOR Prevention Cheat Sheet
- OWASP Top 10 2025 A01: Broken Access Control
- Resource-based authorization in ASP.NET Core