HTTP Authorization: Securing Endpoints with requestMatchers
HTTP Authorization: The Last Line of Defense
AuthorizationFilter is the last filter in the Spring Security chain. After all authentication filters have run (and either set an Authentication or left it as anonymous), AuthorizationFilter makes the final access decision: allow or deny.
The rules you write in authorizeHttpRequests() are evaluated in order, first match wins.
flowchart TD
Request[HTTP Request] --> Auth[AuthorizationFilter]
Auth --> Rules{Evaluate rules\nin order}
Rules -->|Rule 1 matches| Decision1{Allowed?}
Rules -->|Rule 2 matches| Decision2{Allowed?}
Rules -->|No rule matches| Deny[Deny — implicit deny-all]
Decision1 -->|Yes| Controller
Decision1 -->|No| Denied[403 Forbidden]
Decision2 -->|Yes| Controller
Decision2 -->|No| Denied
The Deny-by-Default Principle
The most important security principle: start by denying everything, then explicitly permit what is allowed.
// WRONG — open by default, close selectively (dangerous)
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN")
// Everything else is implicitly open — new endpoints are public by default!
);
// RIGHT — deny by default, open selectively
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated() // ← this is the default deny for everything else
);
.anyRequest().authenticated() at the end means “any request not matched by a previous rule must be authenticated.” It’s your safety net — new endpoints are automatically protected.
For maximum security: .anyRequest().denyAll() rejects everything not explicitly permitted:
.requestMatchers("/api/products").permitAll()
.requestMatchers("/api/orders").hasRole("USER")
.anyRequest().denyAll() // unknown endpoints → 403, not just 401
requestMatchers: Pattern Types
Ant Patterns
.requestMatchers("/api/**") // any path starting with /api/
.requestMatchers("/api/products") // exact match
.requestMatchers("/api/products/**") // /api/products/1, /api/products/1/reviews, etc.
.requestMatchers("/api/*/profile") // /api/alice/profile, /api/bob/profile
** matches zero or more path segments. * matches exactly one path segment.
HTTP Method + Path
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/products").hasRole("ADMIN")
.requestMatchers(HttpMethod.PUT, "/api/products/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.PATCH, "/api/products/**").hasRole("ADMIN")
This pattern makes GET public (browsing is open) while all write operations require admin.
Multiple Patterns
.requestMatchers("/login", "/register", "/forgot-password", "/reset-password")
.permitAll()
Regex Patterns
.requestMatchers(new RegexRequestMatcher("/api/v[0-9]+/.*", "GET"))
.permitAll()
Authorization Expressions
| Expression | Meaning |
|---|---|
permitAll() | Allow everyone (authenticated or not) |
denyAll() | Deny everyone |
authenticated() | Must be authenticated (not anonymous) |
anonymous() | Must be anonymous (not authenticated) |
hasRole("ADMIN") | Must have ROLE_ADMIN authority |
hasAnyRole("ADMIN", "MANAGER") | Must have any of the listed roles |
hasAuthority("product:write") | Must have exact authority string |
hasAnyAuthority("a", "b") | Must have any of the listed authorities |
rememberMe() | Must be authenticated via remember-me |
fullyAuthenticated() | Must be authenticated, not via remember-me |
Role vs Authority
hasRole("ADMIN") automatically prepends ROLE_ and checks for ROLE_ADMIN.
hasAuthority("ROLE_ADMIN") checks the exact string (same result in this case).
hasAuthority("product:write") checks the exact string product:write (no prefix).
// These are equivalent:
.hasRole("ADMIN")
.hasAuthority("ROLE_ADMIN")
// These are different:
.hasRole("product:write") // checks for ROLE_product:write
.hasAuthority("product:write") // checks for product:write
Use hasRole() for role-based control (ADMIN, USER, MANAGER).
Use hasAuthority() for fine-grained permission-based control (product:write, order:read).
Rule Ordering Matters
Rules are evaluated top-down, first match wins. More specific rules must come before less specific ones:
http.authorizeHttpRequests(auth -> auth
// CORRECT ORDER — specific before general
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/users/me").authenticated()
.requestMatchers("/api/users/**").hasRole("ADMIN") // user management (admin only)
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers("/api/**").authenticated()
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated()
);
// WRONG — general rule before specific (specific rule never reached)
.requestMatchers("/api/**").authenticated() // matches everything in /api/ first
.requestMatchers("/api/auth/**").permitAll() // NEVER REACHED — /api/auth/** already matched above
RequestCache: Saving Redirected Requests
When an unauthenticated user tries to access /dashboard, Spring Security redirects them to /login. After successful login, the user should land on /dashboard, not the root. RequestCache handles this:
http.requestCache(cache -> cache
.requestCache(new HttpSessionRequestCache()) // default: stores in session
);
// Or disable if you handle redirects yourself (stateless APIs)
http.requestCache(cache -> cache.disable());
For stateless APIs, always disable RequestCache — there’s no session to store it in:
http.requestCache(cache -> cache.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
Checking IP Address
Restrict certain endpoints to specific IPs (e.g., admin only from the internal network):
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**")
.access(new WebExpressionAuthorizationManager(
"hasRole('ADMIN') and hasIpAddress('10.0.0.0/8')"
))
.anyRequest().authenticated()
);
SpEL expressions available via WebExpressionAuthorizationManager:
hasIpAddress('192.168.1.1')— exact IPhasIpAddress('192.168.1.0/24')— CIDR range
Dynamic Authorization Rules
For rules that come from a database (e.g., admin can configure which roles can access which paths):
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.anyRequest().access(dynamicAuthorizationManager)
);
return http.build();
}
@Component
public class DynamicAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {
private final AccessRuleRepository ruleRepository;
@Override
public AuthorizationDecision check(
Supplier<Authentication> authentication,
RequestAuthorizationContext context
) {
String path = context.getRequest().getRequestURI();
String method = context.getRequest().getMethod();
// Load rules from DB (cache this in production)
List<AccessRule> rules = ruleRepository.findByPathAndMethod(path, method);
Authentication auth = authentication.get();
for (AccessRule rule : rules) {
if (rule.isPublic()) return new AuthorizationDecision(true);
if (!auth.isAuthenticated()) return new AuthorizationDecision(false);
if (auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals(rule.getRequiredAuthority()))) {
return new AuthorizationDecision(true);
}
}
// Default: deny
return new AuthorizationDecision(false);
}
}
Complete REST API Authorization Example
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // enables @PreAuthorize etc. for method-level security
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// Public — no authentication
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/categories/**").permitAll()
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
// Authenticated users
.requestMatchers(HttpMethod.GET, "/api/orders/my").authenticated()
.requestMatchers(HttpMethod.POST, "/api/orders").authenticated()
.requestMatchers("/api/users/profile/**").authenticated()
// Manager role
.requestMatchers(HttpMethod.GET, "/api/orders/**").hasAnyRole("MANAGER", "ADMIN")
.requestMatchers("/api/reports/**").hasAnyRole("MANAGER", "ADMIN")
// Admin only
.requestMatchers(HttpMethod.POST, "/api/products/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.PUT, "/api/products/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/actuator/**").hasRole("ACTUATOR_ADMIN")
// Deny everything else
.anyRequest().authenticated()
);
return http.build();
}
}
Summary
mindmap
root((HTTP Authorization))
Deny by Default
anyRequest authenticated or denyAll
New endpoints protected automatically
Rule Order
Specific before general
First match wins
requestMatchers
Ant patterns
HTTP method + path
Regex patterns
Expressions
permitAll denyAll
authenticated anonymous
hasRole hasAuthority
hasIpAddress
Advanced
Dynamic rules from DB
AuthorizationManager interface
Disable RequestCache for stateless
- Rules execute top-down — first match wins. Put specific rules before general ones.
.anyRequest().authenticated()is your default-deny safety net. Never leave it off.hasRole("ADMIN")prependsROLE_;hasAuthority("product:write")checks exact string.- For stateless APIs: disable
RequestCacheandSessionCreationPolicy.STATELESS. - Implement
AuthorizationManagerfor dynamic, database-driven access rules.
Next: Article 17 covers role-based access control in depth — role hierarchies, dynamic role loading, and permission-based authority design.