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

ExpressionMeaning
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 IP
  • hasIpAddress('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") prepends ROLE_; hasAuthority("product:write") checks exact string.
  • For stateless APIs: disable RequestCache and SessionCreationPolicy.STATELESS.
  • Implement AuthorizationManager for 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.