Skip to content

Spring Boot · Security

Authorization in Spring Security: URL and Method Rules

6 min read Updated 2026-06-26 Share:

Practice Authorization interview questions

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.

More ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel