Mastering Custom Security Expressions in Spring Security
Go beyond @PreAuthorize with custom security expressions.
20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.
- ✓Basic knowledge of Spring Boot and Spring Security
- ✓Familiarity with @PreAuthorize and method security
- ✓Understanding of dependency injection in Spring
- Custom security expressions enable fine-grained, reusable authorization logic beyond EL-based SpEL.
- Implement
SecurityExpressionRootand register withMethodSecurityExpressionHandler. - Use
@PreAuthorizewith custom methods like@MyOrgAuth.check('PAYMENT_ADMIN'). - Avoid common pitfalls like missing bean registration and thread-local leaks.
- Production debugging requires careful logging of expression evaluation.
Think of Spring Security's default expressions like a hotel key card that only opens your room. Custom expressions are like a master key system where you can define exactly which doors each employee can open — based on their role, the time of day, or even how many guests they've checked in. You write the rules once, then use them everywhere.
If you've built any non-trivial Spring Boot application, you've probably used @PreAuthorize("hasRole('ADMIN')") or @PostAuthorize("returnObject.owner == authentication.name"). These built-in SpEL expressions work fine for simple checks — but they fall apart fast when you need multi-tenant authorization, resource-level permissions, or rules that involve external data.
I've seen teams try to cram complex logic into SpEL expressions, resulting in unreadable, untestable messes. One client had a 200-character SpEL expression that checked three different services — it was a debugging nightmare. The better approach: custom security expressions.
In this article, I'll show you how to build, register, and debug custom security expressions in Spring Security 6+. You'll learn the architecture, see real code for a SaaS billing system, and get the hard-won lessons from production incidents. By the end, you'll be able to replace those tangled SpEL expressions with clean, reusable methods that are easy to test and maintain.
Why Custom Security Expressions?
Spring Security provides a handful of built-in expressions like hasRole(), hasAuthority(), and hasPermission(). These work for simple role-based checks, but real-world authorization often requires more nuance. For example, in a multi-tenant SaaS billing system, you might need to check: 'Is this user an admin of the organization that owns this invoice?' Or 'Does this user have the 'REFUND' permission for the payment method used?'
Sure, you could write a service method that performs these checks and call it from your controller. But that scatters authorization logic across your codebase, making it hard to audit and maintain. Custom security expressions let you encapsulate that logic in a single place and use it declaratively with @PreAuthorize.
Here's the hard truth: most teams get this wrong. They either overcomplicate SpEL expressions or give up and write imperative checks everywhere. I've seen a codebase where every single controller method started with if (!permissionService.canAccess(user, resource)) — that's a maintenance nightmare and a security audit's worst enemy.
Custom expressions give you the best of both worlds: declarative security with the full power of Java. You can inject any Spring bean, make database calls, or even call external services from within your expression methods. But with great power comes great responsibility — you need to understand the architecture to avoid the pitfalls we'll cover later.
Architecture: How Spring Security Evaluates Expressions
Understanding the evaluation pipeline is crucial for debugging. When you annotate a method with @PreAuthorize("@myBean.check(#id)"), Spring Security uses a MethodSecurityInterceptor that evaluates the expression before method invocation. The expression is parsed by SpEL (Spring Expression Language) and executed against a root object that implements MethodSecurityExpressionOperations.
By default, Spring Security uses SecurityExpressionRoot as the root object. To add custom methods, you need to: 1. Extend SecurityExpressionRoot (or implement MethodSecurityExpressionOperations directly). 2. Create a custom MethodSecurityExpressionHandler that returns your root object. 3. Register that handler as a bean.
Here's the part that trips up many developers: the expression handler must be registered as a bean, and the root object must be created fresh for each evaluation. I once spent three hours debugging a case where the root object was reused across threads — the Authentication object was stale, causing random 403s.
Spring Security 6 introduced @EnableMethodSecurity which simplifies method security configuration. But it also changed how custom handlers are discovered. In older versions, you could just define a bean of type MethodSecurityExpressionHandler and it would be picked up automatically. In Spring Security 6, you need to be more explicit, especially if you're using the annotation-based approach.
Let's look at the registration code.
Injecting Services into Custom Expressions
The real power of custom expressions comes from injecting Spring beans into your root object. For example, you might need a UserRepository to look up user permissions, or a BillingService to check subscription status.
The trick is to make your root object a Spring-managed bean itself, or to inject dependencies via the handler. I prefer the latter because it keeps the root object focused on evaluation and avoids circular dependencies.
Here's the pattern: make your root class a @Component with injected services, then in the handler, get an instance of the root from the application context. But be careful — the root object should be prototype-scoped because each evaluation needs a fresh instance with the current Authentication.
Let me show you the cleanest approach I've used in production.
Using Custom Expressions in Controllers and Services
Once your custom expression handler is set up, using the expressions is straightforward. You reference them by method name in @PreAuthorize, @PostAuthorize, @PreFilter, or @PostFilter.
Here's the syntax: @PreAuthorize("isOrgAdmin(#orgId)") where #orgId is a method parameter. Spring Security automatically maps method parameters to SpEL variables.
But there's a nuance: if your method name conflicts with a built-in expression like hasPermission, you'll need to be explicit. I recommend using a prefix like my or your company abbreviation to avoid clashes.
Let's see this in action with a real controller for a billing API.
@PreAuthorize checks to fail silently because SpEL couldn't resolve the variable.What the Official Docs Won't Tell You
The Spring Security reference documentation is good, but it glosses over several critical details that you'll only discover in production. Here are the gotchas I've accumulated over the years.
1. Lazy initialization of expression handlers If you define your MethodSecurityExpressionHandler bean in a configuration class that's loaded lazily, your custom expressions won't be available at startup. I once saw an application where the security config was in a @ConditionalOnProperty class that wasn't active in certain profiles — resulting in default expressions being used silently.
2. The @EnableMethodSecurity order matters If you have multiple @Configuration classes with @EnableMethodSecurity, the last one processed wins. This can override your custom handler. Always define your handler in the same class as @EnableMethodSecurity or use @Order to control precedence.
3. SpEL variable resolution with proxy methods When using @PreAuthorize on methods in a class that's proxied (e.g., @Transactional), the parameter names might not be available. Spring resolves parameters using the MethodInvocation object, but if the proxy is a CGLIB proxy without debug info, parameter names become arg0, arg1, etc. Always compile with -parameters flag or use @Param annotation.
4. Thread-local leaks in async scenarios If you use @Async methods with @PreAuthorize, the SecurityContext is propagated via SecurityContextHolder. But if your custom expression accesses request-scoped beans (like HttpServletRequest), those beans won't be available in the async thread. I've debugged cases where expressions worked in the main thread but failed in async tasks.
5. Expression caching Spring Security caches parsed SpEL expressions by default. If your expression uses dynamic values (like method parameters), this is fine. But if your expression logic changes based on external state (e.g., a feature flag), the cached expression might evaluate incorrectly. In that case, disable caching by setting spring.security.expression.caching.enabled=false or implement a custom cache key.
@PreAuthorize caused a NullPointerException because the custom expression tried to access a request-scoped bean. The fix was to extract the required data before the async call and pass it as a parameter.Testing Custom Security Expressions
Testing is where custom expressions really shine. Because they're plain Java methods, you can unit test them with mocked dependencies. Integration testing with @WithMockUser or custom security context is also straightforward.
Here's a common pattern I use: create a test configuration that registers the expression handler, then write tests that verify access control for different scenarios.
But there's a trap: if your expression method calls a service that itself uses @PreAuthorize, you can get into infinite recursion. Always ensure your expression methods don't trigger security checks on the same method.
The Midnight Billing Gateway Outage
SecurityExpressionRoot implementation was not annotated with @Component, and the MethodSecurityExpressionHandler bean was not explicitly registered. Spring Security fell back to its default handler, which didn't recognize the custom methods.@Component to the expression root class and explicitly declared a @Bean for DefaultMethodSecurityExpressionHandler that used the custom root. Redeployed and verified.- Always explicitly register custom expression handlers as beans — don't rely on component scanning.
- Add integration tests that verify custom expressions evaluate correctly with mocked security context.
- Log the evaluated expression and result during debugging to catch registration issues early.
- Use
@ConditionalOnMissingBeanif you need to override default behavior safely. - Document the expression methods with clear names and parameter types to avoid confusion.
curl -X GET http://localhost:8080/actuator/beans | grep expressionCheck logs for 'No bean named' or 'SpelEvaluationException'| File | Command / Code | Purpose |
|---|---|---|
| CustomExpressionRoot.java | public class CustomSecurityExpressionRoot extends SecurityExpressionRoot | Why Custom Security Expressions? |
| SecurityConfig.java | @Configuration | Architecture |
| SecurityConfig.java | @Bean | Injecting Services into Custom Expressions |
| InvoiceController.java | @RestController | Using Custom Expressions in Controllers and Services |
| application.properties | spring.security.expression.caching.enabled=false | What the Official Docs Won't Tell You |
| CustomSecurityExpressionRootTest.java | @ExtendWith(MockitoExtension.class) | Testing Custom Security Expressions |
Key takeaways
Interview Questions on This Topic
How would you implement a custom security expression that checks if the authenticated user is the owner of a resource?
SecurityExpressionRoot and implements MethodSecurityExpressionOperations. Add a method like isOwner(#resourceId) that calls a service to check ownership. Register a custom MethodSecurityExpressionHandler that returns instances of this root. Then use @PreAuthorize("isOwner(#id)") on the controller method.Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.
That's Spring Security. Mark it forged?
4 min read · try the examples if you haven't