Spring Security Fine-Grained Access: Custom Expressions: Beyond hasRole()





Spring Security Fine-Grained Access: Custom Expressions: Beyond hasRole()| www.codegigs.app






Custom Spring Security Expressions: Why hasRole() Isn’t Enough

You start with @PreAuthorize("hasRole('ADMIN')"). It feels great. Clean. Simple.

Then your Product Manager walks in. “Hey, users should only be able to edit their own posts. Oh, unless they are a moderator for that specific topic. Or if they are in the ‘Editors’ group but only on weekdays.”

Suddenly, your clean annotations turn into this monstrosity:

@PreAuthorize("hasRole('ADMIN') or authentication.name == #post.owner or ...") // chaos

Or worse, you start shoving if statements into your service methods, mixing business logic with security checks. I did this for years. It’s a maintenance nightmare.

There is a better way. You can write your own custom security expressions. Imagine replacing that mess with this:

@PreAuthorize("canEditPost(#postId)")

At www.codegigs.app, we use this pattern to handle complex ownership rules without cluttering our controllers. Here is how you build it from scratch in Spring Boot 3.

The Goal: Declarative Domain Security

We want to use a custom method in our SpEL (Spring Expression Language) annotations. To do this, we need to hack into Spring Security’s expression handling mechanism.

It sounds scary, but it’s really just two classes.

Step 1: The Security Root

This class defines the actual methods you want to use in your annotations. It needs to extend SecurityExpressionRoot and implement MethodSecurityExpressionOperations.

// CustomSecurityExpressionRoot.java
// Spring Boot 3.2.1, Spring Security 6.2.0
package app.codegigs.security;

import org.springframework.security.access.expression.SecurityExpressionRoot;
import org.springframework.security.access.expression.method.MethodSecurityExpressionOperations;
import org.springframework.security.core.Authentication;

import app.codegigs.services.PostService;

public class CustomSecurityExpressionRoot extends SecurityExpressionRoot 
        implements MethodSecurityExpressionOperations {

    private final PostService postService;

    public CustomSecurityExpressionRoot(Authentication authentication, PostService postService) {
        super(authentication);
        this.postService = postService;
    }

    // This is the method we will use in @PreAuthorize
    public boolean canEditPost(Long postId) {
        String currentUser = this.getAuthentication().getName();
        
        // 1. Database lookup to find owner
        // In production, cache this look up!
        String owner = postService.getOwnerByPostId(postId);
        
        // 2. Complex logic kept strictly in security layer
        return currentUser.equals(owner) || hasRole("ADMIN");
    }

    // Boilerplate overrides required by interface
    @Override
    public void setFilterObject(Object filterObject) {}

    @Override
    public Object getFilterObject() { return null; }

    @Override
    public void setReturnObject(Object returnObject) {}

    @Override
    public Object getReturnObject() { return null; }

    @Override
    public Object getThis() { return null; }
}

Lines 22-31 are where the magic happens. You have full access to your beans (like PostService) and the current authentication. You can run any logic you want here.

Step 2: The Handler Factory

Now we need a factory to tell Spring “Hey, use my custom root object instead of the default one.”

// CustomMethodSecurityExpressionHandler.java
package app.codegigs.security;

import org.aopalliance.intercept.MethodInvocation;
import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler;
import org.springframework.security.access.expression.method.MethodSecurityExpressionOperations;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;

import app.codegigs.services.PostService;

@Component
public class CustomMethodSecurityExpressionHandler extends DefaultMethodSecurityExpressionHandler {

    private final PostService postService;

    public CustomMethodSecurityExpressionHandler(PostService postService) {
        this.postService = postService;
    }

    @Override
    protected MethodSecurityExpressionOperations createSecurityExpressionRoot(
            Authentication authentication, MethodInvocation invocation) {
            
        CustomSecurityExpressionRoot root = new CustomSecurityExpressionRoot(authentication, postService);
        
        // This is crucial. Without this, standard expressions like hasRole() might break
        root.setPermissionEvaluator(getPermissionEvaluator());
        root.setTrustResolver(getTrustResolver());
        root.setRoleHierarchy(getRoleHierarchy());
        
        return root;
    }
}

I wasted 4 hours debugging a null pointer once because I forgot lines 27-29. You must pass the existing evaluators to your new root, or everything else falls apart.

Step 3: Register the Handler

Finally, plug it into your configuration.

// SecurityConfig.java
package app.codegigs.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;

import app.codegigs.security.CustomMethodSecurityExpressionHandler;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public MethodSecurityExpressionHandler methodSecurityExpressionHandler(
            CustomMethodSecurityExpressionHandler customHandler) {
        return customHandler;
    }
}

Using It (The fun part)

Now your controllers stay clean.

// PostController.java
@PutMapping("/posts/{id}")
@PreAuthorize("canEditPost(#id)")
public void updatePost(@PathVariable Long id, @RequestBody PostDto dto) {
    service.update(id, dto);
}

This is readable. It’s testable. And if the logic for “can edit post” changes, you update it in one place, not in 50 different controllers.

Performance Warning

This method canEditPost runs before the controller method. That means if you query the database inside canEditPost, and then your service queries the database again to fetch the post for updating, you just doubled your IO.

This is a common performance trap. We discuss caching strategies for security lookups extensively in the Performance Tuning module at www.codegigs.app.

Common Mistakes

  • Circular Dependencies: If you inject PostService into your security handler, but PostService uses @PreAuthorize, Spring will crash on startup. Use @Lazy injection or separate your read-only logic into a different service.
  • Private Methods: As always with Spring AOP, @PreAuthorize doesn’t work on private methods.

Take Control of Authorization

Ready to build a complete ACL system or handle multi-tenant security? Join 50,000+ developers mastering the framework.

Start the Spring Security Masterclass at www.codegigs.app →

Conclusion

Custom security expressions are the difference between a spaghetti-code mess and a professional, domain-driven security layer. Start small—maybe just an isOwner() check—and expand as your app grows.

If you need to understand the basics of method security before diving into this, check out our guide on How to Check User Roles in Java.


Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top