Databases, Spring and microservices · 4. Spring Security, lesson 5 of 8

Authorization: URL rules, roles vs authorities and method security

Intermediate3 min read@since 17Code runs on your Java 25
Explain it forThe essentials plus production detail and pitfalls.

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.

Java
// 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()).

Java
@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

Java
@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

Part of Spring Core and Spring Security in depth.

Was this lesson helpful?

Finished reading? Mark it complete to track your progress.