Spring Boot
Authorization
Authorization decides what an authenticated user may do. This page covers roles and authorities, URL rules in the SecurityFilterChain, method security with @PreAuthorize, @PostAuthorize and @PreFilter/@PostFilter (including how to switch it on and the cases where it silently does nothing), reading the current user safely, clean 401/403 responses, and tests that prove the rules work.
Roles and Authorities
Everything in Spring Security authorization comes down to GrantedAuthority: a string attached to the logged-in user. A role is simply an authority whose string starts with ROLE_. The difference is only convention: roles describe who you are (ROLE_ADMIN), authorities describe what you may do (user:delete).
The prefix is where most 403 bugs begin. hasRole('ADMIN') looks for the string ROLE_ADMIN and adds the prefix for you. hasAuthority('ROLE_ADMIN') looks for the exact string. So if your database stores plain "ADMIN" and you never add ROLE_ when building authorities, hasRole('ADMIN') will never match. In the URL DSL, writing hasRole("ROLE_ADMIN") is rejected at startup, because the prefix is added automatically.
A clean way to organise this is an enum that maps each role to its permissions, so the mapping lives in one place. The principal should also carry the user's id; later sections use it to answer "is this your record?" without a database lookup.
Java
// ── Prefix rules ─────────────────────────────────────────────────────
// hasRole('ADMIN') checks "ROLE_ADMIN" (prefix added)
// hasAnyRole('ADMIN','USER') checks ROLE_ADMIN or ROLE_USER
// hasAuthority('user:read') checks the exact string
// DSL: hasRole("ROLE_ADMIN") -> IllegalArgumentException at startup
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";
private Permissions() { }
}
// ── One place that defines which role grants which permission ────────
public enum Role {
USER(Set.of(Permissions.USER_READ,
Permissions.ORDER_READ, Permissions.ORDER_WRITE)),
ADMIN(Set.of(Permissions.USER_READ, Permissions.USER_WRITE,
Permissions.USER_DELETE,
Permissions.ORDER_READ, Permissions.ORDER_WRITE));
private final Set<String> permissions;
Role(Set<String> permissions) { this.permissions = permissions; }
public List<GrantedAuthority> authorities() {
List<GrantedAuthority> result = new ArrayList<>();
result.add(new SimpleGrantedAuthority("ROLE_" + name())); // the prefix
permissions.forEach(p -> result.add(new SimpleGrantedAuthority(p)));
return result;
}
}
// ── Principal that carries the user id ───────────────────────────────
public class AppUserDetails implements UserDetails {
private final Long id;
private final String username;
private final String passwordHash;
private final Role role;
public AppUserDetails(Long id, String username, String passwordHash, Role role) {
this.id = id;
this.username = username;
this.passwordHash = passwordHash;
this.role = role;
}
public Long getId() { return id; }
@Override public String getUsername() { return username; }
@Override public String getPassword() { return passwordHash; }
@Override public Collection<? extends GrantedAuthority> getAuthorities() {
return role.authorities();
}
// isEnabled() and the other flags default to true in UserDetails
}
// ── Other authority sources ──────────────────────────────────────────
// OAuth2 / JWT resource servers produce "SCOPE_read" style authorities
// (JwtGrantedAuthoritiesConverter), so use hasAuthority('SCOPE_read').
// Spring Security 6.3+ supports a RoleHierarchy bean, e.g.
// "ROLE_ADMIN > ROLE_USER", so admins inherit everything users can do.URL-Based Authorization
URL rules live in the SecurityFilterChain, in authorizeHttpRequests(). The first matching rule wins, so specific rules come first and a catch-all comes last. In Spring Security 6 this is enforced: writing requestMatchers(...) after anyRequest() makes the application fail at startup with an IllegalStateException, rather than quietly ignoring the later rules as older tutorials claim.
For the catch-all, authenticated() means "any logged-in user may access anything I forgot to list", while denyAll() means "anything I forgot is closed". denyAll() is safer for APIs where every route is listed explicitly.
URL rules only see the path and the HTTP method. They can say "only admins may DELETE /api/users/**", but they cannot say "only the owner may edit this user", because that needs the record. Use URL rules for broad gates and method security for per-resource decisions; having both is defence in depth. For Spring Boot Actuator, prefer EndpointRequest over hand-written paths, because it follows the configured base path.
Java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
// ── Public ────────────────────────────────────────────────────
.requestMatchers("/api/auth/**", "/api/public/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
// ── Actuator: health/info public, everything else admin ──────
.requestMatchers(EndpointRequest.to("health", "info")).permitAll()
.requestMatchers(EndpointRequest.toAnyEndpoint()).hasRole("ADMIN")
// ── Role-based ────────────────────────────────────────────────
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/internal/**").hasAnyRole("ADMIN", "SYSTEM")
// ── Authority-based, per HTTP method ─────────────────────────
.requestMatchers(HttpMethod.GET, "/api/users/**").hasAuthority(Permissions.USER_READ)
.requestMatchers(HttpMethod.POST, "/api/users/**").hasAuthority(Permissions.USER_WRITE)
.requestMatchers(HttpMethod.DELETE, "/api/users/**").hasAuthority(Permissions.USER_DELETE)
.requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority(Permissions.ORDER_READ)
.requestMatchers(HttpMethod.POST, "/api/orders/**").hasAuthority(Permissions.ORDER_WRITE)
// ── Catch-all: always LAST ───────────────────────────────────
.anyRequest().denyAll() // or .authenticated()
);
return http.build();
}
// ── Rule order ───────────────────────────────────────────────────────
// WRONG: Spring Security 6 refuses to start the application:
// .anyRequest().authenticated()
// .requestMatchers("/admin/**").hasRole("ADMIN")
// -> IllegalStateException: Can't configure requestMatchers after anyRequest
//
// RIGHT: specific rules first, catch-all last (as above).
//
// Overlapping rules: the FIRST match wins, so put the narrower pattern first:
// .requestMatchers("/api/users/me").authenticated()
// .requestMatchers("/api/users/**").hasAuthority(Permissions.USER_READ)Method Security with @PreAuthorize
@PreAuthorize runs a Spring Expression Language (SpEL) check before the method executes. The expression can read the current Authentication, the method parameters (#name) and any Spring bean (@beanName).
First, switch it on. Method security annotations do nothing unless a configuration class carries @EnableMethodSecurity. Without it the app compiles and starts, and every @PreAuthorize method is wide open. This is the most common authorization bug.
Three behaviours to know. The checks are applied by a proxy, so only calls that come through the proxy are protected: a method calling another @PreAuthorize method on this, in the same class, bypasses the check completely. Private and final methods cannot be proxied. And #id and #request only work if parameter names are available at runtime, which the Spring Boot Maven and Gradle plugins enable with the -parameters compiler flag (otherwise use @P("id") on the parameter).
Java
// ── Switch it on (once, on any @Configuration class) ─────────────────
@Configuration
@EnableMethodSecurity // pre/post annotations on
// @EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true)
// -> also enables @Secured("ROLE_ADMIN") and @RolesAllowed("ADMIN")
public class MethodSecurityConfig { }
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
// ── Role ─────────────────────────────────────────────────────────
@PreAuthorize("hasRole('ADMIN')")
public List<UserResponse> findAll() {
return userRepository.findAll().stream().map(UserResponse::from).toList();
}
// ── Authority ────────────────────────────────────────────────────
@PreAuthorize("hasAuthority('user:read')")
public UserResponse findById(Long id) {
return UserResponse.from(userRepository.findById(id).orElseThrow());
}
// ── Owner or admin: #id is the method parameter; principal.id comes
// from AppUserDetails.getId() ─────────────────────────────────
@PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id")
public UserResponse findOwnOrAny(Long id) {
return UserResponse.from(userRepository.findById(id).orElseThrow());
}
// ── Rule on a field of the request object: only admins may
// create admins ────────────────────────────────────────────────
@PreAuthorize("hasRole('ADMIN') or " +
"(hasAuthority('user:write') and #request.role.name() != 'ADMIN')")
public UserResponse create(CreateUserRequest request) {
// ... save and return
return null;
}
// ── Delegate complex rules to a bean ─────────────────────────────
@PreAuthorize("@authorizationService.canAccessUser(authentication, #id)")
public UserResponse findWithCustomCheck(Long id) {
return UserResponse.from(userRepository.findById(id).orElseThrow());
}
}
@Service("authorizationService")
public class AuthorizationService {
public boolean canAccessUser(Authentication auth, Long targetUserId) {
boolean admin = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
if (admin) return true;
// instanceof also handles the anonymous principal (a plain String)
return auth.getPrincipal() instanceof AppUserDetails p
&& p.getId().equals(targetUserId);
}
}
// ── Self-invocation: the check is SKIPPED ────────────────────────────
public void rename(Long id, String name) {
UserResponse u = this.findById(id); // direct call: no proxy, no check!
// ...
}
// Fix: put the protected method in another bean, or also annotate rename().@PostAuthorize, @PreFilter and @PostFilter
@PostAuthorize checks the result after the method has run, using returnObject. That is useful when the answer depends on the data you just loaded (the owner of a document), but remember the method has already executed, including its database query and any side effects. Never put @PostAuthorize on a method that changes data: the change happens before access is denied. For reads it is fine; prefer @PreAuthorize whenever the rule does not need the result.
@PostFilter removes elements from a returned collection one by one. It loads everything and filters in memory, and it breaks pagination (a page of 20 can come back with 3 items). It is acceptable for small, bounded lists. For anything else, filter in the query by passing the user into the repository method.
@PreFilter filters a collection argument before the method runs, and it modifies that collection in place, so the caller must pass a mutable list (List.of(...) throws UnsupportedOperationException). It silently drops items; if you would rather reject the request than process a subset, check explicitly and throw instead.
Java
@Service
public class DocumentService {
private final DocumentRepository documentRepository;
public DocumentService(DocumentRepository documentRepository) {
this.documentRepository = documentRepository;
}
// ── @PostAuthorize: read-only lookups only ───────────────────────
@PostAuthorize("returnObject.ownerUsername == authentication.name " +
"or hasRole('ADMIN')")
public Document findById(Long id) {
return documentRepository.findById(id).orElseThrow();
// The query ALREADY ran. Fine for a read; never for a write/delete.
}
// ── Better for lists: filter in the database ─────────────────────
@PreAuthorize("hasRole('ADMIN') or #username == authentication.name")
public List<Document> findByOwner(String username) {
return documentRepository.findByOwnerUsername(username); // one query, pageable
}
// ── @PostFilter: small, bounded collections only ─────────────────
@PostFilter("filterObject.ownerUsername == authentication.name " +
"or hasRole('ADMIN')")
public List<Document> findByType(String type) {
return documentRepository.findByType(type);
// Loads ALL matching rows, then filters in memory; breaks paging.
}
// ── @PreFilter: caller must pass a MUTABLE collection ────────────
@PreFilter("filterObject.ownerUsername == authentication.name")
public void deleteAll(List<Document> documents) {
documentRepository.deleteAll(documents); // only the user's own documents
}
}
// Caller:
// documentService.deleteAll(new ArrayList<>(selected)); // OK
// documentService.deleteAll(List.of(a, b)); // UnsupportedOperationExceptionAccessing the Current User
In a controller, the cleanest way is @AuthenticationPrincipal with your own principal type, which gives you the id and username without touching the security context. Anywhere else you can read SecurityContextHolder, but always null-check: getAuthentication() can be null, and for unauthenticated requests it holds an anonymous token whose principal is the string "anonymousUser", not a UserDetails.
@AuthenticationPrincipal is resolved by Spring MVC for controller parameters only. Putting it on a service method parameter does not inject anything; the caller has to pass the value in.
The security context is stored in a ThreadLocal, so it does not follow your code onto another thread. A method annotated @Async, or a task submitted to an executor, will see no authenticated user unless you wrap the executor so the context is propagated.
Java
// ── Controller: inject your principal type ───────────────────────────
@RestController
@RequestMapping("/api/me")
public class MeController {
private final UserService userService;
public MeController(UserService userService) { this.userService = userService; }
@GetMapping
public UserResponse me(@AuthenticationPrincipal AppUserDetails currentUser) {
return userService.findOwnOrAny(currentUser.getId());
}
// Shortcut: extract just the id with a meta-annotation
@GetMapping("/id")
public Long myId(@CurrentUserId Long userId) {
return userId;
}
}
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@AuthenticationPrincipal(expression = "id")
public @interface CurrentUserId { }
// ── Anywhere else: null-safe SecurityContextHolder access ────────────
@Component
public class CurrentUser {
public Optional<Authentication> authentication() {
return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.filter(a -> a.isAuthenticated()
&& !(a instanceof AnonymousAuthenticationToken));
}
public String username() {
return authentication().map(Authentication::getName).orElse("anonymous");
}
public boolean hasRole(String role) {
return authentication()
.map(a -> a.getAuthorities().stream()
.anyMatch(x -> x.getAuthority().equals("ROLE_" + role)))
.orElse(false);
}
}
// ── Controller parameters Spring can also inject ─────────────────────
// Principal principal -> principal.getName()
// Authentication authentication -> authorities, details, principal
// ── Keeping the user inside @Async / executor tasks ──────────────────
@Bean(name = "taskExecutor")
public AsyncTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor delegate = new ThreadPoolTaskExecutor();
delegate.initialize();
return new DelegatingSecurityContextAsyncTaskExecutor(delegate); // copies the context
}401, 403 and Error Responses
Two exceptions describe authorization failures. AuthenticationException means "we do not know who you are" and becomes 401; AccessDeniedException means "we know you, but you may not do this" and becomes 403. For URL rules, ExceptionTranslationFilter catches them and calls the authenticationEntryPoint (401) or the accessDeniedHandler (403).
Method security works differently. The exception is thrown from inside your service, which is inside the controller call, so Spring MVC sees it first and your @RestControllerAdvice gets a chance to handle it. This causes a classic bug: a catch-all @ExceptionHandler(Exception.class) also catches AccessDeniedException and turns every 403 into a 500 or 400. Handle AccessDeniedException explicitly, or rethrow it.
One more design point: when a user asks for someone else's record, 403 confirms that the record exists. For sensitive resources, query by id and owner together and return 404, so existence is not revealed.
Java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, ObjectMapper mapper)
throws Exception {
http.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, e) -> // 401
writeJson(res, mapper, 401, "Unauthorized", "Authentication required"))
.accessDeniedHandler((req, res, e) -> // 403
writeJson(res, mapper, 403, "Forbidden", "Insufficient permissions")));
return http.build();
}
private static void writeJson(HttpServletResponse res, ObjectMapper mapper,
int status, String error, String message)
throws IOException {
res.setStatus(status);
res.setContentType(MediaType.APPLICATION_JSON_VALUE);
mapper.writeValue(res.getOutputStream(),
Map.of("status", status, "error", error, "message", message));
}
// ── Method-security failures reach @RestControllerAdvice ─────────────
@RestControllerAdvice
public class SecurityExceptionHandler {
@ExceptionHandler(AccessDeniedException.class) // @PreAuthorize denials
public ResponseEntity<Map<String, Object>> handleAccessDenied(AccessDeniedException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Map.of(
"status", 403, "error", "Forbidden",
"message", "You do not have permission to perform this action"));
}
}
// WARNING: a generic @ExceptionHandler(Exception.class) in the same advice
// would also catch AccessDeniedException unless a more specific handler
// like the one above exists.
// ── Hide the existence of other users' records: 404, not 403 ─────────
public OrderResponse findOwned(Long id, String username) {
return orderRepository.findByIdAndOwnerUsername(id, username) // one query
.map(OrderResponse::from)
.orElseThrow(() -> new OrderNotFoundException(id)); // 404 either way
}Testing Authorization Rules
Authorization bugs are silent: the app works, but the wrong people get in or out. A short test for each rule turns them into failures. @WithMockUser installs a fake logged-in user, so you can test as a normal user, an admin, or a user with one specific authority, and the same annotation works for both HTTP and method-security tests.
One catch: @WithMockUser builds a standard Spring User, not your AppUserDetails. A rule like authentication.principal.id fails with that principal. For those rules use @WithUserDetails (loads from your UserDetailsService) or build the Authentication yourself with MockMvc's authentication(...) post-processor.
Java
@SpringBootTest
@AutoConfigureMockMvc
class AuthorizationTest {
@Autowired MockMvc mvc;
@Autowired UserService userService;
// ── URL rules ────────────────────────────────────────────────────
@Test
void anonymousGets401() throws Exception {
mvc.perform(get("/api/admin/stats"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "USER") // becomes ROLE_USER
void normalUserGets403OnAdminArea() throws Exception {
mvc.perform(get("/api/admin/stats"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(roles = "ADMIN")
void adminGets200() throws Exception {
mvc.perform(get("/api/admin/stats"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(authorities = "user:read") // exact authority string
void readerCanListButNotDelete() throws Exception {
mvc.perform(get("/api/users")).andExpect(status().isOk());
mvc.perform(delete("/api/users/1").with(csrf()))
.andExpect(status().isForbidden());
}
// ── Method security ──────────────────────────────────────────────
@Test
@WithMockUser(roles = "USER")
void findAllIsAdminOnly() {
assertThrows(AccessDeniedException.class, () -> userService.findAll());
}
@Test
@WithMockUser(roles = "ADMIN")
void adminMayCallFindAll() {
assertDoesNotThrow(() -> userService.findAll());
}
}
// Rules that read authentication.principal.id need a real AppUserDetails:
// @WithUserDetails("alice") // loaded through your UserDetailsServiceCommon Problems and Fixes
These are the failures people hit most often. Each has a symptom, a cause and a fix.
Java
// ── 1. @PreAuthorize has no effect (everyone gets in) ────────────────
// Causes, in order of likelihood:
// a) @EnableMethodSecurity is missing.
// b) The method is called from inside the same class (self-invocation).
// c) The method is private or final, or the class is not a Spring bean
// (created with new).
// Fix: add @EnableMethodSecurity; call through another bean; make the method
// public and non-final.
// ── 2. hasRole('ADMIN') always gives 403 ─────────────────────────────
// Cause: the authority is stored as "ADMIN" without the ROLE_ prefix.
// Fix: build authorities as "ROLE_" + role, or use hasAuthority('ADMIN').
// Print what the user really has:
// log.debug("authorities = {}", auth.getAuthorities());
// ── 3. SpelEvaluationException: property 'id' not found on ... ───────
// Cause: authentication.principal is a plain User (or the String
// "anonymousUser"), not your AppUserDetails.
// Fix: return AppUserDetails from your UserDetailsService (and from JWT
// filters); in tests use @WithUserDetails.
// ── 4. #id evaluates to null / "Property or field 'id' cannot be found"
// Cause: parameter names are not available at runtime.
// Fix: compile with -parameters (Spring Boot plugins do), or annotate
// the parameter: public User find(@P("id") Long id)
// ── 5. A denied method returns 500 or 400, not 403 ───────────────────
// Cause: a catch-all @ExceptionHandler(Exception.class) swallows
// AccessDeniedException.
// Fix: add a specific @ExceptionHandler(AccessDeniedException.class).
// ── 6. Application fails at startup: "Can't configure requestMatchers
// after anyRequest" ─────────────────────────────────────────────
// Cause: a rule is declared after anyRequest().
// Fix: move anyRequest() to the end of authorizeHttpRequests.
// ── 7. @Async method sees no logged-in user ──────────────────────────
// Cause: the SecurityContext is a ThreadLocal and is not copied to the new thread.
// Fix: wrap the executor in DelegatingSecurityContextAsyncTaskExecutor
// (see "Accessing the Current User").