Skip to content

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")
  • 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 → Missing AND 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)

Order order = authorizationService.authorizeOrderAccess(
    orderId, currentUserId, Permission.READ
);

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() or getOne() 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 = :userId in 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

  • @PreAuthorize uses SpEL with custom authorization checks, not just isAuthenticated()
  • 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 @AuthenticationPrincipal or Authentication object

Controller Layer

  • Controllers pass authenticated user ID to service/repository layers
  • @PathVariable and @RequestParam IDs 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 @PreAuthorize expressions 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 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, @DeleteMapping with @PathVariable
  • Check @PreAuthorize("isAuthenticated()") - should have ownership checks
  • Verify service methods accepting IDs also accept user ID parameter
  • Look for JPQL/HQL queries with :id but no :userId

Additional Resources