CWE-566: Authorization Bypass Through User-Controlled SQL Primary Key - Java
Overview
Authorization bypass through user-controlled keys, commonly known as Insecure Direct Object Reference (IDOR), happens when a Java application uses a user-supplied identifier - an order ID, a user ID, a document ID - in a database query or a business decision without checking that the authenticated user is entitled to that resource. Changing the identifier in the request then reads, modifies or deletes another user's data, which is horizontal privilege escalation.
Java enterprise applications, particularly those built with Spring Boot, Jakarta EE, and JPA/Hibernate, are susceptible when developers rely on framework convenience methods like findById() or getOne() without an authorization check. REST conventions put the resource ID straight into the URL (/api/orders/{orderId}), so every such endpoint is a place the check has to be made and can be missed.
Spring Security provides authentication, but it does not automatically enforce object-level authorization. @PreAuthorize("isAuthenticated()") is often taken as sufficient protection when 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 findings. JPA's lazy loading and entity relationships open a second route, exposing related entities that no check covered.
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
Why this is vulnerable: The only thing selecting a record is a path variable the caller supplies, and findById answers for any of them. Authentication has happened; authorization has not, so every signed-in account is equivalent.
Sequential identifiers are what turn one exposure into all of them. A Long primary key from a database sequence is guessable by counting, so the endpoint is enumerable rather than merely bypassable - and swapping to a UUID raises the cost of discovery without changing the fact that a known identifier still works. The identifier scheme is hardening; the ownership comparison is the fix.
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
Why this is vulnerable: findById is generated by Spring Data from the method name, so it carries exactly the semantics its name states and no authorization at all - redeclaring it on the interface adds nothing but makes it look considered.
Where the ownership constraint lives is the durable decision. A check after the load can be forgotten by the next caller of getDocument(); a derived query such as findByIdAndOwnerId(id, currentUserId) puts the constraint in the SQL, so a row belonging to someone else is not returned to be checked. That also fixes the response: a caller receives the same "not found" whether the document is absent or not theirs, which is what stops the endpoint doubling as an existence oracle.
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
Why this is vulnerable: Writing the query by hand does not add a constraint that was not written, and a JPQL string is easier to review for injection than for a missing WHERE clause - the parameter is bound correctly, so the query looks careful.
Add the owner to the query rather than around it: WHERE u.id = :id AND u.owner = :currentUser makes the authorization part of what the database is asked, so no caller can reach the row without it. For an application-wide version of the same idea, a Hibernate @Filter or a tenant discriminator applies the predicate to every query rather than to the ones somebody remembered - but check what "every query" covers before relying on it for this sink. By default a Hibernate filter does not apply to a load by primary key, which is what find(), findById() and a @ManyToOne fetch all resolve to, so an ownership filter leaves exactly the lookup this page traces untouched. @FilterDef(applyToLoadByKey = true) extends the filter to by-id loads and exists only from Hibernate 6.6; below that version there is no documented way to make a filter cover the lookup at all, and the query constraint above is the only defence. autoEnabled (Hibernate 6.5 and later) is a different element - it removes the per-session Session.enableFilter call and says nothing about by-id loads.
File Download Without Authorization
// VULNERABLE - file located by a caller-supplied row ID, with no ownership check
@RestController
public class FileController {
@Autowired
private UploadedFileRepository fileRepository;
@GetMapping("/download/{fileId}")
public ResponseEntity<Resource> downloadFile(@PathVariable Long fileId) {
try {
// Primary key straight from the path - the row is fetched for
// whoever asks, and only then handed to the file system
UploadedFile record = fileRepository.findById(fileId)
.orElseThrow(() -> new ResourceNotFoundException("File not found"));
// No ownership check - any authenticated user gets any file
Resource resource = new UrlResource(Paths.get(record.getStoragePath()).toUri());
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + record.getOriginalName() + "\"")
.body(resource);
} catch (MalformedURLException e) {
throw new RuntimeException("Error reading file");
}
}
}
// Attack example:
// User uploads their invoice, which is stored as file 123
// Attacker requests: GET /download/124
// Attacker requests: GET /download/125
// Result: downloads every other user's uploads by walking the ID
Why this is vulnerable: findById(fileId) looks the row up by primary key alone, 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 - findByIdAndUserId(fileId, currentUserId) - so the file system is only reached for a row the caller could already have.
A related mistake is not CWE-566 and needs its 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.
Downloads also tend to be reached through a second route. A file served directly by the web server, a pre-signed URL with a long expiry, or a static mapping over the upload directory bypasses this controller entirely, so a check added here can be true and irrelevant. Confirm the storage location is not reachable except through code that authorizes.
Batch Operations Without Per-Item Authorization
// VULNERABLE - Bulk delete without individual authorization checks
@RestController
@RequestMapping("/api/documents")
public class DocumentController {
@Autowired
private DocumentRepository documentRepository;
@PostMapping("/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
Why this is vulnerable: The authorization was written for the endpoint and the request carries a list, so a single permitted caller acts on an arbitrary set of records - and one request reaches as many rows as it names, which is the difference between a finding that leaks a record and one that leaks a table.
Batch endpoints also tend to be added after the single-item version is already correct, which is why searching for them is worth doing before closing any finding of this kind. The reliable shape is to scope the query rather than to loop over checks: updateByIdInAndOwner(ids, currentUser) cannot process a row it did not select, whereas a per-item check inside a loop has to be right for every path through the method, including the error path.
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: @PreAuthorize("isAuthenticated()") verifies that the caller is logged in and nothing more. Method security evaluates whatever expression it is given, so object-level authorization is the expression's job - and this expression does not mention the order. Every authenticated caller passes it, and every orderId they send is answered.
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!
}
}
// 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(@P("orderId") 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(@P("orderId") 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(@P("orderId") Long orderId) {
orderRepository.deleteById(orderId);
}
// SECURE - Custom permission check for shared resources
@PreAuthorize("@orderSecurityService.hasPermission(#orderId, authentication.principal.userId, 'READ')")
public Order getSharedOrder(@P("orderId") Long orderId) {
return orderRepository.findById(orderId).orElseThrow();
}
}
// Security service for @PreAuthorize expressions
// Visibility is a query, not a sequence of checks: owned, or shared with a
// row that carries read. Everything the authorization decides about
// *existence* is settled by this single statement
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("""
SELECT o FROM Order o
WHERE o.id = :orderId
AND (o.userId = :userId
OR EXISTS (SELECT 1 FROM OrderShare s
WHERE s.orderId = o.id
AND s.userId = :userId
AND s.canRead = true))
""")
Optional<Order> findVisibleTo(@Param("orderId") Long orderId,
@Param("userId") Long userId);
}
@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) {
// One query settles existence and visibility together. Loading the
// order by id and only then looking for a share costs a caller who
// cannot see it one more round trip than a caller who named an id
// that was never issued - both are refused, but the refusals take
// different amounts of work, which is the timing tell the Testing
// section asks you to remove. Measured on Hibernate 6.6: one
// statement for a missing id and one for an invisible order alike
Optional<Order> visible = orderRepository.findVisibleTo(orderId, userId);
if (visible.isEmpty()) {
return false;
}
// Owner has all permissions
if (visible.get().getUserId().equals(userId)) {
return true;
}
// Shared, and the query above already applied the read floor. This
// second lookup decides the operation rather than existence, so it
// runs only for a caller who may already know the order is there.
// Load the share once rather than calling existsBy...AndCanWrite per
// permission - independent existence queries cannot express "read is
// the floor", because each answers about its own flag in isolation
OrderShare share = shareRepository
.findByOrderIdAndUserId(orderId, userId)
.orElse(null);
if (share == null) {
return false;
}
return switch (permission) {
case "READ" -> true; // established by the floor above
case "WRITE" -> share.isCanWrite();
case "DELETE" -> share.isCanDelete();
default -> false; // unrecognised permission denies
};
}
}
// 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: @PreAuthorize evaluates its SpEL (Spring Expression Language) expression before the method body runs. @orderSecurityService.isOwner(#orderId, authentication.principal.userId) takes the current user ID from the security context and asks the security service whether this caller owns that order. If it answers false, Spring Security throws AccessDeniedException and the method never executes, so the repository is never reached. Keeping the decision in a dedicated service means one implementation to test and audit rather than one per call site.
Why the parameters carry @P. #orderId asks Spring Security to resolve a
method parameter by name, and names reach the class file only when the code is
compiled with -parameters. Spring Security's
DefaultSecurityParameterNameDiscoverer consults @P first and then falls back
to standard reflection - and LocalVariableTableParameterNameDiscoverer, the
fallback that used to recover names from debug symbols, was removed in Spring
Framework 6.1. Measured on Spring Security 6.5 against JDK 21 bytecode: compiled
with -parameters, a bare Long orderId resolves and the owner is admitted;
compiled without it, #orderId evaluates to null, the security service is
called with a null id, and the expression denies - so the owner is refused,
with nothing in the failure pointing at a compiler flag. @P("orderId") - it is
org.springframework.security.core.parameters.P, which ships with
spring-security-core and needs no extra dependency - puts the name in an
annotation, which is always retained; #p0 is the positional alternative and
needs no annotation at all, at the cost of breaking silently if someone reorders
the parameters. This is the same build dependency the AOP
aspect below avoids with @ResourceId, and it fails the same way: closed, and
for everybody.
On the response code: a failed @PreAuthorize throws AccessDeniedException, which Spring Security's default handling answers with 403 - and it does so for a missing order and an unowned one alike, because isOwner loads the order and returns false either way. That is a uniform 403, not the oracle this page warns about: the two outcomes are indistinguishable, which is the property that matters. Keep it that way deliberately, or map it to 404 with the advice under Where @PostAuthorize Fits in Secure Patterns, if you want the API to answer consistently with handlers that return Optional.empty() for an unowned row.
Watch the failure mode, though. It holds only while every branch behind the expression is uniform. A security service rewritten to findById(orderId).orElseThrow() - so a missing order 404s and an unowned one 403s - reintroduces the oracle without touching the annotation or the controller, and no test that only checks "user B is refused" would catch it.
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: findByIdAndUserId combines the resource lookup and the ownership check in one query. With userId in the WHERE clause alongside id, the database returns a row only when both match, so an attacker who knows another user's order ID gets an empty Optional rather than the order. The row never reaches application code, which leaves no post-load check to forget and costs no extra query.
Explicit Authorization Service with Reusable Logic
// SECURE - Dedicated authorization service
@Repository
public interface DocumentRepository extends JpaRepository<Document, Long> {
@Query("""
SELECT d FROM Document d
WHERE d.id = :documentId
AND (d.ownerId = :userId
OR EXISTS (SELECT 1 FROM DocumentShare s
WHERE s.documentId = d.id
AND s.userId = :userId
AND s.canRead = true))
""")
Optional<Document> findVisibleTo(@Param("documentId") Long documentId,
@Param("userId") Long userId);
}
@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 ResourceNotFoundException if the document does not exist, or
* this user has no share carrying read on it
* @throws AccessDeniedException if the user can see the document but the
* share does not carry the requested permission
* @return The authorized document
*/
public Document authorizeDocumentAccess(
Long documentId,
Long userId,
DocumentPermission permission) {
// One query settles visibility: owned, or shared with a row that
// carries read. Fetching by id and then looking for a share makes a
// denied request cost one more round trip than a missing id, so the
// two ResourceNotFoundExceptions - identical in status and body -
// stay separable by response time. Measured on Hibernate 6.6: one
// statement for a missing id, for an invisible document, and for a
// share that grants write without read, alike
Document document = documentRepository.findVisibleTo(documentId, userId)
.orElseThrow(() -> new ResourceNotFoundException("Document not found"));
// Check ownership first
if (document.getOwnerId().equals(userId)) {
return document; // Owner has all permissions
}
// Past the floor the query above applied, so this second lookup runs
// only for a caller already entitled to know the document exists.
// The orElseThrow is unreachable - findVisibleTo returns a non-owned
// document only when a readable share exists - but it keeps that
// invariant from becoming a NullPointerException if it ever changes
DocumentShare access = shareRepository
.findByDocumentIdAndUserId(documentId, userId)
.orElseThrow(() -> new ResourceNotFoundException("Document not found"));
// Past that floor the caller can already see the document, so a
// denial here reveals nothing new about existence - 403 is correct
switch (permission) {
case READ:
break; // already established by the visibility check above
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 in a dedicated service means every controller reaches a document through one code path, and a new controller cannot acquire a weaker check by omission - it either calls authorizeDocumentAccess and gets an authorized Document, or it has no document to return. Naming the permission at the call site keeps each endpoint's requirement visible in the controller rather than implied by which method it happens to call.
The two denial paths deliberately throw different exceptions, and that split is the part worth copying:
- Not visible at all - not owned, and no share carrying
can_read- throwsResourceNotFoundException, exactly as a missing ID does. ThrowingAccessDeniedExceptionhere instead would answer 404 for an ID that does not exist and 403 for one that does, handing back the enumeration the check exists to prevent. - Visible but not permitted at this level - a read-only share on a DELETE - throws
AccessDeniedException. The caller can already see the document, so a 403 tells them nothing about existence they did not already know, and a 404 would be actively misleading.
Note that can_read is enforced as the floor rather than as one case among three, and that it is enforced in the query: findVisibleTo returns nothing for a share row with read revoked, so such a row is treated as no access at all. Checking only that a share row exists would grant read access that the flag explicitly denies.
Putting the floor in the query is also what makes the two 404 answers cost the same. A version that loads the document by id and only then looks for a share spends an extra round trip on the denial path, so a caller can separate "no such document" from "not yours" by response time while the status and body stay identical - the thing the Testing section asks you to remove. Measured on Hibernate 6.6, findVisibleTo answers a missing id, an invisible document and a write-without-read share in one statement each; the second lookup runs only once visibility is settled, where its cost tells the caller nothing they were not already entitled to know.
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(@P("orderId") Long orderId) {
return orderRepository.findById(orderId)
.orElseThrow(() -> new ResourceNotFoundException("Order not found"));
}
@PreAuthorize("@orderSecurityService.isOrderOwner(#orderId, authentication.principal.userId)")
public void deleteOrder(@P("orderId") 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;
}
}
// @EnableMethodSecurity turns the annotations above on. Without it they are
// inert and every call goes straight through - see Testing below
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}
Why this works: @PreAuthorize is declarative authorization that runs before the method does. @orderSecurityService.isOrderOwner(#orderId, authentication.principal.userId) takes the current user ID from the security context and verifies ownership; a false answer raises AccessDeniedException and the body never runs. The requirement stays visible in the method signature instead of being buried in the body, and it applies to every caller that reaches the method through the proxy.
Where @PostAuthorize Fits
@PostAuthorize is real authorization, not a broken version of @PreAuthorize. Spring Security evaluates the expression against returnObject after the method returns and throws AccessDeniedException when it fails, so the caller never receives the object:
// Prevents disclosure - the caller does not receive an order they don't own.
// Read the response-code note below before using this as-is
@PostAuthorize("returnObject.userId == authentication.principal.userId")
public Order getOrder(Long orderId) {
return orderRepository.findById(orderId).orElseThrow();
}
It stops the disclosure but leaks existence by default. A missing order throws NoSuchElementException from orElseThrow() and a denied one throws AccessDeniedException, which Spring Security's default handling maps to 403 - so the two outcomes are distinguishable and the endpoint is still the ID oracle described under Testing below. Either map both to the same response, or accept a 403 as a deliberate decision because the order's existence is not sensitive:
// The denial and the miss now look identical to the caller
@RestControllerAdvice
public class NotFoundAdvice {
@ExceptionHandler({ AccessDeniedException.class, NoSuchElementException.class })
public ResponseEntity<Void> notFound() {
return ResponseEntity.notFound().build();
}
}
Scope that advice to the controllers where it belongs. Collapsing every AccessDeniedException in the application into a 404 also hides the ones that should stay 403 - a missing scope, a role check, a CSRF failure - and makes those far harder to diagnose from the client side.
Prefer @PreAuthorize or an ownership-scoped repository method anyway, for reasons that are about blast radius rather than bypass:
- It is too late for a method that changes state. By the time the expression runs, the write has happened. It is rolled back only if the method is transactional and the
AccessDeniedExceptionpropagates out of the transaction boundary -@PostAuthorizeon a non-transactional service method, or an exception swallowed by a caller inside the transaction, leaves the change committed. - It does not filter collections.
@PostAuthorizeon a method returningList<Order>evaluates the expression against the list object, not its elements, and the expression above simply fails to resolveuserId. Use@PostFilterfor collections, and be aware it loads every row before discarding the ones the user cannot see. - A
nullreturn has to be handled in the expression.returnObjectis bound to whatever the method returned, soreturnObject.userIdis a property access onnullwhen the method returnsnull- SpEL raises an evaluation error (EL1007E) rather than denying cleanly, and null-safe navigation (returnObject?.userId) turns it into a denial instead of an empty result. Decide which you want and write it:returnObject == null or returnObject.userId == ...to let an empty result through,returnObject != null and ...to deny. The example above avoids the question by throwing fromorElseThrow()instead of returningnull. - The row is loaded regardless. Not a bypass, but it means an unauthorized ID still costs a database round trip, and anything the method logs, caches or emits as an event has already seen the data.
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 ownerField() default "userId";
}
// Marks which argument carries the resource id. An annotation is retained in
// the class file unconditionally; a *parameter name* is not - see the note
// under "Why this works" below
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface ResourceId {
}
@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();
// Find the argument marked @ResourceId. Matching on the parameter
// *name* instead makes the aspect depend on compiler flags rather
// than on anything in the source
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Annotation[][] paramAnnotations =
signature.getMethod().getParameterAnnotations();
Object[] paramValues = joinPoint.getArgs();
Long entityId = null;
outer:
for (int i = 0; i < paramAnnotations.length; i++) {
for (Annotation annotation : paramAnnotations[i]) {
if (annotation instanceof ResourceId) {
entityId = (Long) paramValues[i];
break outer;
}
}
}
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);
// Same exception as the missing-entity branch above. A distinct
// "you don't own this" would confirm the row exists, which is the
// enumeration the aspect was added to prevent
if (!ownerId.equals(currentUserId)) {
throw new ResourceNotFoundException("Resource not found");
}
// Authorization passed, proceed with method execution
return joinPoint.proceed();
}
}
@Service
public class DocumentService {
@Autowired
private DocumentRepository documentRepository;
@AuthorizeOwnership(entityClass = Document.class)
public Document getDocument(@ResourceId Long documentId) {
// Authorization already verified by aspect
return documentRepository.findById(documentId).orElseThrow();
}
@AuthorizeOwnership(entityClass = Document.class)
public void deleteDocument(@ResourceId Long documentId) {
// Authorization already verified by aspect
documentRepository.deleteById(documentId);
}
}
Why this works: Aspect-Oriented Programming (AOP) applies the same authorization logic to any method annotated with @AuthorizeOwnership, reducing the chance that a developer forgets the check on a covered method. The aspect intercepts the call before execution, extracts the entity ID from the parameters, fetches the entity, and verifies that the current user owns it; if not, it throws and the method never runs. Both failures throw ResourceNotFoundException - the entity being absent and the entity belonging to someone else are indistinguishable to the caller, so a covered method cannot be used to test which IDs exist. The requirement is declared once in the annotation rather than written out in every method body.
Why the id is marked with an annotation and not matched by name. The obvious
version of this aspect takes an idParam = "documentId" string and compares it
against signature.getParameterNames(). Parameter names survive into the class
file only when the code is compiled with -parameters, so that version's
correctness depends on a build setting rather than on anything visible in the
source. Measured on Spring Boot 3.5 with JDK 21 bytecode: compiled with
-parameters, getParameterNames() returns [documentId] and the aspect
works; compiled without it and without debug symbols, the same call returns
null, and the loop that reads paramNames.length throws
NullPointerException before any authorization runs. It fails closed - the
secured method does not execute - but a control that stops working when someone
changes a compiler flag is not one to hand a reader.
spring-boot-starter-parent sets -parameters for you, which is why the
name-matching version usually works and why the failure, when it comes, arrives
with a build change rather than a code change. The parameter annotation has no
such dependency: annotation metadata is always retained, and the same aspect run
against a stripped build resolves the id and proceeds normally.
Using UUIDs with Authorization (Defense in Depth)
// SECURE - UUID primary keys + authorization checks
@Entity
@Table(name = "documents")
public class Document {
@Id
@GeneratedValue
// Hibernate 6: @GenericGenerator and org.hibernate.id.UUIDGenerator are
// both deprecated. @UuidGenerator defaults to style = RANDOM, which is a
// version 4 UUID - style = TIME embeds a timestamp and is guessable
@UuidGenerator
@Column(updatable = false, nullable = false)
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
// Random UUIDs make enumeration computationally infeasible, but authorization is still required
Why this works: A random UUID cannot be walked the way an auto-incrementing Long can - there is no pattern to increment, so enumeration stops being a matter of counting. That is the whole of what it buys. UUIDs are not an access control by themselves, and they leak through logs, shared URLs, browser history, API responses, or social engineering, so the code still queries with findByIdAndUserId: an attacker who has obtained a valid document ID still gets nothing back.
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: The ownership check (o.userId = :userId) sits in the JPQL WHERE clause, so the database returns a row only when the order ID and the user ID both match, and nothing unauthorized is loaded into memory on this path. Manipulating orderId gets an attacker nowhere, because userId comes from the authenticated security context rather than from the request. One query makes the authorization decision part of the retrieval instead of a step beside it.
Bulk Operations Scoped to the Owner
// SECURE - the owner is part of the statement, so a document belonging to
// someone else is never selected and cannot be acted on
@Repository
public interface DocumentRepository extends JpaRepository<Document, Long> {
// The load-bearing method: authorization is the WHERE clause
List<Document> findByIdInAndUserId(List<Long> ids, Long userId);
// One-statement variant. Read the note below before choosing it
@Modifying(clearAutomatically = true)
@Transactional
@Query("DELETE FROM Document d WHERE d.id IN :ids AND d.userId = :userId")
int deleteByIdInAndUserId(@Param("ids") List<Long> ids, @Param("userId") Long userId);
}
public record BulkDeleteResult(int deletedCount, int requestedCount) {}
@RestController
@RequestMapping("/api/documents")
public class DocumentController extends BaseController {
@Autowired
private DocumentRepository documentRepository;
@PostMapping("/bulk-delete")
@Transactional
public ResponseEntity<BulkDeleteResult> bulkDelete(
@RequestBody BulkDeleteRequest request) {
Long userId = getCurrentUserId();
List<Long> requestedIds = request.getDocumentIds();
// Cap the batch size - a limit on blast radius, not rate limiting
if (requestedIds == null || requestedIds.isEmpty() || requestedIds.size() > 100) {
return ResponseEntity.badRequest().build();
}
// Load only what this caller owns, then delete those entities, so
// cascades and @PreRemove callbacks run
List<Document> owned = documentRepository.findByIdInAndUserId(requestedIds, userId);
documentRepository.deleteAll(owned);
// One pair of counts, never a per-ID breakdown. Reporting which IDs
// were skipped - or splitting them into "no such document" and "not
// yours" - turns a single batch request into an existence oracle for
// the whole table, which is what the ownership predicate refuses to say
return ResponseEntity.ok(
new BulkDeleteResult(owned.size(), requestedIds.size()));
}
}
Why this works: The ownership predicate is in the statement rather than beside it, so the set the handler operates on is already the caller's own set. A document belonging to another user is never selected, which means there is no per-item check to get wrong on some path through the loop, and no ordering in which an unowned row is briefly held. The response carries two counts and no identifiers: a caller learns how many of their own documents were deleted and nothing about the ones that were not.
Which of the two deletes to use is not a style choice. Measured on Spring
Data JPA 3.5 with Hibernate 6.6, deleting the loaded entities and issuing the
bulk DELETE behave differently:
| Entity has a cascading association | Entity has none | |
|---|---|---|
deleteAll(findByIdInAndUserId(...)) |
Child rows removed, @PreRemove fires |
Works |
@Query("DELETE ... IN :ids AND d.userId = :userId") |
Throws DataIntegrityViolationException |
Works, returns the count |
A bulk JPQL DELETE is executed straight against the database and does not
consult the persistence context, so it cascades to nothing and fires no entity
callbacks - against a @OneToMany with a foreign key it does not fail quietly,
it fails on the constraint. Prefer the load-then-delete form, which is correct
in both columns and costs one extra query; reach for the single statement only
where the entity has no cascading associations and the batch is large enough
for the round trip to matter. Either way findByIdInAndUserId is where the
authorization lives, so the choice below it does not affect the fix.
Framework-Specific Guidance
Spring Boot with Spring Security
Spring Boot applications get authentication from Spring Security and have to supply the object-level authorization themselves:
// SECURE - Complete Spring Boot authorization example (Spring Boot 3 /
// Spring Security 6). WebSecurityConfigurerAdapter was removed in Spring
// Security 6.0 and @EnableGlobalMethodSecurity is deprecated in favour of
// @EnableMethodSecurity, so the pre-6 form of this class no longer compiles
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
// The filter has to be a bean for this to be injected - annotate it
// @Component, or declare it with @Bean in a configuration class
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
// Disabling CSRF is safe only for a stateless, token-authenticated
// API. Leave it on for anything that authenticates with a cookie
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}
// 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);
}
// 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) {
// Delete the row the ownership query returned, not the id the caller
// sent. An existsByIdAndUserId(...) check followed by
// deleteById(orderId) splits the predicate from the operation: the
// statement that deletes names only the primary key, so nothing in it
// carries the constraint, and a later edit that moves or drops the
// check leaves a delete that reads as complete and is unscoped. It is
// also a second query for a row findByIdAndUserId already returned.
// (deleteById is not itself unsafe here - Spring Data implements it
// as findById(id).ifPresent(this::delete), so cascades and @PreRemove
// still run. The objection is where the authorization lives.)
Order order = orderRepository.findByIdAndUserId(orderId, currentUserId)
.orElseThrow(() -> new ResourceNotFoundException("Order not found"));
orderRepository.delete(order);
}
}
// 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: Spring Security authenticates the request and puts the user in the security context; BaseController.getCurrentUserId() is the only place a controller gets a user ID, and every repository method here takes that ID as part of its WHERE clause. The ID the caller sends picks a candidate row, the ID from the security context decides whether it is returned, and the two come from different places - so a manipulated path variable has nothing to manipulate the ownership predicate with. Because the service methods take currentUserId as a parameter, the constraint travels with the call rather than living only in the controller.
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 {
// Find the argument marked @ResourceId - the same annotation declared
// in the Spring AOP example above, reused verbatim here. It is a
// plain @Target(PARAMETER) marker with no Spring dependency, so copy
// that declaration across if you are taking this section on its own. Taking "the first Long argument" instead
// authorizes whichever id happens to be declared first, so
// addCollaborator(Long userId, Long documentId) would look the
// *user* id up in the document table and decide from that
Annotation[][] paramAnnotations =
context.getMethod().getParameterAnnotations();
Object[] params = context.getParameters();
Long documentId = null;
outer:
for (int i = 0; i < paramAnnotations.length; i++) {
for (Annotation annotation : paramAnnotations[i]) {
if (annotation instanceof ResourceId) {
documentId = (Long) params[i];
break outer;
}
}
}
if (documentId == null) {
throw new IllegalArgumentException(
"No @ResourceId parameter on " + context.getMethod().getName());
}
// 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");
}
// 404, not 403 - a distinct "forbidden" tells the caller the
// document exists, which is the enumeration the check exists to stop
if (!doc.getUserId().equals(userId)) {
throw new NotFoundException("Document not found");
}
// Authorization passed
return context.proceed();
}
}
@Stateless
public class DocumentService {
@PersistenceContext
private EntityManager em;
@Secured(ResourceType.DOCUMENT)
public Document getDocument(@ResourceId Long documentId) {
// Interceptor already verified authorization
return em.find(Document.class, documentId);
}
@Secured(ResourceType.DOCUMENT)
public void deleteDocument(@ResourceId Long documentId) {
// Interceptor already verified authorization
Document doc = em.find(Document.class, documentId);
em.remove(doc);
}
}
Why this works: A Jakarta EE interceptor runs before any method annotated with @Secured, verifies ownership, and either proceeds or throws - the annotated method cannot execute unauthorized. The check lives in one class to audit and maintain rather than being repeated in each service method, so the thing a developer can forget is the annotation, not the logic.
Common Pitfalls
- Using
@PreAuthorize("hasRole('USER')")instead of an ownership-checking SpEL expression such as@PreAuthorize("#id == authentication.principal.id")(with@P("id")on the parameter, per the note above). A role check confirms the caller is authenticated as a user, not that they own this resource - it's the same authentication-vs-authorization gap the framework overview warns about, just moved into an annotation. - Fixing the controller method with an ownership check, but the JPA entity has a lazy-loaded
@OneToMany/@ManyToOneassociation that Jackson serializes into the response. Accessing a related entity through that association bypasses the top-level check entirely, since Jackson serializes whatever the entity graph contains, not what the security annotation screened. - Writing a correct ownership-scoped repository method (
findByIdAndUserId) for the main read path, then adding a native or JPQL@Queryfor a reporting or admin feature that queries the same table without the same predicate - custom queries don't inherit Spring Data's derived-method conventions automatically. - Fixing every
orderRepository.findById()call site the scanner flagged, while a Thymeleaf/JSP view independently calls a service or repository method to render related data (an "order history" fragment, an admin export view) that reaches the same table through a different method - a sweep targeting one access pattern misses a second one to the same data.
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 the persistence layer before any check.
@PreAuthorizeis actually being enforced, by asserting the denial and not only the allow. The annotation is inert - the method runs unchecked, with no warning - when method security is not enabled at all, and when the call is a self-invocation that never leaves the bean, because both bypass the proxy that reads it. A broken expression is the safe failure by comparison: a misspelled bean name raisesIllegalArgumentException: Failed to evaluate expressionand the method does not run.- 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
- Baeldung Spring Security Guides
- CWE-566: Authorization Bypass Through User-Controlled SQL Primary Key
- OWASP API Security Top 10 - API1:2023 Broken Object Level Authorization
- OWASP IDOR Prevention Cheat Sheet
- OWASP Top 10 2025 A01: Broken Access Control
- Spring Data JPA Reference
- Spring Security Reference