Spring Boot
Authorization
Authorization determines what an authenticated user is allowed to do. Spring Security supports URL-based authorization (rules in SecurityFilterChain), method-level authorization (@PreAuthorize, @PostAuthorize, @Secured), and expression-based access control using Spring Expression Language. The authorization model is built on GrantedAuthority — roles and permissions assigned to the authenticated principal.
Roles and Authorities
Spring Security's authorization model is built on GrantedAuthority — a string representing a permission or role that has been granted to the authenticated user. Roles are authorities prefixed with ROLE_. The hasRole('ADMIN') expression checks for ROLE_ADMIN; hasAuthority('READ_PRODUCTS') checks for the exact string.
Java
// ── GrantedAuthority — the unit of authorization: ────────────────────
// A GrantedAuthority is a String — "ROLE_ADMIN", "READ_USERS", "SCOPE_write"
// The authenticated user carries a Collection<GrantedAuthority>.
// ── Roles vs Authorities: ─────────────────────────────────────────────
// Role: "ROLE_ADMIN", "ROLE_USER" — coarse-grained (who you are)
// Authority: "user:read", "user:write" — fine-grained (what you can do)
// Both are GrantedAuthority strings — the distinction is convention only.
// ── Creating authorities: ─────────────────────────────────────────────
new SimpleGrantedAuthority("ROLE_ADMIN")
new SimpleGrantedAuthority("user:read")
new SimpleGrantedAuthority("SCOPE_openid")
// ── Checking authorities — Spring Security expressions: ───────────────
hasRole('ADMIN') // checks for ROLE_ADMIN (adds prefix automatically)
hasAuthority('ROLE_ADMIN') // checks exact string ROLE_ADMIN (no prefix added)
hasAnyRole('ADMIN', 'USER') // checks for ROLE_ADMIN or ROLE_USER
hasAuthority('user:read') // checks exact string user:read
isAuthenticated() // any authenticated user (not anonymous)
isAnonymous() // unauthenticated user
permitAll() // anyone, including anonymous
denyAll() // nobody
// ── Recommended approach: use authorities for fine-grained control: ───
// Define permissions as String constants:
public final class Permissions {
public static final String USER_READ = "user:read";
public static final String USER_WRITE = "user:write";
public static final String USER_DELETE = "user:delete";
public static final String ORDER_READ = "order:read";
public static final String ORDER_WRITE = "order:write";
public static final String ADMIN_ALL = "admin:all";
}
// Assign permissions to roles in UserDetailsService:
private Collection<GrantedAuthority> getAuthoritiesForRole(String role) {
return switch (role) {
case "ADMIN" -> List.of(
new SimpleGrantedAuthority("ROLE_ADMIN"),
new SimpleGrantedAuthority(Permissions.ADMIN_ALL),
new SimpleGrantedAuthority(Permissions.USER_READ),
new SimpleGrantedAuthority(Permissions.USER_WRITE),
new SimpleGrantedAuthority(Permissions.USER_DELETE));
case "USER" -> List.of(
new SimpleGrantedAuthority("ROLE_USER"),
new SimpleGrantedAuthority(Permissions.USER_READ),
new SimpleGrantedAuthority(Permissions.ORDER_READ),
new SimpleGrantedAuthority(Permissions.ORDER_WRITE));
default -> List.of(new SimpleGrantedAuthority("ROLE_" + role));
};
}URL-Based Authorization
URL authorization rules are defined in the SecurityFilterChain using authorizeHttpRequests(). Rules are evaluated top to bottom — the first matching rule applies. The most specific rules must come before more general ones.
Java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
// ── Public endpoints — no authentication required: ────────────
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
// ── Role-based rules: ─────────────────────────────────────────
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/internal/**").hasAnyRole("ADMIN", "SYSTEM")
// ── Authority-based rules (fine-grained): ─────────────────────
.requestMatchers(HttpMethod.POST, "/api/users/**")
.hasAuthority(Permissions.USER_WRITE)
.requestMatchers(HttpMethod.DELETE, "/api/users/**")
.hasAuthority(Permissions.USER_DELETE)
.requestMatchers(HttpMethod.GET, "/api/users/**")
.hasAuthority(Permissions.USER_READ)
// ── Combined HTTP method + path rules: ────────────────────────
.requestMatchers(HttpMethod.POST, "/api/orders/**")
.hasAuthority(Permissions.ORDER_WRITE)
.requestMatchers(HttpMethod.GET, "/api/orders/**")
.hasAnyAuthority(Permissions.ORDER_READ, Permissions.ADMIN_ALL)
// ── Actuator secured: ─────────────────────────────────────────
.requestMatchers("/actuator/**").hasRole("ADMIN")
// ── Catch-all — must be last: ─────────────────────────────────
.anyRequest().authenticated()
);
return http.build();
}
// ── RequestMatcher patterns: ──────────────────────────────────────────
// "/api/**" — any path starting with /api/
// "/api/users/{id}" — path with variable (matches any id)
// HttpMethod.GET + "/api/**" — only GET requests to /api/**
// "/admin/**" — ant pattern, case sensitive by default
// ── Common mistake — rule order: ─────────────────────────────────────
// WRONG — anyRequest().authenticated() before specific rules:
.anyRequest().authenticated() // matches everything — rules below never reached
.requestMatchers("/admin/**").hasRole("ADMIN") // unreachable!
// CORRECT — specific rules first, catch-all last:
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()@PreAuthorize — Method-Level Authorization
@PreAuthorize evaluates a Spring Security expression before the method executes. It has access to the security context, method parameters (via #paramName), and Spring beans (via @beanName). It is the most powerful and flexible authorization annotation.
Java
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
// ── Role check: ───────────────────────────────────────────────────
@PreAuthorize("hasRole('ADMIN')")
public List<UserResponse> findAll() {
return userRepository.findAll().stream()
.map(UserResponse::from).toList();
}
// ── Authority check: ──────────────────────────────────────────────
@PreAuthorize("hasAuthority('user:read')")
public UserResponse findById(Long id) {
return UserResponse.from(userRepository.findById(id).orElseThrow());
}
// ── Parameter binding — #id refers to the method parameter: ──────
@PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id")
public UserResponse findByIdOwnerOrAdmin(Long id) {
return UserResponse.from(userRepository.findById(id).orElseThrow());
}
// ── Object field access: ──────────────────────────────────────────
@PreAuthorize("hasRole('ADMIN') or #request.userId == authentication.principal.id")
public OrderResponse createOrder(CreateOrderRequest request) {
return orderService.create(request);
}
// ── Multiple conditions: ──────────────────────────────────────────
@PreAuthorize("hasAuthority('user:write') and #user.role != 'ADMIN'")
public UserResponse update(Long id, UpdateUserRequest user) {
return UserResponse.from(userService.update(id, user));
}
// ── Spring bean in expression (@beanName): ────────────────────────
@PreAuthorize("@authorizationService.canAccessUser(authentication, #id)")
public UserResponse findByIdWithCustomCheck(Long id) {
return UserResponse.from(userRepository.findById(id).orElseThrow());
}
// ── isAuthenticated: ──────────────────────────────────────────────
@PreAuthorize("isAuthenticated()")
public UserResponse getMyProfile(
@AuthenticationPrincipal UserDetails currentUser) {
return UserResponse.from(
userRepository.findByUsername(currentUser.getUsername()).orElseThrow());
}
}
// ── Custom authorization service (referenced in SpEL): ───────────────
@Service("authorizationService")
@RequiredArgsConstructor
public class AuthorizationService {
private final UserRepository userRepository;
public boolean canAccessUser(Authentication auth, Long targetUserId) {
if (auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"))) {
return true;
}
UserDetails principal = (UserDetails) auth.getPrincipal();
User user = userRepository.findByUsername(principal.getUsername())
.orElse(null);
return user != null && user.getId().equals(targetUserId);
}
public boolean isResourceOwner(Authentication auth, Long resourceId) {
UserDetails principal = (UserDetails) auth.getPrincipal();
return resourceRepository.existsByIdAndOwnerUsername(
resourceId, principal.getUsername());
}
}@PostAuthorize and @PreFilter / @PostFilter
@PostAuthorize evaluates after the method returns and can inspect the return value. @PreFilter and @PostFilter filter collections before or after method execution based on a per-element expression.
Java
@Service
public class DocumentService {
// ── @PostAuthorize — verify the returned object: ──────────────────
// The method executes, then the expression is evaluated.
// If false: AccessDeniedException is thrown (response not sent).
// returnObject refers to the method's return value.
@PostAuthorize("returnObject.ownerUsername == authentication.name " +
"or hasRole('ADMIN')")
public Document findById(Long id) {
return documentRepository.findById(id).orElseThrow();
// Document is retrieved, then ownership is checked.
// Use @PreAuthorize when possible — @PostAuthorize executes the query
// even if access will be denied.
}
// ── @PostFilter — filter a returned collection: ───────────────────
// filterObject refers to each element in the returned collection.
@PostFilter("filterObject.ownerUsername == authentication.name " +
"or hasRole('ADMIN')")
public List<Document> findAll() {
return documentRepository.findAll();
// ALL documents are loaded from DB, then filtered in memory.
// WARNING: inefficient for large collections — filter in the DB query instead.
// Use only for small collections or when DB filtering is not possible.
}
// ── @PreFilter — filter an input collection: ──────────────────────
// Filters elements of a Collection parameter before the method executes.
@PreFilter("filterObject.ownerUsername == authentication.name")
public List<Document> deleteAll(List<Document> documents) {
documentRepository.deleteAll(documents);
return documents;
// Only documents owned by the current user pass through.
// filterObject = each element in the 'documents' list.
}
// ── Combining @PreAuthorize and @PostFilter: ──────────────────────
@PreAuthorize("isAuthenticated()")
@PostFilter("filterObject.ownerUsername == authentication.name " +
"or hasRole('ADMIN')")
public List<Document> findByType(String type) {
return documentRepository.findByType(type);
}
}Accessing the Current User
Spring Security provides multiple ways to access the currently authenticated user in controllers and services — @AuthenticationPrincipal, SecurityContextHolder, and method injection.
Java
// ── @AuthenticationPrincipal — inject directly into controller method: ─
@RestController
@RequestMapping("/api/me")
public class MeController {
// Injects the authenticated principal (UserDetails implementation):
@GetMapping
public UserResponse getMe(
@AuthenticationPrincipal UserDetails currentUser) {
return userService.findByUsername(currentUser.getUsername());
}
// Null when not authenticated (requires permitAll on the endpoint):
@GetMapping("/optional")
public String optionalAuth(
@AuthenticationPrincipal(errorOnInvalidType = false)
UserDetails currentUser) {
return currentUser != null
? "Hello, " + currentUser.getUsername()
: "Hello, anonymous";
}
// Custom annotation — resolve custom UserDetails field directly:
// @Target(ElementType.PARAMETER)
// @Retention(RetentionPolicy.RUNTIME)
// @AuthenticationPrincipal(expression = "id")
// public @interface CurrentUserId { }
//
// @GetMapping("/id")
// public Long getMyId(@CurrentUserId Long userId) { return userId; }
}
// ── SecurityContextHolder — access anywhere (not just controllers): ───
@Service
public class AuditService {
public String getCurrentUsername() {
Authentication auth =
SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()
|| auth instanceof AnonymousAuthenticationToken) {
return "anonymous";
}
return auth.getName();
}
public Collection<? extends GrantedAuthority> getCurrentAuthorities() {
return SecurityContextHolder.getContext()
.getAuthentication().getAuthorities();
}
public boolean currentUserHasRole(String role) {
return SecurityContextHolder.getContext()
.getAuthentication().getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_" + role));
}
}
// ── Principal injection in controller: ───────────────────────────────
@GetMapping("/principal")
public String getPrincipal(Principal principal) {
return principal.getName(); // username of the authenticated user
}
// ── Authentication injection: ─────────────────────────────────────────
@GetMapping("/auth")
public Map<String, Object> getAuth(Authentication authentication) {
return Map.of(
"username", authentication.getName(),
"authorities", authentication.getAuthorities()
);
}Authorization Exceptions and Error Responses
Spring Security throws two exceptions for authorization failures. ExceptionTranslationFilter intercepts them and delegates to entry points and access denied handlers that control the HTTP response.
Java
// ── The two authorization exceptions: ────────────────────────────────
// AuthenticationException — user is not authenticated (HTTP 401)
// AccessDeniedException — user is authenticated but lacks permission (HTTP 403)
// ── Custom 401 and 403 responses for REST APIs: ────────────────────
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.exceptionHandling(ex -> ex
// 401 — unauthenticated (AuthenticationException):
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("""
{
"status": 401,
"error": "Unauthorized",
"message": "Authentication required"
}
""");
})
// 403 — authenticated but forbidden (AccessDeniedException):
.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("""
{
"status": 403,
"error": "Forbidden",
"message": "Insufficient permissions"
}
""");
})
);
return http.build();
}
// ── @RestControllerAdvice — handle AccessDeniedException globally: ────
@RestControllerAdvice
public class SecurityExceptionHandler {
// Handles AccessDeniedException thrown by @PreAuthorize:
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDenied(
AccessDeniedException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ErrorResponse.of(403, "Forbidden",
"You do not have permission to perform this action"));
}
// NOTE: AuthenticationException is handled by the authenticationEntryPoint
// defined in the SecurityFilterChain — it does not reach @ControllerAdvice.
}
// ── Checking authorization programmatically: ──────────────────────────
@Service
@RequiredArgsConstructor
public class OrderService {
private final AuthorizationManager<MethodInvocation> authorizationManager;
public OrderResponse findById(Long id) {
Order order = orderRepository.findById(id).orElseThrow();
// Throw AccessDeniedException if the current user cannot read this order:
Authentication auth =
SecurityContextHolder.getContext().getAuthentication();
if (!auth.getName().equals(order.getOwnerUsername())
&& auth.getAuthorities().stream()
.noneMatch(a -> a.getAuthority().equals("ROLE_ADMIN"))) {
throw new AccessDeniedException(
"Access to order " + id + " is denied");
}
return OrderResponse.from(order);
}
}