CWE-566: Authorization Bypass Through User-Controlled Key - Java
Overview
Authorization bypass through user-controlled keys, commonly known as Insecure Direct Object Reference (IDOR), is a critical vulnerability in Java 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.
Java enterprise applications, particularly those built with Spring Boot, Jakarta EE, and JPA/Hibernate, are susceptible to IDOR vulnerabilities when developers rely on framework convenience methods like findById() or getOne() without implementing proper authorization checks. The object-oriented nature of Java and the prevalence of RESTful API patterns (where resource IDs appear directly in URLs like /api/orders/{orderId}) create numerous opportunities for this vulnerability if authorization is not consistently enforced.
Spring Security provides robust authentication mechanisms, but it does NOT automatically enforce object-level authorization. Many developers mistakenly believe that @PreAuthorize("isAuthenticated()") 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 Java IDOR vulnerabilities. Additionally, JPA's lazy loading and entity relationships can inadvertently expose related entities without authorization checks.
Real-world Java IDOR vulnerabilities have resulted in significant data breaches, including exposure of financial records, healthcare information, and customer data. E-commerce platforms, banking applications, and healthcare systems built with Spring Boot have been compromised through sequential ID enumeration and parameter manipulation. Given Java's dominance in enterprise applications handling sensitive data, implementing proper object-level authorization is business-critical.
Primary Defence: Create custom repository methods that enforce ownership, such as findByIdAndUserId(Long id, Long userId), and always pass the authenticated user's ID from the security context. For JPQL queries, include ownership in the WHERE clause: WHERE e.id = :id AND e.userId = :userId. Never use bare findById() or entityManager.find() without subsequent authorization checks. Use Spring Security's @PreAuthorize with custom SpEL expressions to verify ownership before method execution, not just authentication status.
Common Vulnerable Patterns
Spring Boot Controller Without Authorization Check
// VULNERABLE - No ownership verification in Spring REST Controller
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@Autowired
private OrderRepository orderRepository;
@GetMapping("/{orderId}")
public ResponseEntity<OrderDTO> getOrder(@PathVariable Long orderId) {
// Directly retrieves ANY order by ID - no authorization!
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new ResourceNotFoundException("Order not found"));
// Returns order details regardless of who owns it
return ResponseEntity.ok(toDTO(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
JPA Repository Query Without Owner Filter
// VULNERABLE - Repository method without ownership filter
@Repository
public interface DocumentRepository extends JpaRepository<Document, Long> {
// This method has NO authorization built in
Optional<Document> findById(Long id);
}
@Service
public class DocumentService {
@Autowired
private DocumentRepository documentRepository;
public Document getDocument(Long documentId) {
// No check if current user owns this document
return documentRepository.findById(documentId)
.orElseThrow(() -> new ResourceNotFoundException("Document not found"));
}
}
// Attack example:
// Any authenticated user can call this service method with ANY document ID
// Result: Horizontal privilege escalation - access to all documents
JPQL Query Without Security Constraints
// VULNERABLE - JPQL query uses user input directly
@Service
public class UserService {
@PersistenceContext
private EntityManager entityManager;
public User getUserProfile(Long userId) {
// User ID comes from request parameter - no verification!
String jpql = "SELECT u FROM User u WHERE u.id = :userId";
return entityManager.createQuery(jpql, User.class)
.setParameter("userId", userId)
.getSingleResult();
}
}
@RestController
public class ProfileController {
@Autowired
private UserService userService;
@GetMapping("/api/users/{userId}/profile")
public UserProfile getProfile(@PathVariable Long userId) {
// No check if current user can access this profile
User user = userService.getUserProfile(userId);
return new UserProfile(
user.getId(),
user.getEmail(), // PII exposure!
user.getPhoneNumber(),
user.getSsn(), // Critical data leak!
user.getCreditCard() // Payment info exposed!
);
}
}
// 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
File Download Without Authorization
// VULNERABLE - File download using user-provided filename
@RestController
public class FileController {
private static final String UPLOAD_DIR = "/var/uploads/";
@GetMapping("/download/{filename}")
public ResponseEntity<Resource> downloadFile(@PathVariable String filename) {
try {
Path filePath = Paths.get(UPLOAD_DIR).resolve(filename);
Resource resource = new UrlResource(filePath.toUri());
if (!resource.exists()) {
throw new ResourceNotFoundException("File not found");
}
// No ownership check - anyone can download any file!
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + resource.getFilename() + "\"")
.body(resource);
} catch (MalformedURLException e) {
throw new RuntimeException("Error reading file");
}
}
}
// 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/../../etc/passwd
// Result: Downloads other users' files or system files!
Batch Operations Without Per-Item Authorization
// VULNERABLE - Bulk delete without individual authorization checks
@RestController
@RequestMapping("/api/documents")
public class DocumentController {
@Autowired
private DocumentRepository documentRepository;
@DeleteMapping("/bulk-delete")
public ResponseEntity<BulkDeleteResponse> bulkDelete(
@RequestBody BulkDeleteRequest request) {
List<Long> documentIds = request.getDocumentIds();
// Deletes ALL specified documents without ownership verification!
documentRepository.deleteAllById(documentIds);
return ResponseEntity.ok(
new BulkDeleteResponse(documentIds.size(), "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
Spring Data JPA Method Security Misconfiguration
// VULNERABLE - Method security only checks authentication, not authorization
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
// This only checks if user is authenticated, NOT if they own the order!
@PreAuthorize("isAuthenticated()")
public Order getOrder(Long orderId) {
return orderRepository.findById(orderId)
.orElseThrow(() -> new ResourceNotFoundException("Order not found"));
}
// Attacker is authenticated, so this check passes
// But they can access ANY order by changing the ID parameter
}
// Attack example:
// User logs in → Authentication successful
// User requests: GET /api/orders/999 (belongs to another user)
// @PreAuthorize passes because user IS authenticated
// Result: Returns another user's order details
Why this is vulnerable: The @PreAuthorize("isAuthenticated()") annotation only verifies that the user is logged in, not that they own the requested order. This is a common misconception - Spring Security's method security provides authentication checks, but object-level authorization must be implemented separately. Any authenticated user can pass this check and retrieve any order by manipulating the orderId parameter, enabling complete horizontal privilege escalation across all user data.
Spring Annotations Demonstrating Vulnerable vs Secure Patterns
// VULNERABLE - Various annotation misconfigurations
@Service
public class VulnerableOrderService {
@Autowired
private OrderRepository orderRepository;
// VULNERABLE - Only checks authentication
@PreAuthorize("isAuthenticated()")
public Order getOrder(Long orderId) {
return orderRepository.findById(orderId).orElseThrow();
// Any authenticated user can access ANY order!
}
// VULNERABLE - Role check without ownership verification
@PreAuthorize("hasRole('USER')")
public Order updateOrder(Long orderId, OrderUpdate update) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.setStatus(update.getStatus());
return orderRepository.save(order);
// Any user with USER role can modify ANY order!
}
// VULNERABLE - @PostAuthorize checks AFTER data is loaded
@PostAuthorize("returnObject.userId == authentication.principal.userId")
public Order getOrderPostAuth(Long orderId) {
// Order is ALREADY loaded from database before check
// Inefficient and data already in memory
return orderRepository.findById(orderId).orElseThrow();
}
}
// SECURE - Proper authorization with Spring annotations
@Service
public class SecureOrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private OrderSecurityService orderSecurityService;
// SECURE - SpEL expression verifies ownership BEFORE execution
@PreAuthorize("@orderSecurityService.isOwner(#orderId, authentication.principal.userId)")
public Order getOrder(Long orderId) {
// This only executes if user owns the order
return orderRepository.findById(orderId).orElseThrow();
}
// SECURE - Multiple conditions - role AND ownership
@PreAuthorize("hasRole('USER') and @orderSecurityService.isOwner(#orderId, authentication.principal.userId)")
public Order updateOrder(Long orderId, OrderUpdate update) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.setStatus(update.getStatus());
return orderRepository.save(order);
}
// SECURE - Delete requires ownership verification
@PreAuthorize("@orderSecurityService.canDelete(#orderId, authentication.principal.userId)")
public void deleteOrder(Long orderId) {
orderRepository.deleteById(orderId);
}
// SECURE - Custom permission check for shared resources
@PreAuthorize("@orderSecurityService.hasPermission(#orderId, authentication.principal.userId, 'READ')")
public Order getSharedOrder(Long orderId) {
return orderRepository.findById(orderId).orElseThrow();
}
}
// Security service for @PreAuthorize expressions
@Service("orderSecurityService")
public class OrderSecurityService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private OrderShareRepository shareRepository;
/**
* Check if user owns the order
*/
public boolean isOwner(Long orderId, Long userId) {
return orderRepository.findById(orderId)
.map(order -> order.getUserId().equals(userId))
.orElse(false);
}
/**
* Check if user can delete (owner only)
*/
public boolean canDelete(Long orderId, Long userId) {
return isOwner(orderId, userId);
}
/**
* Check if user has specific permission (owner or shared)
*/
public boolean hasPermission(Long orderId, Long userId, String permission) {
Optional<Order> order = orderRepository.findById(orderId);
if (order.isEmpty()) {
return false;
}
// Owner has all permissions
if (order.get().getUserId().equals(userId)) {
return true;
}
// Check shared permissions
if ("READ".equals(permission)) {
return shareRepository.existsByOrderIdAndUserIdAndCanRead(
orderId, userId, true
);
} else if ("WRITE".equals(permission)) {
return shareRepository.existsByOrderIdAndUserIdAndCanWrite(
orderId, userId, true
);
}
return false;
}
}
// Controller using secure service
@RestController
@RequestMapping("/api/orders")
public class SecureOrderController {
@Autowired
private SecureOrderService orderService;
@GetMapping("/{orderId}")
public ResponseEntity<OrderDTO> getOrder(@PathVariable Long orderId) {
// Service layer enforces authorization via @PreAuthorize
Order order = orderService.getOrder(orderId);
return ResponseEntity.ok(OrderDTO.fromEntity(order));
}
@PutMapping("/{orderId}")
public ResponseEntity<OrderDTO> updateOrder(
@PathVariable Long orderId,
@RequestBody OrderUpdate update) {
// Authorization checked in service layer
Order order = orderService.updateOrder(orderId, update);
return ResponseEntity.ok(OrderDTO.fromEntity(order));
}
@DeleteMapping("/{orderId}")
public ResponseEntity<Void> deleteOrder(@PathVariable Long orderId) {
// Authorization checked in service layer
orderService.deleteOrder(orderId);
return ResponseEntity.noContent().build();
}
}
Why the secure pattern works: The @PreAuthorize annotation with SpEL (Spring Expression Language) expressions allows declarative authorization that executes BEFORE the method runs. The expression @orderSecurityService.isOwner(#orderId, authentication.principal.userId) automatically extracts the current user ID from the security context and calls the security service to verify ownership. If the check fails, Spring Security throws AccessDeniedException and the method never executes, preventing any unauthorized data access. The #orderId syntax references the method parameter, ensuring the authorization check uses the actual requested resource ID. This pattern separates authorization logic into a dedicated service, making it reusable, testable, and easy to audit.
Secure Patterns
Repository Method with Ownership Filter (Primary Pattern)
// SECURE - Repository method enforces ownership
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
// Custom query method that includes ownership check
Optional<Order> findByIdAndUserId(Long id, Long userId);
// Get all orders for a specific user
List<Order> findByUserId(Long userId);
}
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@Autowired
private OrderRepository orderRepository;
@GetMapping("/{orderId}")
public ResponseEntity<OrderDTO> getOrder(
@PathVariable Long orderId,
@AuthenticationPrincipal UserDetails currentUser) {
Long userId = getUserId(currentUser);
// Query filters by BOTH id AND user ownership
Order order = orderRepository.findByIdAndUserId(orderId, userId)
.orElseThrow(() -> new ResourceNotFoundException("Order not found"));
return ResponseEntity.ok(toDTO(order));
}
@GetMapping
public ResponseEntity<List<OrderDTO>> getAllOrders(
@AuthenticationPrincipal UserDetails currentUser) {
Long userId = getUserId(currentUser);
// Only returns orders belonging to current user
List<Order> orders = orderRepository.findByUserId(userId);
return ResponseEntity.ok(
orders.stream()
.map(this::toDTO)
.collect(Collectors.toList())
);
}
private Long getUserId(UserDetails userDetails) {
return ((CustomUserDetails) userDetails).getUserId();
}
}
Why this works: The custom repository method findByIdAndUserId combines the resource ID lookup with the ownership check in a single database query. By including userId in the WHERE clause alongside id, the database ensures that only resources belonging to the authenticated user can be retrieved. Even if an attacker knows another user's order ID, the query returns empty because the ownership constraint is not satisfied. This prevents horizontal privilege escalation at the database layer before the data ever reaches the application code. The pattern is efficient (single query), secure (database-enforced), and prevents accidentally forgetting authorization checks.
Explicit Authorization Service with Reusable Logic
// SECURE - Dedicated authorization service
@Service
public class AuthorizationService {
@Autowired
private DocumentRepository documentRepository;
@Autowired
private DocumentShareRepository shareRepository;
/**
* Verify user can access document with specified permission level
*
* @param documentId Document to access
* @param userId Current user
* @param permission Required permission (READ, WRITE, DELETE)
* @throws AccessDeniedException if user lacks permission
* @return The authorized document
*/
public Document authorizeDocumentAccess(
Long documentId,
Long userId,
DocumentPermission permission) {
Document document = documentRepository.findById(documentId)
.orElseThrow(() -> new ResourceNotFoundException("Document not found"));
// Check ownership first
if (document.getOwnerId().equals(userId)) {
return document; // Owner has all permissions
}
// Check shared access
Optional<DocumentShare> share = shareRepository
.findByDocumentIdAndUserId(documentId, userId);
if (share.isEmpty()) {
throw new AccessDeniedException("You don't have access to this document");
}
// Verify permission level
DocumentShare access = share.get();
switch (permission) {
case READ:
if (!access.isCanRead()) {
throw new AccessDeniedException("Read permission denied");
}
break;
case WRITE:
if (!access.isCanWrite()) {
throw new AccessDeniedException("Write permission denied");
}
break;
case DELETE:
if (!access.isCanDelete()) {
throw new AccessDeniedException("Delete permission denied");
}
break;
}
return document;
}
}
@RestController
@RequestMapping("/api/documents")
public class DocumentController {
@Autowired
private AuthorizationService authorizationService;
@Autowired
private DocumentRepository documentRepository;
@GetMapping("/{documentId}")
public ResponseEntity<DocumentDTO> getDocument(
@PathVariable Long documentId,
@AuthenticationPrincipal UserDetails currentUser) {
Long userId = getUserId(currentUser);
// Authorization check before accessing document
Document document = authorizationService.authorizeDocumentAccess(
documentId, userId, DocumentPermission.READ
);
return ResponseEntity.ok(toDTO(document));
}
@DeleteMapping("/{documentId}")
public ResponseEntity<Void> deleteDocument(
@PathVariable Long documentId,
@AuthenticationPrincipal UserDetails currentUser) {
Long userId = getUserId(currentUser);
// Requires DELETE permission
Document document = authorizationService.authorizeDocumentAccess(
documentId, userId, DocumentPermission.DELETE
);
documentRepository.delete(document);
return ResponseEntity.noContent().build();
}
}
enum DocumentPermission {
READ, WRITE, DELETE
}
Why this works: Centralizing authorization logic in a dedicated service ensures consistent enforcement across all endpoints and prevents developers from forgetting authorization checks in new controllers. The service performs comprehensive verification including ownership checks, shared access permissions, and fine-grained permission levels before returning the authorized resource. By throwing AccessDeniedException when authorization fails, it prevents the endpoint from ever receiving unauthorized data. The separation of concerns makes the codebase easier to audit, test, and maintain. Fine-grained permissions (read/write/delete) prevent users with read-only shared access from modifying resources they don't own.
Spring Security SpEL with Method-Level Authorization
// SECURE - Spring Security Expression Language for authorization
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
// SpEL expression verifies ownership BEFORE method executes
@PreAuthorize("@orderSecurityService.isOrderOwner(#orderId, authentication.principal.userId)")
public Order getOrder(Long orderId) {
return orderRepository.findById(orderId)
.orElseThrow(() -> new ResourceNotFoundException("Order not found"));
}
@PreAuthorize("@orderSecurityService.isOrderOwner(#orderId, authentication.principal.userId)")
public void deleteOrder(Long orderId) {
orderRepository.deleteById(orderId);
}
}
@Service("orderSecurityService")
public class OrderSecurityService {
@Autowired
private OrderRepository orderRepository;
/**
* Check if user owns the order
* Called by Spring Security before method execution
*/
public boolean isOrderOwner(Long orderId, Long userId) {
return orderRepository.findById(orderId)
.map(order -> order.getUserId().equals(userId))
.orElse(false); // Non-existent orders return false
}
/**
* Check if user can access order (owner or shared)
*/
public boolean canAccessOrder(Long orderId, Long userId) {
Optional<Order> order = orderRepository.findById(orderId);
if (order.isEmpty()) {
return false;
}
// Check ownership
if (order.get().getUserId().equals(userId)) {
return true;
}
// Check shared access (if applicable)
// return shareRepository.existsByOrderIdAndUserId(orderId, userId);
return false;
}
}
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// Spring Security configuration
}
Why this works: Spring Security's @PreAuthorize annotation with SpEL (Spring Expression Language) provides declarative authorization that executes before the method runs. The expression @orderSecurityService.isOrderOwner(#orderId, authentication.principal.userId) automatically extracts the current user ID from the security context and verifies ownership before allowing method execution. If the check fails, Spring Security throws AccessDeniedException and the method never executes, preventing unauthorized access at the framework level. This pattern keeps authorization logic separate from business logic, makes authorization visible and auditable in method signatures, and ensures authorization cannot be bypassed because it's enforced by the Spring Security infrastructure.
Custom Aspect for Cross-Cutting Authorization
// SECURE - AOP aspect for automatic authorization enforcement
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AuthorizeOwnership {
Class<?> entityClass();
String idParam() default "id";
String ownerField() default "userId";
}
@Aspect
@Component
public class AuthorizationAspect {
@Autowired
private EntityManager entityManager;
@Around("@annotation(authorizeOwnership)")
public Object checkOwnership(
ProceedingJoinPoint joinPoint,
AuthorizeOwnership authorizeOwnership) throws Throwable {
// Get current user from security context
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Long currentUserId = ((CustomUserDetails) authentication.getPrincipal()).getUserId();
// Extract entity ID from method parameters
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String[] paramNames = signature.getParameterNames();
Object[] paramValues = joinPoint.getArgs();
Long entityId = null;
for (int i = 0; i < paramNames.length; i++) {
if (paramNames[i].equals(authorizeOwnership.idParam())) {
entityId = (Long) paramValues[i];
break;
}
}
if (entityId == null) {
throw new IllegalArgumentException("Entity ID parameter not found");
}
// Fetch entity and verify ownership
Object entity = entityManager.find(authorizeOwnership.entityClass(), entityId);
if (entity == null) {
throw new ResourceNotFoundException("Resource not found");
}
// Use reflection to get owner field
String ownerFieldName = authorizeOwnership.ownerField();
Field ownerField = authorizeOwnership.entityClass().getDeclaredField(ownerFieldName);
ownerField.setAccessible(true);
Long ownerId = (Long) ownerField.get(entity);
if (!ownerId.equals(currentUserId)) {
throw new AccessDeniedException("You don't own this resource");
}
// Authorization passed, proceed with method execution
return joinPoint.proceed();
}
}
@Service
public class DocumentService {
@Autowired
private DocumentRepository documentRepository;
@AuthorizeOwnership(entityClass = Document.class, idParam = "documentId")
public Document getDocument(Long documentId) {
// Authorization already verified by aspect
return documentRepository.findById(documentId).orElseThrow();
}
@AuthorizeOwnership(entityClass = Document.class, idParam = "documentId")
public void deleteDocument(Long documentId) {
// Authorization already verified by aspect
documentRepository.deleteById(documentId);
}
}
Why this works: Aspect-Oriented Programming (AOP) allows authorization logic to be applied automatically to any method annotated with @AuthorizeOwnership, ensuring developers cannot forget authorization checks. The aspect intercepts method calls before execution, extracts the entity ID from method parameters, fetches the entity, and verifies that the current user owns it. If authorization fails, an exception is thrown and the method never executes. This pattern eliminates code duplication, provides consistent authorization enforcement across the entire application, and makes authorization requirements explicit through annotation metadata. The aspect can be easily extended to support different permission models or custom authorization logic.
Using UUIDs with Authorization (Defense in Depth)
// SECURE - UUID primary keys + authorization checks
@Entity
@Table(name = "documents")
public class Document {
@Id
@GeneratedValue(generator = "UUID")
@GenericGenerator(name = "UUID", strategy = "org.hibernate.id.UUIDGenerator")
@Column(updatable = false, nullable = false, columnDefinition = "VARCHAR(36)")
private UUID id; // UUID instead of Long
@Column(nullable = false)
private Long userId;
private String title;
private String content;
// Getters and setters
}
@Repository
public interface DocumentRepository extends JpaRepository<Document, UUID> {
// Custom method with ownership filter
Optional<Document> findByIdAndUserId(UUID id, Long userId);
List<Document> findByUserId(Long userId);
}
@RestController
@RequestMapping("/api/documents")
public class DocumentController {
@Autowired
private DocumentRepository documentRepository;
@GetMapping("/{documentId}")
public ResponseEntity<DocumentDTO> getDocument(
@PathVariable UUID documentId,
@AuthenticationPrincipal UserDetails currentUser) {
Long userId = getUserId(currentUser);
// UUIDs prevent enumeration, but STILL need authorization!
Document document = documentRepository
.findByIdAndUserId(documentId, userId)
.orElseThrow(() -> new ResourceNotFoundException("Document not found"));
return ResponseEntity.ok(toDTO(document));
}
}
// Example document IDs:
// Instead of: /api/documents/1, /api/documents/2, /api/documents/3
// Use: /api/documents/550e8400-e29b-41d4-a716-446655440000
// UUIDs are cryptographically random - enumeration is computationally infeasible
Why this works: UUIDs (Universally Unique Identifiers) are 128-bit values with approximately 2^122 possible unique combinations, making sequential enumeration attacks computationally infeasible. Unlike auto-incrementing Long IDs (1, 2, 3...), attackers cannot guess valid UUIDs through pattern matching or incrementing. However, UUIDs are NOT a security control by themselves - they only prevent brute-force enumeration. The code still includes explicit authorization checks (findByIdAndUserId) because UUIDs can be exposed through logs, shared URLs, browser history, API responses, or social engineering. This defense-in-depth approach combines obscurity (UUIDs make guessing hard) with proper access control (ownership verification prevents access even with known IDs) for maximum security.
JPQL with Built-In Security Filter
// SECURE - JPQL query with ownership filter
@Service
public class OrderService {
@PersistenceContext
private EntityManager entityManager;
public Order getOrderForCurrentUser(Long orderId, Long currentUserId) {
// JPQL includes ownership filter in WHERE clause
String jpql = "SELECT o FROM Order o WHERE o.id = :orderId AND o.userId = :userId";
try {
return entityManager.createQuery(jpql, Order.class)
.setParameter("orderId", orderId)
.setParameter("userId", currentUserId) // Ownership check!
.getSingleResult();
} catch (NoResultException e) {
throw new ResourceNotFoundException("Order not found");
}
}
public List<Order> getAllOrdersForCurrentUser(Long currentUserId) {
// Query scoped to current user's orders only
String jpql = "SELECT o FROM Order o WHERE o.userId = :userId ORDER BY o.createdAt DESC";
return entityManager.createQuery(jpql, Order.class)
.setParameter("userId", currentUserId)
.getResultList();
}
}
Why this works: By including the ownership check (o.userId = :userId) directly in the JPQL WHERE clause, the authorization is enforced at the database level. The database engine will only return rows where both the order ID matches AND the user ID matches the current user. This prevents the JPA layer from ever loading unauthorized data into memory. Even if an attacker manipulates the orderId parameter, they cannot bypass the userId filter because it comes from the authenticated security context, not user input. The single database query is efficient, and the authorization cannot be forgotten or bypassed because it's integral to the data retrieval logic.
Framework-Specific Guidance
Spring Boot with Spring Security
Spring Boot applications should leverage Spring Security's authentication infrastructure combined with custom authorization:
// SECURE - Complete Spring Boot authorization example
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
// Custom UserDetails with userId
public class CustomUserDetails implements UserDetails {
private Long userId;
private String username;
private String password;
private Collection<? extends GrantedAuthority> authorities;
// Constructor, getters
public Long getUserId() {
return userId;
}
}
// Base controller with user extraction
@RestController
public abstract class BaseController {
protected Long getCurrentUserId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !(auth.getPrincipal() instanceof CustomUserDetails)) {
throw new AccessDeniedException("Not authenticated");
}
return ((CustomUserDetails) auth.getPrincipal()).getUserId();
}
protected CustomUserDetails getCurrentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return (CustomUserDetails) auth.getPrincipal();
}
}
// Repository with security methods
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o FROM Order o WHERE o.id = :orderId AND o.userId = :userId")
Optional<Order> findByIdAndUserId(@Param("orderId") Long orderId, @Param("userId") Long userId);
@Query("SELECT o FROM Order o WHERE o.userId = :userId")
List<Order> findAllByUserId(@Param("userId") Long userId);
@Query("SELECT COUNT(o) > 0 FROM Order o WHERE o.id = :orderId AND o.userId = :userId")
boolean existsByIdAndUserId(@Param("orderId") Long orderId, @Param("userId") Long userId);
}
// Service with authorization
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
public Order getOrder(Long orderId, Long currentUserId) {
return orderRepository.findByIdAndUserId(orderId, currentUserId)
.orElseThrow(() -> new ResourceNotFoundException("Order not found"));
}
public List<Order> getUserOrders(Long currentUserId) {
return orderRepository.findAllByUserId(currentUserId);
}
@Transactional
public Order updateOrder(Long orderId, Long currentUserId, OrderUpdateRequest request) {
Order order = orderRepository.findByIdAndUserId(orderId, currentUserId)
.orElseThrow(() -> new ResourceNotFoundException("Order not found"));
// Update fields
order.setShippingAddress(request.getShippingAddress());
order.setNotes(request.getNotes());
return orderRepository.save(order);
}
@Transactional
public void deleteOrder(Long orderId, Long currentUserId) {
// Verify ownership before deleting
if (!orderRepository.existsByIdAndUserId(orderId, currentUserId)) {
throw new ResourceNotFoundException("Order not found");
}
orderRepository.deleteById(orderId);
}
}
// Controller
@RestController
@RequestMapping("/api/orders")
public class OrderController extends BaseController {
@Autowired
private OrderService orderService;
@GetMapping("/{orderId}")
public ResponseEntity<OrderDTO> getOrder(@PathVariable Long orderId) {
Long userId = getCurrentUserId();
Order order = orderService.getOrder(orderId, userId);
return ResponseEntity.ok(OrderDTO.fromEntity(order));
}
@GetMapping
public ResponseEntity<List<OrderDTO>> getAllOrders() {
Long userId = getCurrentUserId();
List<Order> orders = orderService.getUserOrders(userId);
return ResponseEntity.ok(
orders.stream()
.map(OrderDTO::fromEntity)
.collect(Collectors.toList())
);
}
@PutMapping("/{orderId}")
public ResponseEntity<OrderDTO> updateOrder(
@PathVariable Long orderId,
@Valid @RequestBody OrderUpdateRequest request) {
Long userId = getCurrentUserId();
Order order = orderService.updateOrder(orderId, userId, request);
return ResponseEntity.ok(OrderDTO.fromEntity(order));
}
@DeleteMapping("/{orderId}")
public ResponseEntity<Void> deleteOrder(@PathVariable Long orderId) {
Long userId = getCurrentUserId();
orderService.deleteOrder(orderId, userId);
return ResponseEntity.noContent().build();
}
}
Why this works: This comprehensive Spring Boot pattern combines multiple security layers: Spring Security handles authentication and extracts the user ID into the security context, repository methods enforce ownership at the database layer, service methods validate authorization before operations, and controllers use the authenticated user from the security context (never from request parameters). By consistently passing currentUserId from the security context to all service and repository methods, user-controlled IDs cannot bypass authorization. The layered approach provides defense-in-depth where even if one layer is bypassed, others prevent unauthorized access.
Jakarta EE with CDI and Security Interceptors
// SECURE - Jakarta EE authorization with interceptors
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
@InterceptorBinding
public @interface Secured {
ResourceType value();
}
public enum ResourceType {
DOCUMENT, ORDER, USER_PROFILE
}
@Interceptor
@Secured(ResourceType.DOCUMENT)
@Priority(Interceptor.Priority.APPLICATION)
public class DocumentSecurityInterceptor {
@Inject
private Principal principal;
@PersistenceContext
private EntityManager em;
@AroundInvoke
public Object checkAccess(InvocationContext context) throws Exception {
// Extract document ID from method parameters
Object[] params = context.getParameters();
Long documentId = null;
for (Object param : params) {
if (param instanceof Long) {
documentId = (Long) param;
break;
}
}
if (documentId == null) {
throw new IllegalArgumentException("Document ID not found");
}
// Get current user ID
Long userId = Long.parseLong(principal.getName());
// Verify ownership
Document doc = em.find(Document.class, documentId);
if (doc == null) {
throw new NotFoundException("Document not found");
}
if (!doc.getUserId().equals(userId)) {
throw new ForbiddenException("Access denied");
}
// Authorization passed
return context.proceed();
}
}
@Stateless
public class DocumentService {
@PersistenceContext
private EntityManager em;
@Secured(ResourceType.DOCUMENT)
public Document getDocument(Long documentId) {
// Interceptor already verified authorization
return em.find(Document.class, documentId);
}
@Secured(ResourceType.DOCUMENT)
public void deleteDocument(Long documentId) {
// Interceptor already verified authorization
Document doc = em.find(Document.class, documentId);
em.remove(doc);
}
}
Why this works: Jakarta EE interceptors provide automatic authorization enforcement for any method annotated with @Secured. The interceptor runs before the method executes, verifies ownership, and either allows execution or throws an exception. This prevents developers from forgetting authorization checks and centralizes the logic for easy auditing and maintenance.
Remediation Steps
Locate the Finding
Review the security scan report to identify the data flow:
- Source: User-controlled input containing resource ID
- Spring:
@PathVariable Long orderId,@RequestParam Long userId - JAX-RS:
@PathParam("orderId") Long orderId - Servlet:
request.getParameter("documentId")
- Spring:
- Sink: Database query or resource access without authorization
repository.findById(orderId)entityManager.find(Order.class, orderId)em.createQuery("SELECT o FROM Order o WHERE o.id = :id")
- Missing Control: No comparison of
entity.getUserId()with authenticated user
Understand the Data Flow
Trace how the user-controlled ID flows through the code:
- Identify where the ID enters: URL path parameter, query parameter, request body
- Follow the ID to the repository/DAO call
- Check if any ownership validation exists
- Verify if authentication is required (
@PreAuthorize,@RolesAllowed) - Look for authorization checks comparing owner with current user
Identify the Pattern
Match the vulnerable code to one of these patterns:
- Direct repository lookup:
repository.findById(id)→ No ownership check - JPQL without filter:
SELECT e FROM Entity e WHERE e.id = :id→ MissingAND e.userId = :userId - Bulk operation:
repository.deleteAllById(ids)→ No per-item authorization - Method security only:
@PreAuthorize("isAuthenticated()")→ Checks authentication, not authorization
Apply the Fix
Follow this priority order:
PRIORITY 1: Add custom repository method with ownership (best performance)
// Before
Optional<Order> findById(Long id);
// After
Optional<Order> findByIdAndUserId(Long id, Long userId);
PRIORITY 2: Use Spring Security SpEL (declarative authorization)
@PreAuthorize("@orderSecurityService.isOrderOwner(#orderId, authentication.principal.userId)")
public Order getOrder(Long orderId) {
return orderRepository.findById(orderId).orElseThrow();
}
PRIORITY 3: Explicit authorization service (centralized logic)
PRIORITY 4: Custom AOP aspect (automatic enforcement)
@AuthorizeOwnership(entityClass = Order.class, idParam = "orderId")
public Order getOrder(Long orderId) {
return orderRepository.findById(orderId).orElseThrow();
}
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:
999999999
- Run automated tests: Execute JUnit tests covering authorization scenarios
- Rescan with security tool: Verify the finding is resolved
- Manual testing: Use tools like Postman or curl to test different user contexts
Check for Similar Issues
Search the codebase for related vulnerabilities:
# Find repository.findById calls
grep -r "findById(" --include="*.java"
# Find EntityManager.find calls
grep -r "\.find(.*\.class" --include="*.java"
# Find @PathVariable with ID
grep -r "@PathVariable.*[Ii]d" --include="*.java"
# Find JPQL queries with ID parameters
grep -r "WHERE.*\.id.*:id" --include="*.java"
# Find @PreAuthorize without custom authorization
grep -r '@PreAuthorize("isAuthenticated()' --include="*.java"
Review all endpoints that:
- Accept user-controlled IDs in URLs, 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
- Repository methods include ownership in query (e.g.,
findByIdAndUserId(id, userId)) - No direct
findById()orgetOne()without authorization checks - Authorization uses authenticated user from Security Context, never from request parameters
- Bulk operations verify ownership for each item individually
- File operations verify user owns the file before serving/deleting
Repository & Query Security
- Custom repository methods with ownership:
findByIdAndUserId(Long id, Long userId) - JPQL queries include
AND e.userId = :userIdin WHERE clause - Criteria API queries filter by user ownership
- All list queries scoped to current user (no global queries)
- Named queries include ownership parameters
Spring Security Configuration
-
@PreAuthorizeuses SpEL with custom authorization checks, not justisAuthenticated() - Security service implements ownership verification (e.g.,
@orderSecurityService.isOwner(#id, principal.userId)) -
@EnableGlobalMethodSecurity(prePostEnabled = true)configured - Avoid
@PostAuthorize(checks after data loaded - inefficient) - Authentication extracts user ID from
@AuthenticationPrincipalorAuthenticationobject
Controller Layer
- Controllers pass authenticated user ID to service/repository layers
-
@PathVariableand@RequestParamIDs never used without authorization - DTOs don't expose internal IDs or sensitive ownership data
- Input validation on all ID parameters
Service Layer
- Service methods accept authenticated user ID as parameter
- Authorization checked before any database operations
- Transactions roll back on authorization failures
- Shared access permissions verified if applicable (read/write/delete levels)
Response Security
- Return 404 for both non-existent and unauthorized resources (don't leak existence)
- No sensitive data in exception messages
- Consistent error responses for authorized and unauthorized requests
- Global exception handler for
AccessDeniedException
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
@PreAuthorizeexpressions work correctly - Test bulk operations only affect owned items
- Test with malformed IDs (don't return 500 errors)
- Test update/delete operations require ownership
Defense in Depth (Optional but Recommended)
- Use UUID primary keys instead of sequential Long IDs
- Rate limiting on resource access endpoints
- Audit logging for authorization failures
- Security headers configured (Spring Security defaults)
Code Review Focus Areas
- Search for
repository.findById(,entityManager.find(, direct ID lookups - Review all
@GetMapping,@PostMapping,@PutMapping,@DeleteMappingwith@PathVariable - Check
@PreAuthorize("isAuthenticated()")- should have ownership checks - Verify service methods accepting IDs also accept user ID parameter
- Look for JPQL/HQL queries with
:idbut no:userId