Spring Boot Tutorial
Authorization Rules and Method Security
Authentication tells you who the user is. Authorization decides what they may do — and it is where most real security bugs live. A missing check on one endpoint is enough for any logged-in user to read another customer's orders.
Spring Security gives you two complementary layers. URL-based rules in authorizeHttpRequests protect whole areas of the application. Method security with @PreAuthorize and @PostAuthorize protects individual service methods and can check ownership of specific records. This lesson covers both, plus custom authorization logic and role hierarchies.
URL-Based Rules with authorizeHttpRequests
Rules are evaluated top to bottom and the first match wins, so put specific rules before general ones and end with anyRequest(). In Spring Security 7, string patterns are matched with PathPatternRequestMatcher (the old AntPathRequestMatcher and MvcRequestMatcher were removed).
permitAll()— anyone, including anonymous users.authenticated()— any logged-in user.hasRole("ADMIN")/hasAnyRole("ADMIN","SUPPORT")— checks ROLE_-prefixed authorities.hasAuthority("orders:refund")/hasAnyAuthority(...)— checks exact authority strings.denyAll()— nobody; useful as a safe final rule in locked-down apps.access(AuthorizationManager)— any custom logic you like.
Enabling Method Security
Add @EnableMethodSecurity to a configuration class. Spring then wraps your beans in proxies that check annotations before (or after) each method call. Because it uses proxies, the checks apply only to calls that come from another bean; a method calling another method on this bypasses the proxy.
@PreAuthorize("hasRole('ADMIN')")— checked before the method runs; the most common annotation.@PostAuthorize("returnObject.owner == authentication.name")— checked after, with access to the returned value.@PreFilter/@PostFilter— filter collections passed in or returned.@Securedand JSR-250@RolesAllowed— simpler role-only annotations, enabled with attributes on @EnableMethodSecurity.
Ownership Checks with SpEL and Beans
The most important authorization rule in most applications is "users may only access their own data". SpEL expressions in @PreAuthorize can reference method parameters with #name, the current user with authentication or principal, and any Spring bean with @beanName. Moving complex checks into a dedicated bean keeps annotations readable and makes the logic unit-testable.
Custom AuthorizationManager
For URL rules that need logic beyond roles — office hours, IP ranges, feature flags, tenant membership — implement AuthorizationManager<RequestAuthorizationContext> and plug it in with .access(...). Its authorize method receives a supplier of the current Authentication and the request context, and returns an AuthorizationDecision.
Role Hierarchies
Instead of giving an administrator every role explicitly, declare a RoleHierarchy bean such as ADMIN > STAFF > USER. Anyone with ROLE_ADMIN is then treated as also having ROLE_STAFF and ROLE_USER, for both URL rules and method security.
Returning 403 vs 404
When a user asks for a record they do not own, a 403 confirms the record exists. For sensitive resources (medical records, private documents) many teams return 404 instead so that ids cannot be probed. Decide consistently and document it.
Examples
Ordered URL rules (most specific first)
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/actuator/health").permitAll()
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/products/**").hasRole("ADMIN")
.requestMatchers("/api/orders/*/refund").hasAuthority("orders:refund")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults());
return http.build();
}
GET /api/products/3 (anonymous) -> 200
POST /api/products (ROLE_USER) -> 403 Forbidden
POST /api/orders/9/refund (support user with orders:refund) -> 200
GET /api/orders (anonymous) -> 401 Unauthorized
Method security with ownership checks
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {}
@Service
public class OrderService {
private final OrderRepository orders;
public OrderService(OrderRepository orders) {
this.orders = orders;
}
@PreAuthorize("hasRole('ADMIN')")
public List<Order> findAll() {
return orders.findAll();
}
// Users may only list their own orders; admins may list anyone's
@PreAuthorize("#customerEmail == authentication.name or hasRole('ADMIN')")
public List<Order> findForCustomer(String customerEmail) {
return orders.findByCustomerEmail(customerEmail);
}
// Checked after loading, using the returned object
@PostAuthorize("returnObject.customerEmail == authentication.name or hasRole('ADMIN')")
public Order findById(Long id) {
return orders.findById(id).orElseThrow();
}
// Removes items the caller may not see from the returned list
@PostFilter("filterObject.visibleTo(authentication.name)")
public List<Order> recent() {
return new ArrayList<>(orders.findTop50ByOrderByCreatedAtDesc());
}
}
asha calls findForCustomer("asha@webnest.in") -> list of Asha's orders
asha calls findForCustomer("ravi@webnest.in") -> AccessDeniedException -> HTTP 403
admin calls findForCustomer("ravi@webnest.in") -> list of Ravi's orders
Moving authorization logic into a testable bean
@Component("orderAuth")
public class OrderAuthorization {
private final OrderRepository orders;
public OrderAuthorization(OrderRepository orders) {
this.orders = orders;
}
public boolean canCancel(Long orderId, Authentication auth) {
return orders.findById(orderId)
.map(o -> o.getCustomerEmail().equals(auth.getName()) && o.getStatus() == OrderStatus.PLACED)
.orElse(false);
}
}
@PreAuthorize("@orderAuth.canCancel(#orderId, authentication)")
public void cancel(Long orderId) {
// only reached when the caller owns an order that is still cancellable
}
DELETE /api/orders/41 (owner, status PLACED) -> 204 No Content
DELETE /api/orders/41 (owner, status SHIPPED) -> 403 Forbidden
DELETE /api/orders/41 (different user) -> 403 Forbidden
Custom AuthorizationManager and role hierarchy
// Allow the reports area only during office hours, and only for staff
public class OfficeHoursAuthorizationManager
implements AuthorizationManager<RequestAuthorizationContext> {
private final Clock clock;
public OfficeHoursAuthorizationManager(Clock clock) {
this.clock = clock;
}
@Override
public AuthorizationResult authorize(Supplier<? extends Authentication> authentication,
RequestAuthorizationContext context) {
int hour = LocalTime.now(clock).getHour();
boolean isStaff = authentication.get().getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_STAFF"));
return new AuthorizationDecision(isStaff && hour >= 9 && hour < 18);
}
}
@Bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/reports/**").access(new OfficeHoursAuthorizationManager(Clock.systemDefaultZone()))
.anyRequest().authenticated());
return http.build();
}
// ADMIN implies STAFF, which implies USER
@Bean
static RoleHierarchy roleHierarchy() {
return RoleHierarchyImpl.withDefaultRolePrefix()
.role("ADMIN").implies("STAFF")
.role("STAFF").implies("USER")
.build();
}
GET /api/reports/sales (ROLE_ADMIN, 11:00) -> 200 (ADMIN implies STAFF)
GET /api/reports/sales (ROLE_STAFF, 21:30) -> 403 Forbidden
GET /api/reports/sales (ROLE_USER, 11:00) -> 403 Forbidden
Common Mistakes
- Placing anyRequest().authenticated() before more specific rules — it matches first, so later rules never apply (Spring Security fails fast on this at startup).
- Protecting only the controller URL and forgetting that another endpoint calls the same service method without any check. Put ownership checks in the service layer.
- Expecting @PreAuthorize to work on a private method or on a self-call from the same class; proxies only intercept public calls from other beans.
- Checking only "is the user logged in?" and trusting the id in the URL — the classic IDOR (insecure direct object reference) vulnerability.
- Forgetting @EnableMethodSecurity, so every @PreAuthorize annotation is silently ignored.
Key Points to Remember
- authorizeHttpRequests rules are evaluated top-down; the first match wins, so order from specific to general.
- @EnableMethodSecurity activates @PreAuthorize, @PostAuthorize, @PreFilter and @PostFilter on bean methods.
- Ownership checks ("is this my order?") belong in the service layer, ideally in a dedicated authorization bean.
- Custom AuthorizationManager implementations handle rules that roles alone cannot express.
- RoleHierarchy lets higher roles inherit lower roles without duplicating assignments.
Practice the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.