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
PostServiceinto your security handler, butPostServiceuses@PreAuthorize, Spring will crash on startup. Use@Lazyinjection or separate your read-only logic into a different service. - Private Methods: As always with Spring AOP,
@PreAuthorizedoesn’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.
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.