CWE-862: Missing Authorization - Java
Overview
In Spring applications, Missing Authorization typically shows up as a @RestController method that sits behind the global authentication filter but carries no @PreAuthorize, @Secured, or matching authorizeHttpRequests rule - so any logged-in user can call it, not just the role it was intended for. It also appears as a new endpoint added after the SecurityFilterChain matcher list was written, so it falls through to a broader default rule instead of the specific one it needs. Jakarta EE applications show the same gap as a @RolesAllowed-free EJB or JAX-RS method. The fix is method-level or route-level authorization for the role/permission check, plus an explicit ownership comparison for anything that operates on a specific record.
Common Vulnerable Patterns
Authenticated-Only Endpoint With No Role Check
// VULNERABLE - authentication filter runs, but nothing checks the caller's role
@RestController
public class OrderController {
@PostMapping("/orders/{id}/refund")
public ResponseEntity<Void> refundOrder(@PathVariable Long id) {
orderService.refund(id); // any authenticated user can call this
return ResponseEntity.noContent().build();
}
}
// Attack: a standard authenticated user calls POST /orders/500/refund
// Result: the refund executes with no role check at all
Why this is vulnerable: The global security filter chain confirms the caller is logged in, but nothing on this method or a matching authorizeHttpRequests rule restricts it to the role that should be allowed to issue refunds.
Role Check Without Resource Ownership
// VULNERABLE - confirms the caller has the right role, but not that they own the order
@PreAuthorize("hasRole('CUSTOMER')")
@GetMapping("/orders/{id}")
public ResponseEntity<Order> getOrder(@PathVariable Long id) {
return ResponseEntity.ok(orderService.findById(id)); // any customer, any order ID
}
// Attack: an authenticated customer requests /orders/1, then /orders/2, /orders/3...
// Result: every customer's order is returned to every other customer
Why this is vulnerable: hasRole('CUSTOMER') confirms the caller holds a valid customer role, but a role is not the same as ownership - the method never checks that order id actually belongs to the requesting user.
Secure Patterns
Method-Level Role Check
// SECURE - @PreAuthorize restricts the action to the required role
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/orders/{id}/refund")
public ResponseEntity<Void> refundOrder(@PathVariable Long id) {
orderService.refund(id);
return ResponseEntity.noContent().build();
}
Why this works: @PreAuthorize runs before the method body executes and raises AccessDeniedException (handled as an HTTP 403 by Spring Security's default AccessDeniedHandler) if the caller lacks the required role. Because it is an annotation on the method rather than a path pattern in the filter chain, it stays attached when the route is renamed or remapped.
On a controller method it guards the HTTP entry point and nothing else. The work happens in orderService.refund(id), and a scheduled job, a message listener, or another service calling that method reaches it with no check at all. Annotate the service method instead whenever more than one caller can reach it - @PreAuthorize protects the annotated Spring-managed bean method, so putting it where the operation lives is what makes the rule independent of how the request arrived.
Resource-Based Ownership Check via a Security Bean
// SECURE - a dedicated security bean loads the resource and compares ownership
@Component("orderSecurity")
public class OrderSecurity {
private final OrderRepository orderRepository;
public OrderSecurity(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public boolean isOwner(Long orderId, String username) {
return orderRepository.findById(orderId)
.map(order -> order.getOwnerUsername().equals(username))
.orElse(false);
}
}
@PreAuthorize("@orderSecurity.isOwner(#id, authentication.name)")
@GetMapping("/orders/{id}")
public ResponseEntity<Order> getOrder(@PathVariable Long id) {
return ResponseEntity.ok(orderService.findById(id));
}
Why this works: The SpEL expression calls a bean that loads the actual Order row and compares its owner to the authenticated principal before the controller method runs. Because the comparison happens against a server-loaded record, an attacker cannot influence the outcome by editing the id path parameter - a wrong ID fails the check.
Deny-by-Default Filter Chain
// SECURE - matchers ordered most-specific first, chain ends deny-by-default
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/orders/*/refund").hasRole("ADMIN")
// authentication only - ownership is @PreAuthorize's job, see below
.requestMatchers(HttpMethod.GET, "/orders/*").authenticated()
.requestMatchers("/actuator/health").permitAll()
.anyRequest().denyAll()
);
return http.build();
}
Why this works: Spring Security evaluates authorizeHttpRequests matchers in order and uses the first match, so specific rules must come before general ones. Ending the chain with .anyRequest().denyAll() means a newly added endpoint that nobody wired a matcher for is rejected by default instead of silently falling through to an unintended broad rule.
What it does not do is settle GET /orders/*. A matcher sees a URL pattern, not the record behind the *, so .authenticated() there is exactly the vulnerable shape above - every logged-in customer may read every order - unless the handler itself carries the ownership check. That rule is @PreAuthorize("@orderSecurity.isOwner(#id, authentication.name)") on the method, and the two are complementary rather than alternatives: the chain decides which requests reach a controller at all, and method security decides which record each caller may have. A path pattern can express a role requirement, as the /orders/*/refund line does. It can never express ownership.
Framework-Specific Guidance
Jakarta EE
// SECURE - @RolesAllowed restricts an EJB or JAX-RS method to specific roles
@Stateless
public class OrderService {
@RolesAllowed("ADMIN")
public void refund(Long orderId) {
// ...
}
@DenyAll
public void internalReconcile() {
// never callable through a remote interface
}
}
Why this works: The Jakarta EE container enforces @RolesAllowed before invoking the method, using the caller's principal from the container's security context - the check runs regardless of which client (web, remote EJB, batch job) invokes the method, not just callers that go through a particular controller.
Enable Method Security
// SECURE - required for @PreAuthorize/@PostAuthorize to be enforced
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}
Why this works: @PreAuthorize annotations are inert without @EnableMethodSecurity (or the older @EnableGlobalMethodSecurity) wiring the enforcement aspect into the application context - leave it out and every @PreAuthorize in the codebase is silently unenforced.
Testing
- Normal: call the endpoint as a user holding the correct role and owning the target resource; confirm success.
- Boundary: request a resource owned by someone else, then request an ID that does not exist, and confirm the two responses are identical - same status and same body. Under the security-bean pattern above both are 403 with an empty body, because
isOwnerreturnsfalsefor a missing row and a row owned by somebody else alike, and oneAccessDeniedExceptioncovers both. That differs from the scoped-query approach on the JavaScript and Python pages, where both answers are 404 instead; either is fine. A 403 for one and a 404 for the other is an existence oracle whichever way round they are. - Malicious: call the endpoint directly with
MockMvcor an HTTP client as an authenticated user with no role at all, bypassing the UI entirely; confirm 403. - Assert
@EnableMethodSecurityis in effect rather than assuming it: a test that expects 403 from a@PreAuthorizemethod and gets 200 is the only signal that the annotations are inert. Without one, removing that configuration disables every annotation in the codebase silently. - Use
@WithMockUser(roles = "...")and@SpringBootTestwithMockMvcto assert both the role check and the ownership check independently. - Re-run any SAST/DAST scan that reported the finding to confirm it no longer triggers.
Common Pitfalls
@PreAuthorizeon the controller but not the service: Guarding the HTTP entry point while a scheduled job, message listener, or another service class calls the same service method directly, bypassing the controller-level check entirely. Put the check on the service method itself when it can be reached from more than one entry point.- Matcher ordering in
SecurityFilterChain: Placing a broadauthenticated()matcher before a specifichasRole(...)matcher for the same path pattern - Spring Security uses the first matching rule, so the broad rule wins and the specific restriction never applies. - Forgetting
@EnableMethodSecurity: Adding@PreAuthorizeannotations throughout the codebase without the configuration that activates them, so every annotation is present in source but silently unenforced at runtime. - Role check with no ownership comparison: Verifying
hasRole('CUSTOMER')on an endpoint that returns a specific record by ID, without checking that the record belongs to the caller - this is IDOR (CWE-639) wearing a role check as camouflage.