Implementing Endpoint Security in Spring Boot

tags

In our previous articles, Securing Spring Boot Applications - From Requirements to Implementation and Building a Flexible Permission System, we explored how we structured our permissions and authorities. Now we’ll examine how we put these permissions into practice to secure our application’s endpoints. We’ll also discover how we developed a unique validation system to ensure comprehensive security coverage.

The Security Implementation Challenge

When implementing security in a Spring Boot application, we face several key decisions that will affect our application’s maintainability, testability, and security posture. One fundamental choice is whether to implement security at the controller level (securing HTTP endpoints) or at the method level (securing service methods).

Both approaches have their merits:

  • Method-level security using @PreAuthorize annotations offers fine-grained control and can secure internal service calls
  • Controller-level security provides a clear security boundary at our API layer and keeps security concerns separate from business logic

After careful consideration, we chose controller-level security for our Construction Project Diary system. Let’s explore why this decision made sense for our use case and how we implemented it. More on this topic can be read in the Controller (web request, antMatcher) security vs method(service) level security stack overflow answer.

Why Controller-Level Security?

Our choice of controller-level security was driven by three key architectural benefits:

  1. Explicit Security by Default: By using a deny-all catchall rule, we force ourselves to make conscious decisions about securing each endpoint. This prevents accidentally exposing endpoints without proper security checks.

  2. Separation of Concerns: Keeping security configuration separate from our business logic makes both easier to understand and maintain. When reviewing security rules, we can focus on our security configuration without diving into service implementations.

  3. Type-Safe Security Expressions: By defining security rules in Java code rather than string annotations, we gain compile-time checking and better IDE support. This helps us catch security misconfigurations early in the development process.

Building Our Security Framework

To implement our security strategy, we developed a modular approach using Spring Security’s configuration capabilities. Let’s examine each component of our solution:

The Security Configuration Foundation

Our security configuration starts with the WebSecurityConfigurerAdapter, which serves as the central point for defining security rules. However, rather than placing all security logic in this class, we developed a modular system where each API defines its own security rules.

This modular approach offers several advantages:

  • Security rules stay close to the endpoints they protect
  • Changes to endpoint security can be reviewed alongside API changes
  • Security configurations can be unit tested independently
@Configuration  
@EnableWebSecurity  
@AllArgsConstructor
public class EsdSecurityConfiguration extends WebSecurityConfigurerAdapter {
 
    private final Set<ApiSecurity<?>> apiSecurities;
 
	@Override  
	protected void configure(HttpSecurity http) throws Exception {  
	    super.configure(http);  
	  
	    http  
	            .authorizeRequests(this::authorizeRequests)
 
	private void authorizeRequests(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry authorize) {  
	    authorize.expressionHandler(expressionHandler());  
	  
	    // spring  
	    authorize.antMatchers("/error").permitAll();  
	 
	    // public  
	    authorize.antMatchers("/public/**").permitAll();  
	    authorize.antMatchers("/health-check").permitAll();  
	   
	    // esd  
	    apiSecurities.forEach(configurer ->  
	            configurer.authorizeRequests(authorize)  
	    );  
	  
	    // deny all other requests  
	    authorize.anyRequest().denyAll();  
	}
}

API-Specific Security Rules

For each API in our system, we create a corresponding security configuration class that implements our ApiSecurity interface. This approach allows us to:

  • Define security rules specific to each domain area
  • Keep security configurations organized and maintainable
  • Reuse common security patterns through base classes

Our domain security configurations extend from a base DomainApiSecurity class that provides common CRUD operation security patterns. This reduces duplication while maintaining flexibility for custom security rules.

public interface ApiSecurity<API> {  
  
    /**  
     * Authorize requests for a specific {@link API}  
     *  
     * @param authorize - the object obtained from {@link HttpSecurity#authorizeRequests()}  
     */  
    void authorizeRequests(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry authorize);  
  
    /**  
     * Construct a permission name in form CATEGORY_SCOPE     */    default String provides(PermissionCategory category, PermissionScope scope) {  
        return Permission.valueOf(category.name() + "_" + scope.name()).name();  
    }  
  
}
 
public abstract class DomainApiSecurity<  
        ROOT extends Domain<ROOT>,  
        API extends DomainApi<ROOT, ?, ?, ?, ?, ?>  
        > extends AbstractApiSecurity<API> {  
  
    protected DomainApiSecurity(PermissionCategory module) {  
        super(module);  
    }  
  
    public void authorizeRequests(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry authorize) {  
        authorize  
                .antMatchers(GET,    prefix + "/{id}"   ).hasAuthority(provides(DomainPermissionScope.DETAIL)) // get  
                .antMatchers(POST,   prefix               ).hasAuthority(provides(DomainPermissionScope.CREATE)) // create  
                .antMatchers(PUT,    prefix + "/{id}"   ).hasAuthority(provides(DomainPermissionScope.UPDATE)) // update  
                .antMatchers(DELETE, prefix + "/{id}"   ).hasAuthority(provides(DomainPermissionScope.DELETE)) // delete  
                .antMatchers(POST,   prefix + "/list/**").hasAuthority(provides(DomainPermissionScope.LIST))   // list  
                .and();  
    }
}

Building Type-Safe Security Expressions

Spring Expression Language (SpEL) is powerful tool, but prone to runtime errors when misconfigured. To address this, we adapted an open-source github project spel-builder that allows us to construct security expressions in Java code. This gives us:

  • Compile-time verification of security expressions
  • IDE auto-completion support
  • Easier refactoring of security rules
// SpEL as a string
.antMatchers("/**").access("hasAuthority(PRIMARY_JOURNAL_DETAIL) or hasAuthority(PRIMARY_JOURNAL_LIST)"); 
 
// SpEL as builder
.antMatchers("/**").access(or(hasAuthority(provides(DETAIL), provides(LIST))).build());
 
// also custom SpEL methods
.antMatchers("/**").access("hasAssigned(PRIMARY_JOURNAL, #id)"); 
 
// also custom SpEL methods as builder
.antMatchers("/**").access(hasAssigned(AssignmentType.PRIMARY_JOURNAL, "id").build());

The Missing Piece: Security Validation

While our security architecture was solid, we still faced a critical challenge: How could we ensure that every endpoint was properly secured? A single overlooked endpoint would throw 403 Forbidden until a security rule is defined.

This led us to develop our security validation system, which verifies security coverage at application startup. This system:

  1. Discovers all endpoints in our application using Spring’s RequestMappingHandlerMapping
  2. Extracts all security rules from our security configuration
  3. Verifies that each endpoint has corresponding security rules
  4. Prevents application startup if any endpoints lack security configuration

Understanding the Validation Process

Our validator works by comparing two sets of information:

  1. The complete set of endpoints registered with Spring MVC
  2. The security rules we’ve defined in our security configurations

If any endpoint lacks corresponding security rules, the validator prevents the application from starting and provides detailed information about the unsecured endpoints. This fail-fast approach ensures security issues are caught during development or deployment, not in production.

@Configuration  
public class AuthorizedRequestsValidator {  
  
    @Lazy  
    @Autowired    private RequestMappingHandlerMapping handlerMapping;  
  
    /**  
     * Validate that all endpoints are secured     * <p>  
     * Since we use .anyRequest().denyAll() in our security configuration, we need to make sure that all endpoints are secured  
     * This method will compare all controller endpoints with the security configuration     * and thow an exception if some endpoint is not secured     */    public void checkThatAllEndpointsAreRegistered(HttpSecurity http) throws Exception {  
        checkThatAllEndpointsAreRegistered(http.authorizeRequests());  
    }
 
	private void checkThatAllEndpointsAreRegistered(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry authorize) throws Exception {  
	    List<UrlMapping> securityMappings = extractUrlMappings(authorize);  
	    Set<Endpoint> endpoints = extractEndpoints();  
	  
	    StringBuilder sb = new StringBuilder();  
	    for (Endpoint endpoint: endpoints) {  
	        if (!hasDefinedSecurity(endpoint, securityMappings)) {  
	            String msg = "Endpoint is not secured: %s %s. (Hint: Define security for that endpoint in corresponding ApiSecurity)";  
	            sb.append(String.format(msg, endpoint.method, endpoint.path)).append("\n");  
	        }  
	    }  
	  
	    if (sb.length() > 0) {  
	        log.error("Some endpoints are not secured:\n{}", sb);  
	        throw new ValidationException("Some endpoints are not secured. Check the logs for more details.");  
	    }  
	}

extractEndpoints()

Getting the endpoints was the easy part. RequestMappingHandlerMapping contains all the controllers and the endpoints. From there all we needed to do was to find the @RequestMapping of the controller, join it with the @RequestMapping of the method along with the HttpMethod. The result is the Endpoint DTO Endpoint(method=GET, path="/journal/primary/hello")

extractUrlMappings()

The hard part was getting the security mappings. Spring security classes are final, package-private, getter-less, etc. For example http.authorizeRequests() returns a public final ExpressionInterceptUrlRegistry, which is an inner class.

The source code didn’t appear to contain the registered urls. However, after debugging the code I found out that it did have List<UrlMapping> urlMappings field, which was in AbstractConfigAttributeRequestMatcherRegistry. Via the power of reflection I was able to obtain the UrlMappings that I defined. Again, the UrlMapping is a package-private class so I had to expose it by mapping it to the exactly same class

@Value  
protected static class UrlMapping {  
    RequestMatcher requestMatcher;  
    Collection<ConfigAttribute> configAttrs;  
}

Even though the validation probably isn’t perfect, I really like that the app does not start until I define the security properly.

Lessons Learned and Best Practices

Through developing and using this security system, we’ve learned several valuable lessons:

  1. Explicit is Better Than Implicit: Our deny-by-default approach with explicit security rules makes our security posture clear and auditable.

  2. Validate Early and Often: The security validator has caught numerous potential security holes during development, proving the value of automated security verification.

  3. Type Safety Matters: Using Java code for security expressions instead of strings has prevented many potential runtime errors.

  4. Modularity Enables Growth: Our modular approach to security configuration has scaled well as our application has grown.

Looking Forward

While our current implementation serves our needs well, we’ve identified several areas for future enhancement:

  1. Extending our validator to check for logical security rule conflicts
  2. Adding support for more complex security patterns
  3. Developing tools to analyze and visualize our security configuration
  4. Adding support for parent scopes (users with READ scope can access LIST scope)

Understanding how to implement and validate security effectively is crucial for any enterprise application. By sharing our approach, we hope to contribute to the broader discussion of how to build secure, maintainable Spring Boot applications.

References

Controller (web request, antMatcher) security vs method(service) level security Spring Expression Language (SpEL) spel-builder