Securing Endpoints by Role
Write URL rules with requestMatchers, hasRole and hasAuthority, order them from specific to general, and test that they hold.
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.POST, "/api/orders").hasRole("USER")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())URL rules live in authorizeHttpRequests. Each rule pairs a request matcher with a requirement, and Spring Security walks the rules top to bottom and applies the first one that matches. That one sentence explains most broken configurations.
http.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/**").hasAnyRole("ADMIN", "SUPPORT")
.anyRequest().authenticated());Roles and authorities
Every authenticated user carries a collection of GrantedAuthority strings. A role is just an authority with the ROLE_ prefix. hasRole("ADMIN") checks for ROLE_ADMIN; hasAuthority("ROLE_ADMIN") is the same check written out. Do not write hasRole("ROLE_ADMIN"): it fails at startup. Use roles for who someone is and plain authorities (such as orders:refund) for fine-grained permissions; with JWTs, scopes arrive as SCOPE_ authorities.
Order from specific to general
Because the first match wins, a broad rule placed early hides narrower ones below it. Put exact paths and admin areas first and finish with anyRequest(). Path patterns use the same syntax as controllers: * matches one path segment, ** matches any number at the end. Adding an HttpMethod lets you open reads while protecting writes on the same path.
Role hierarchies
If an admin should be able to do everything a user can, either list both roles in every rule or declare a RoleHierarchy bean. Spring Security picks it up for URL rules and for method security, so hasRole("USER") also lets admins through.
URL rules are not the whole story
URL rules decide whether someone may call an endpoint, not whether they may touch a particular record. Checking that order 42 belongs to the caller is object-level authorisation, and it belongs in the service or in method security. Missing it is the most common API vulnerability: any logged-in user reads anyone's data by changing an id.
Rules are easy to break during a refactor, for example when a controller path changes from /api/admin to /api/v2/admin and the old rule no longer matches. Write tests for the important paths with @WithMockUser and assert both the allowed and the denied case. Ending with anyRequest().denyAll() instead of authenticated() is a stricter option: every new endpoint is closed until someone writes a rule for it.
Example
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.access.hierarchicalroles.RoleHierarchy;
import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
public class EndpointSecurityConfig {
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
// Most specific first: the first matching rule wins.
.requestMatchers("/error", "/actuator/health/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/products", "/api/products/*").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/orders/*/refund").hasAuthority("orders:refund")
.requestMatchers("/api/**").hasRole("USER")
.anyRequest().denyAll())
.httpBasic(Customizer.withDefaults())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable());
return http.build();
}
static RoleHierarchy roleHierarchy() {
// ADMIN implies SUPPORT, SUPPORT implies USER.
return RoleHierarchyImpl.withDefaultRolePrefix()
.role("ADMIN").implies("SUPPORT")
.role("SUPPORT").implies("USER")
.build();
}
}
When to use it
- Open product browsing to everyone while orders need a signed-in customer and the admin area needs staff.
- Give support staff a refund permission without making them full administrators.
- Lock down every unmapped path with denyAll so new endpoints are closed until a rule is written.
More examples
Testing the rules
<code>@WebMvcTest</code> loads the controller and the security filters but not your configuration classes, so <code>@Import</code> brings in the real rules; without it the test runs against Boot's default chain and proves nothing. <code>@WithMockUser</code> puts a user with the given roles into the security context for one test. Testing the 401, the 403 and the allowed case catches both a rule that is too strict and one that is too loose.
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class AdminReportController {
@GetMapping("/api/admin/reports")
String reports() {
return "[]";
}
}
@Configuration
class ReportSecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults());
return http.build();
}
}
@WebMvcTest(AdminReportController.class)
@Import(ReportSecurityConfig.class)
public class AdminReportSecurityTest {
@Autowired
MockMvcTester mvc;
@Test
void anonymousCallerGets401() {
assertThat(mvc.get().uri("/api/admin/reports")).hasStatus(HttpStatus.UNAUTHORIZED);
}
@Test
@WithMockUser(roles = "USER")
void ordinaryUserGets403() {
assertThat(mvc.get().uri("/api/admin/reports")).hasStatus(HttpStatus.FORBIDDEN);
}
@Test
@WithMockUser(roles = "ADMIN")
void adminGets200() {
assertThat(mvc.get().uri("/api/admin/reports")).hasStatusOk();
}
}
Checking ownership in the service, not the URL
The URL rule only says "logged-in users may call <code>/api/invoices/*</code>". The service then checks that the invoice belongs to the caller, or that the caller is support staff. Answering 404 for someone else's invoice avoids leaking which ids exist. This check is what stops one customer reading another customer's invoices by guessing ids.
import java.util.Optional;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
record Invoice(long id, String ownerEmail, long totalPence) {
}
interface InvoiceStore {
Optional<Invoice> findById(long id);
}
@Service
class InvoiceService {
private final InvoiceStore store;
InvoiceService(InvoiceStore store) {
this.store = store;
}
Invoice findForCaller(long id, Authentication caller) {
boolean support = caller.getAuthorities().stream()
.anyMatch(a -> "ROLE_SUPPORT".equals(a.getAuthority()));
return store.findById(id)
.filter(inv -> support || inv.ownerEmail().equals(caller.getName()))
// 404, not 403: do not confirm that someone else's invoice exists.
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}
}
@RestController
public class InvoiceController {
private final InvoiceService invoices;
InvoiceController(InvoiceService invoices) {
this.invoices = invoices;
}
@GetMapping("/api/invoices/{id}")
Invoice get(@PathVariable long id, Authentication caller) {
return invoices.findForCaller(id, caller);
}
}
Authorities loaded from the database
Roles get the <code>ROLE_</code> prefix in the query, so <code>hasRole("ADMIN")</code> matches, while permissions are stored and checked as plain authorities with <code>hasAuthority</code>. Keeping the prefix logic in one place avoids the classic mismatch where half the code checks <code>ADMIN</code> and the other half <code>ROLE_ADMIN</code>.
import java.util.List;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class PermissionUserDetailsService implements UserDetailsService {
private final JdbcClient jdbc;
PermissionUserDetailsService(JdbcClient jdbc) {
this.jdbc = jdbc;
}
@Override
public UserDetails loadUserByUsername(String email) {
String hash = jdbc.sql("select password_hash from app_user where email = :email")
.param("email", email)
.query(String.class)
.optional()
.orElseThrow(() -> new UsernameNotFoundException("not found"));
// Roles are stored as 'ADMIN', permissions as 'orders:refund'.
List<GrantedAuthority> authorities = jdbc.sql("""
select 'ROLE_' || r.name from user_role r where r.email = :email
union
select p.permission from user_permission p where p.email = :email""")
.param("email", email)
.query(String.class)
.list()
.stream()
.<GrantedAuthority>map(SimpleGrantedAuthority::new)
.toList();
return User.withUsername(email).password(hash).authorities(authorities).build();
}
}
Discussion