Authorization: URL rules, roles vs authorities and method security
Authorization answers "are you allowed to do this?".
URL rules in authorizeHttpRequests are checked in order, first match wins, so put specific rules before general ones and finish with a catch-all such as anyRequest().authenticated() (deny by default).
permitAll(),authenticated(),hasRole("ADMIN"),hasAuthority("courses:write"),hasAnyRole(...).
Roles vs authorities: both are just strings on the Authentication. A role is an authority with the ROLE_ prefix: hasRole("ADMIN") checks for ROLE_ADMIN. JWT scopes become SCOPE_... authorities. Use roles for coarse groups and authorities for fine-grained permissions.
Method security (@EnableMethodSecurity) protects service methods, whatever entry point calls them:
@PreAuthorize("hasRole('ADMIN')")before the method runs.- SpEL can use parameters and the user:
@PreAuthorize("#userId == authentication.name"). @PostAuthorize("returnObject.owner == authentication.name")checks the result.- Call your own bean:
@PreAuthorize("@courseAccess.canEdit(#id, authentication)").
The most common real-world hole is object-level access: user A changing /api/orders/42 to /api/orders/43 and seeing user B's order. Always check ownership, not just the role.
Object-level authorization (IDOR)
Insecure direct object reference: the endpoint checks that you're logged in, but not that the object is yours, so changing an ID in the URL reveals someone else's data. It is consistently one of the most common API vulnerabilities. Scope every lookup to the current user.
// Vulnerable: any logged-in user can read any order by guessing IDs
@GetMapping("/api/orders/{id}")
Order get(@PathVariable long id) { return orders.findById(id).orElseThrow(); }
// Safe: the query itself is limited to the current user's orders
@GetMapping("/api/orders/{id}")
Order get(@PathVariable long id, @AuthenticationPrincipal Jwt jwt) {
return orders.findByIdAndOwnerEmail(id, jwt.getSubject())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); // 404, not 403: don't confirm it exists
}Testing security rules
Test your rules like any other behaviour. With spring-security-test: @WithMockUser(roles = "ADMIN") runs a test as a given user, and MockMvc request post-processors add a JWT (jwt()) or a CSRF token (csrf()).
@WebMvcTest(AdminController.class)
@Import(SecurityConfig.class)
class AdminControllerTest {
@Autowired MockMvc mvc;
@Test
void anonymousGets401() throws Exception {
mvc.perform(get("/api/admin/stats")).andExpect(status().isUnauthorized());
}
@Test
void userGets403() throws Exception {
mvc.perform(get("/api/admin/stats").with(jwt().authorities(new SimpleGrantedAuthority("ROLE_USER"))))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(roles = "ADMIN")
void adminGets200() throws Exception {
mvc.perform(get("/api/admin/stats")).andExpect(status().isOk());
}
}Example
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/courses/**").permitAll() // specific rules first
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/courses/**").hasAuthority("courses:write")
.anyRequest().authenticated()) // deny anything unmatched to anonymous users
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
.build();
}
@Service
@EnableMethodSecurity // usually on a @Configuration class
class OrderService {
@PreAuthorize("hasRole('ADMIN') or @orders.isOwner(#orderId, authentication.name)")
public Order find(long orderId) { return repo.findById(orderId).orElseThrow(); }
@PreAuthorize("hasRole('ADMIN')")
public void refund(long orderId) { /* … */ }
}Common mistake
Ordering rules from general to specific: .requestMatchers("/api/**").authenticated() before .requestMatchers("/api/admin/**").hasRole("ADMIN") means admin URLs only require login.
Under the hood
Method security is implemented with proxies, so the self-invocation trap applies: a @PreAuthorize method called from another method of the same bean isn't checked. Enforce ownership in the service layer (or in the query itself: findByIdAndOwnerEmail), not just in the controller, so every entry point (REST, GraphQL, scheduled jobs) gets the same rules.
Check yourself
The rules are: /api/** authenticated(), then /api/admin/** hasRole("ADMIN"). A normal user calls /api/admin/stats. What happens?
How this connects
Was this lesson helpful?
Finished reading? Mark it complete to track your progress.