Two places to say "no"
Once Spring knows who you are, authorization decides what you may do — and it offers two layers to enforce it: URL rules at the edge of the request, and method security next to the business logic. The best apps use both. This article walks through each, the SpEL behind them, and the gotchas interviewers love.
URL rules are first-match-wins
Inside authorizeHttpRequests you chain matchers to access rules, evaluated top to bottom, applying the
first match:
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/**").hasAuthority("API_WRITE")
.anyRequest().authenticated()); // catch-all LAST
The ordering rule is the single most common source of bugs: a broad anyRequest() placed early shadows
every specific rule after it. Always order specific-to-general and finish with a catch-all so nothing is
left unguarded.
Matchers: paths and methods
A request matcher selects which requests a rule applies to — by Ant path pattern, HTTP method, or both:
auth.requestMatchers("/api/orders/**").hasRole("USER") // any method
.requestMatchers(HttpMethod.DELETE, "/api/orders/*").hasRole("ADMIN"); // method-specific
* matches a single path segment, ** matches across segments. Method-specific matchers are how you let
everyone read but restrict writes on the same path — a very common requirement.
Method security: checks next to the logic
Turn on @EnableMethodSecurity and you can annotate methods directly. The flagship is @PreAuthorize,
which takes a full SpEL expression:
@EnableMethodSecurity
class MethodSecurityConfig { }
@Service
class ReportService {
@PreAuthorize("hasRole('ANALYST')")
Report generate(Long id) { ... }
}
It's proxy-based, so the same caveats as @Transactional apply: the method must be public and called
from another bean — self-invocation silently skips the check. The payoff is that the rule lives with the
logic and protects the method no matter which controller, job, or listener calls it.
@PreAuthorize vs @PostAuthorize
@PreAuthorize runs before the method and can read its arguments. @PostAuthorize runs after
and can inspect the return value via returnObject:
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.name")
User get(String userId) { ... }
@PostAuthorize("returnObject.owner == authentication.name")
Document load(Long id) { ... } // runs, then denies if not the owner
Prefer @PreAuthorize — it blocks before doing work. Reach for @PostAuthorize only when the decision
genuinely depends on the loaded object, accepting that the body already executed.
@Secured, @RolesAllowed, or @PreAuthorize?
All three gate access, but they differ in power. @Secured (Spring) and @RolesAllowed (JSR-250) take
plain role names only; @PreAuthorize takes an expression that can combine roles, inspect
arguments, and call beans:
@Secured("ROLE_ADMIN") // role list only
@RolesAllowed("ADMIN") // JSR-250 equivalent
@PreAuthorize("hasRole('ADMIN') and #id > 0") // roles + args + logic
@Secured and @RolesAllowed are off by default and need explicit enabling; @PreAuthorize is on by
default and is the most flexible. Most teams just standardize on @PreAuthorize.
Expressions, and when to escape them
SpEL gives you hasRole, hasAuthority, isAuthenticated(), the authentication/principal objects, and
method arguments via #name. The pro move is delegating real logic to a bean instead of cramming it
into a string:
@PreAuthorize("@accessChecker.canEdit(#id, authentication)")
void edit(Long id) { ... }
A @bean reference keeps the decision testable in Java. For filtering collections there's @PreFilter and
@PostFilter, which prune elements rather than allow/deny the whole call — handy for small in-memory lists,
but for large datasets filter in the query instead of loading everything and discarding.
The 403 path
When an authenticated user lacks permission, Spring throws AccessDeniedException, the
ExceptionTranslationFilter catches it, and the response becomes 403 Forbidden — distinct from the
401 an unauthenticated request gets:
http.exceptionHandling(e -> e
.accessDeniedHandler((req, res, ex) -> res.sendError(403, "Not allowed"))
.authenticationEntryPoint((req, res, ex) -> res.sendError(401, "Authenticate first")));
401 = "we don't know you, log in"; 403 = "we know you, but no." Conflating them is a classic mistake.
Role hierarchies remove repetition
Without help you end up writing hasAnyRole('ADMIN','MANAGER','USER') everywhere. A RoleHierarchy lets a
senior role imply junior ones:
@Bean
RoleHierarchy roleHierarchy() {
return RoleHierarchyImpl.fromHierarchy(
"ROLE_ADMIN > ROLE_MANAGER \n ROLE_MANAGER > ROLE_USER");
}
Now an ADMIN automatically satisfies hasRole('USER'), and your rules stay simple and consistent.
Instance-level permissions
"Can this user edit this record" is a different question from "does this user have a role." Use
hasPermission with a custom PermissionEvaluator, or a @bean check:
@PreAuthorize("hasPermission(#id, 'Document', 'EDIT')")
void edit(Long id) { ... }
The evaluator consults your domain rule (ownership, sharing) per object. Spring's full ACL module exists for rich per-object permissions but is heavyweight; a custom evaluator covers most needs.
Defense in depth
Why bother with both layers? Because they fail differently. A URL rule is a coarse gate at the edge; method security protects the operation even when it's reached by a non-HTTP path — a scheduled job, a message listener — that URL rules never see, and even if a new endpoint forgets its rule. Put the authoritative check on the service method, the single chokepoint every caller passes through, and keep URL rules as the broad first line.
And test both sides. With spring-security-test, assert the denied case as carefully as the happy path:
@Test @WithMockUser(roles = "USER")
void userIsForbidden() throws Exception {
mockMvc.perform(delete("/api/users/1").with(csrf()))
.andExpect(status().isForbidden()); // 403
}
Recap
Authorization in Spring Security works at two layers. URL rules are first-match-wins, so order them
specific-to-general and end with a catch-all. Method security (@EnableMethodSecurity) puts
@PreAuthorize/@PostAuthorize checks next to the logic — proxy-based, so mind self-invocation. Prefer
@PreAuthorize and its SpEL, delegating real logic to a @bean; use a RoleHierarchy to kill repetition
and a PermissionEvaluator for per-record decisions. Authenticated-but-denied is a 403, unauthenticated
a 401. Secure the service layer as the authoritative chokepoint, layer URL rules on top, and test
the denied path every time.