CWE-285: Improper Authorization - Java
Overview
In Spring-based Java applications, improper authorization typically shows up in one of two layers: the HTTP security configuration (SecurityFilterChain) that decides which routes require which role or authority, and method-level security (@PreAuthorize, @PostAuthorize, @Secured) that protects individual service methods regardless of how they are called. Both layers need to be configured - HTTP-level rules alone leave internal service calls unprotected, and method-level annotations alone leave routes reachable if no filter chain rule requires authentication in the first place. Spring Security only enforces what is explicitly configured or annotated: an endpoint or method with no matching rule falls through to whatever the surrounding configuration defaults to.
Primary Defence: Define SecurityFilterChain rules with .requestMatchers(...).hasRole(...) (or .hasAuthority(...)) ordered from most specific to least specific, ending in a catch-all .anyRequest().authenticated(). Enable @EnableMethodSecurity and apply @PreAuthorize to service methods that perform privileged operations, using SpEL expressions to check the current user's roles, authorities, or ownership of the resource being acted on. Function-level checks (role/authority) and object-level checks (does this user own this record) are separate controls - a method can correctly require ROLE_USER and still let one user modify another user's data if it never compares the resource owner to the authenticated principal. Express the object-level half as a term in the repository query (findByIdAndOwnerUsername, findByOwnerUsername) rather than as a comparison after the row is loaded: a query constraint applies to the collection route as well as the single-resource one, and a check applied to a loaded object does not.
Common Vulnerable Patterns
Missing or Overly Permissive Filter Chain Rule
// VULNERABLE - admin routes have no matcher, sensitive path uses permitAll()
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/reports/export").permitAll() // meant for a public summary, not the full export
.anyRequest().authenticated()
);
return http.build();
}
// Attack example:
// GET /api/reports/export
// Any unauthenticated caller downloads the full report data, because no
// rule requires a role or authority for that path.
Why this is vulnerable: .permitAll() is easy to leave on a path after a route's purpose changes, and there is no compiler warning when a sensitive endpoint has no hasRole()/hasAuthority() matcher - it simply falls through to whatever the catch-all rule allows.
Service Method Without @PreAuthorize
@Service
public class InvoiceService {
// VULNERABLE - no authorization check; any authenticated caller can
// delete any invoice, and this method is also callable from any
// controller that autowires InvoiceService
public void deleteInvoice(Long invoiceId) {
invoiceRepository.deleteById(invoiceId);
}
}
Why this is vulnerable: Without @PreAuthorize, @Secured, or an equivalent check, the method has no idea who is calling it or whether they should be allowed to. A controller that forgets its own authorization check exposes this method to anyone who is merely authenticated, or - if the controller itself is unprotected - to anyone at all.
Function-Level Check Without Object-Level Ownership Check
@RestController
@RequestMapping("/api/invoices")
public class InvoiceController {
@PreAuthorize("hasRole('USER')")
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteInvoice(@PathVariable Long id) {
// VULNERABLE - proves the caller has ROLE_USER, but never checks
// that this invoice belongs to them
invoiceService.deleteInvoice(id);
return ResponseEntity.noContent().build();
}
}
Why this is vulnerable: hasRole('USER') confirms the caller is a logged-in user, not that they own invoice id. Any authenticated user can delete any invoice by guessing or enumerating IDs - a classic IDOR gap hiding behind a role check that looks correct on its own.
The Collection Endpoint Nobody Puts Under Test
@RestController
@RequestMapping("/api/invoices")
public class InvoiceController {
@PreAuthorize("hasRole('USER')")
@GetMapping("/{id}")
public Invoice getInvoice(@PathVariable Long id) {
return invoiceService.getInvoiceForOwner(id, currentUsername()); // correctly scoped
}
// VULNERABLE - the list route beside it returns every invoice in the
// table to any caller with ROLE_USER
@PreAuthorize("hasRole('USER')")
@GetMapping
public Page<Invoice> listInvoices(Pageable pageable) {
return invoiceRepository.findAll(pageable);
}
}
// Attack example:
// GET /api/invoices?size=2000
// One request, no ID guessing, and the response is the whole table.
Why this is vulnerable: Ownership checks are written for the route that names a resource, and a collection route names none - so the object-level check, whether a @PostAuthorize expression or a hand-written owner comparison, has nothing to fire on and is absent. The single-resource route being correctly protected is what lets this survive review: the ownership logic is visibly present on the page, one method above. This is also the cheaper attack, because it needs no enumeration at all.
@PostFilter("filterObject.ownerUsername == authentication.name") is the tempting patch and is not the fix. It reads every row the query returned before discarding the ones the caller may not see, so the database has already handed the process the whole table - and it does not survive pagination: measured on Spring Security 6.5 with Spring Data Commons, annotating the method above throws IllegalArgumentException: Filter target must be a collection, array, map or stream type, but was Page 1 of 2 ... on the first call. Changing the return type to List<Invoice> makes it run, and then a page sized for twenty rows comes back with the three the caller owns - the database still read twenty - and the totalElements a client needs to page through the result is gone with the Page, because the count the query produced was taken before any filtering. The fix is to put the owner in the query, as the secure pattern below does.
Trusting a Client-Supplied Role
@PostMapping("/api/users")
public ResponseEntity<User> createUser(@RequestBody CreateUserRequest request) {
// VULNERABLE - role comes from the request body, not the authenticated principal
User user = new User(request.getUsername(), request.getRole());
return ResponseEntity.ok(userService.save(user));
}
Why this is vulnerable: Reading a role or permission from request data instead of from Authentication/SecurityContext lets an attacker grant themselves any role the JSON body accepts, regardless of what role checks exist elsewhere in the application.
Secure Patterns
Ordered Filter Chain Rules
// SECURE - specific matchers before the catch-all, explicit role/authority
// requirements for every sensitive prefix
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/reports/**").hasAuthority("REPORTS_DELETE")
.requestMatchers("/api/reports/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
);
return http.build();
}
}
Why this works: Spring Security evaluates requestMatchers() rules in the order they are declared and applies the first match, so listing specific paths and HTTP methods before broader ones prevents a narrow rule from being shadowed by an earlier, looser one. .anyRequest().authenticated() as the final rule means any endpoint added later without its own matcher defaults to "must be logged in" rather than "public" - failing closed instead of open.
Method-Level Security with @PreAuthorize
// SECURE - @EnableMethodSecurity makes @PreAuthorize enforceable
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}
@Service
public class InvoiceService {
@PreAuthorize("hasRole('ADMIN') or hasAuthority('INVOICES_DELETE')")
public void deleteInvoice(Long invoiceId) {
invoiceRepository.deleteById(invoiceId);
}
}
Why this works: @EnableMethodSecurity wires Spring AOP to intercept annotated methods and evaluate the SpEL expression against the current Authentication before the method body runs. Because the check lives on the service method itself, every caller - a REST controller, a scheduled job, another service - goes through the same authorization logic instead of each caller needing to remember to check first.
Object-Level Authorization for Ownership
// SECURE - ownership is a term in the query, so a non-owner and a
// nonexistent ID are the same miss and produce the same response
public interface InvoiceRepository extends JpaRepository<Invoice, Long> {
Optional<Invoice> findByIdAndOwnerUsername(Long id, String ownerUsername);
Page<Invoice> findByOwnerUsername(String ownerUsername, Pageable pageable);
}
@Service
public class InvoiceService {
public void deleteInvoice(Long invoiceId, Authentication authentication) {
boolean isAdmin = authentication.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
Invoice invoice = (isAdmin
? invoiceRepository.findById(invoiceId)
: invoiceRepository.findByIdAndOwnerUsername(invoiceId, authentication.getName()))
.orElseThrow(() -> new ResourceNotFoundException("Invoice not found"));
invoiceRepository.delete(invoice);
}
// The collection endpoint carries the same constraint, from the same place
public Page<Invoice> listInvoices(Authentication authentication, Pageable pageable) {
boolean isAdmin = authentication.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
return isAdmin
? invoiceRepository.findAll(pageable)
: invoiceRepository.findByOwnerUsername(authentication.getName(), pageable);
}
}
Why this works: The ownership term is part of what is asked, not a check applied to the answer. A ROLE_USER caller can delete their own invoices and nobody else's, and the row is never loaded for a caller who may not have it.
Two things follow that a load-then-check version does not give. The failure paths collapse: an invoice owned by someone else and an invoice ID that matches no row both leave through orElseThrow, so the response is identical and walking invoiceId cannot map which invoices exist. The load-then-check shape - findById(...) followed by throw new AccessDeniedException(...) - answers 404 for one and 403 for the other, and that gap is created by adding the ownership check rather than closed by it. And the constraint reaches the collection endpoint, which the single-resource route's checks never do: listInvoices gets its scoping from the same repository method naming the same owner, so the endpoint that returns the most rows is not the one relying on a hook that does not fire for it.
Either status is defensible as long as both denials produce the same one. Keeping both at 403 (letting the ownership predicate fail rather than raising a distinct not-found) is equally sound; what leaks is the pair.
Using @PostAuthorize for Return-Value Checks
// SECURE - evaluates authorization after the object is loaded, using its
// fields in the SpEL expression
@PostAuthorize("returnObject.ownerUsername == authentication.name or hasRole('ADMIN')")
public Invoice getInvoice(Long invoiceId) {
return invoiceRepository.findById(invoiceId)
.orElseThrow(() -> new ResourceNotFoundException("Invoice not found"));
}
Why this works: @PostAuthorize runs after the method returns, so its SpEL expression can reference returnObject - the loaded entity - to make an ownership decision without duplicating the lookup-then-check logic in every method. This keeps object-level authorization declarative and next to the method it protects, at the cost of running the query before the check; it is not appropriate for expensive queries or methods with side effects that should not happen for an unauthorized caller.
It also carries the cost the query-scoped pattern above avoids. A missing invoice leaves through orElseThrow as 404 and someone else's leaves through the denied expression as 403, so walking invoiceId still separates the two - the same pair that section calls a gap opened by adding the ownership check. Where that matters, either make both denials produce the same status or scope the query by owner instead.
Reading Role from the Authenticated Principal
@PostMapping("/api/users")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<User> createUser(@RequestBody CreateUserRequest request) {
// SECURE - the caller's own privilege is enforced by @PreAuthorize above;
// the new user's role is assigned from a fixed default, never from the request
User user = new User(request.getUsername(), Role.STANDARD_USER);
return ResponseEntity.ok(userService.save(user));
}
Why this works: @PreAuthorize("hasRole('ADMIN')") ensures only administrators can reach this endpoint at all, and the new user's role is assigned server-side from a known-safe default rather than trusted from the request body - closing the privilege-escalation path even if an attacker adds a role field to the JSON payload.
Framework-Specific Guidance
Spring Security Method Security: @PreAuthorize vs @Secured
@PreAuthorize accepts full SpEL expressions (hasRole(...), hasAuthority(...), boolean logic, method arguments via #paramName), while @Secured only accepts a fixed list of role strings and cannot express object-level logic. Prefer @PreAuthorize for new code; treat @Secured as a legacy pattern that may need widening to a real permission model rather than more roles.
Jakarta EE / Servlet Security
Applications not using Spring Security can enforce role checks with the Jakarta EE @RolesAllowed annotation (requires @DeclareRoles or container-managed security realm configuration) or programmatically with HttpServletRequest.isUserInRole(String role). Both are function-level only - object-level ownership checks still need to be written explicitly in the method body, the same as the Spring examples above.
Testing
A re-scan sees the annotation and stops there. It cannot tell an expression that denies the right callers from one that denies all of them, it cannot see whether the annotated method is on the path the request actually takes, and it cannot see which of two denials a caller received. Assert these with MockMvc and Spring Security Test:
- The owner still gets their own record.
@WithMockUser(username = "alice", roles = "USER")requesting alice's invoice returns200with the invoice body. A SpEL expression that denies everyone passes every rejection assertion below identically, so this one goes first -hasRole('ADMIN')against an authority stored asADMINrather thanROLE_ADMINis the usual way to arrive there. - The two denials are indistinguishable. As
alice, request an invoice owned byboband an invoice ID that matches no row, and assert both responses carry the same status and the same body. A403for one and a404for the other maps the table one request at a time; which of the two statuses you standardise on matters less than that they match. - The collection endpoint returns only the caller's rows.
GET /api/invoicesasalicereturns her invoices and no others - assert the IDs, not just200. Then request?size=2000and assert the same. The count in aPageis taken by the query, so a filtered list with an unfilteredtotalElementsstill discloses how many rows exist. - The annotated method is on the path the request takes. Assert the denial from the HTTP layer, not by calling the service bean directly in a unit test. A
@PreAuthorizereached through self-invocation is silently skipped and a bean-level test cannot show it - measured on Spring Boot 3.5.6, aROLE_USERprincipal calling ahasRole('ADMIN')method through the proxy is denied, and reaches the method body when the same call comes from another method of the same class. - Client-supplied privilege fields are ignored.
POSTa body carryingrole,isAdminorpermissionsset to an elevated value as a legitimately authorized caller, then read the persisted entity and assert the field holds the server-side default. Asserting only on the response status misses a value that was written and not echoed. - Every verb on the path is covered. Repeat the cross-owner request for
GET,PUT,PATCH,DELETEand any bulk variant of the same resource. The matcher order inSecurityFilterChainis first-match, so a method-specific rule placed after a broader one never runs.
Common Pitfalls
- Self-invocation bypassing
@PreAuthorize: Spring AOP method security works through a proxy around the bean. Calling an@PreAuthorize-annotated method from another method in the same class (this.deleteInvoice(id)) does not go through the proxy, so the check is silently skipped. Move the protected method to a separate bean, or call it through an injected reference to the proxy, if this pattern is unavoidable. - Assuming
hasRole('USER')covers object-level access: a role check proves the caller is a member of a group, not that they own the specific record referenced by the path variable - endpoints returning or mutating one user's data still need the ownership check shown above. - A broad
permitAll()declared before a specific rule shadows it:.requestMatchers("/api/**").permitAll()ahead of an admin-only matcher for/api/admin/**wins, because Spring Security applies the first matching rule rather than the most specific one. Nothing fails and nothing is logged, so this is one to audit for by reading the chain in declaration order. - A matcher declared after
.anyRequest()fails at startup rather than being shadowed:AbstractRequestMatcherRegistryasserts on it (Assert.state(!this.anyRequestConfigured, "Can't configure requestMatchers after anyRequest")), so the context refuses to start with anIllegalStateException; the same guard covers a repeated.anyRequest()anddispatcherTypeMatchers. Keep.anyRequest()last because the framework requires it, not because a later rule would silently lose - that case cannot reach production. - Trusting request data for role or permission assignment: any field in a request body, query string, or header that influences a role or permission decision must be treated as attacker-controlled; the authorization decision belongs to
Authentication/SecurityContext, populated by the server during login or token verification.