Custom Evaluators
Custom evaluators extend webforJ's security system with specialized access control logic beyond basic authentication and role checks. Use them when you need to verify dynamic conditions that depend on request context, not just user permissions.
This guide covers custom evaluators for Spring Security. If you're not using Spring Boot, see the Evaluator Chain guide to understand how evaluators work and Complete Implementation for a working example.
What are custom evaluators
An evaluator determines whether a user can access a specific route based on custom logic. Evaluators are checked during navigation before any component is rendered, allowing you to intercept and control access dynamically.
webforJ includes built-in evaluators for standard Jakarta annotations:
AnonymousAccessEvaluator- Handles@AnonymousAccessPermitAllEvaluator- Handles@PermitAllRolesAllowedEvaluator- Handles@RolesAllowedDenyAllEvaluator- Handles@DenyAll
Custom evaluators follow the same pattern, allowing you to create your own annotations and access control logic.
For details on @AnonymousAccess, @PermitAll, @RolesAllowed, and @DenyAll, see the Security Annotations guide.
Use case: Ownership verification
A common requirement is allowing users to access only their own resources. For example, users should only be able to edit their own profile, not someone else's profile.
The problem: @RolesAllowed("USER") grants access to all authenticated users, but doesn't verify if the user is accessing their own resource. You need to compare the logged-in user ID with the resource ID in the URL.
Example scenario:
- User ID
123is logged in - They navigate to
/users/456/edit - Should they access this page? NO - they can only edit
/users/123/edit
You can't solve this with roles because it depends on the route parameter :userId, which changes for every request.
Creating a custom annotation
Define an annotation to mark routes that require ownership verification:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireOwnership {
/**
* The route parameter name that contains the user ID.
*/
String value() default "userId";
}
Use it on routes that require ownership checks:
@Route(value = "/users/:userId/edit", outlet = MainLayout.class)
@RequireOwnership("userId")
public class EditProfileView extends Composite<Div> {
private final Div self = getBoundComponent();
public EditProfileView() {
self.setText("Edit Profile Page");
}
}
Implementing the evaluator
Create a Spring-managed evaluator that compares the logged-in user ID with the route parameter:
@RegisteredEvaluator(priority = 10)
public class OwnershipEvaluator implements RouteSecurityEvaluator {
@Override
public boolean supports(Class<?> routeClass) {
return routeClass.isAnnotationPresent(RequireOwnership.class);
}
@Override
public RouteAccessDecision evaluate(Class<?> routeClass, NavigationContext context,
RouteSecurityContext securityContext, SecurityEvaluatorChain chain) {
// First check authentication
if (!securityContext.isAuthenticated()) {
return RouteAccessDecision.denyAuthentication();
}
// Get the annotation
RequireOwnership annotation = routeClass.getAnnotation(RequireOwnership.class);
String paramName = annotation.value();
// Get logged-in user ID from security context
String currentUserId = securityContext.getPrincipal()
.filter(p -> p instanceof UserDetails)
.map(p -> ((UserDetails) p).getUsername())
.orElse(null);
// Get :userId from route parameters
String requestedUserId = context.getRouteParameters()
.get(paramName)
.orElse(null);
// Check if they match
if (currentUserId != null && currentUserId.equals(requestedUserId)) {
// Ownership verified - continue chain to allow other evaluators
return chain.evaluate(routeClass, context, securityContext);
}
return RouteAccessDecision.deny("You can only access your own resources");
}
}
Spring automatically discovers and registers evaluators annotated with @RegisteredEvaluator.
How it works
The evaluator implementation has two key methods:
supports(Class<?> routeClass)
- Returns
trueif this evaluator should handle the route - Only evaluators that return
truewill be invoked for the route - Filters routes by checking for the
@RequireOwnershipannotation
evaluate(...)
- Checks if the user is authenticated first
- Gets the logged-in user ID from
securityContext.getPrincipal() - Gets the route parameter value from
context.getRouteParameters().get(paramName) - Compares the two IDs
- If they match, delegates to
chain.evaluate()to allow other evaluators to run - If they don't match, returns
deny()with a reason
Flow example
When ownership check fails:
- User
123logs in and navigates to/users/456/edit OwnershipEvaluator.supports()returnstrue(route has@RequireOwnership)OwnershipEvaluator.evaluate()runs:currentUserId = "123"(from security context)requestedUserId = "456"(from route parameter:userId)"123".equals("456")→false- Returns
RouteAccessDecision.deny("You can only access your own resources")
- User is redirected to access denied page
When ownership check passes:
- User
123logs in and navigates to/users/123/edit OwnershipEvaluator.evaluate()runs:currentUserId = "123",requestedUserId = "123"- IDs match → calls
chain.evaluate()to continue
- If no other evaluators deny access, the user is granted access
Understanding the evaluator chain
The security system uses a chain of responsibility pattern where evaluators are processed in priority order. Evaluators can either make terminal decisions or delegate to the chain for combining multiple checks.
How the chain works
- Evaluators are sorted by priority (lower numbers first)
- For each evaluator,
supports(routeClass)is called to check if it applies - If
supports()returnstrue, the evaluator'sevaluate()method is called - The evaluator can either:
- Return a terminal decision (
grant()ordeny()) - stops the chain - Delegate to the chain by calling
chain.evaluate()- allows other evaluators to run
- Return a terminal decision (
- If the chain completes without a decision and secure-by-default is enabled, unauthenticated users are denied
Terminal decisions
Stop the chain immediately:
RouteAccessDecision.grant()
- Grants access and stops further evaluation
- Used by
@AnonymousAccessand@PermitAll- these are complete authorizations that don't combine with other checks
RouteAccessDecision.deny(reason)
- Denies access and stops further evaluation
- Used by
@DenyAlland when custom checks fail - Example:
RouteAccessDecision.deny("You can only access your own resources")
RouteAccessDecision.denyAuthentication()
- Redirects to login page
- Used when authentication is required but missing
Chain delegation
Allows combining checks:
chain.evaluate(routeClass, context, securityContext)
- Passes control to the next evaluator in the chain
- Enables combining multiple authorization checks
- Used by
@RolesAllowedand@RouteAccessafter their checks pass - Custom evaluators should use this pattern when checks pass to allow composition
Evaluator priority
Evaluators are checked in priority order (lower numbers first). Framework evaluators use priority 1-9, custom evaluators should use 10 or higher.
Built-in evaluators are registered in this order:
// Priority 1: @DenyAll - blocks everything
// Priority 2: @AnonymousAccess - allows unauthenticated access
// Priority 3: AuthenticationRequiredEvaluator - ensures auth for @PermitAll/@RolesAllowed
// Priority 4: @PermitAll - requires authentication only
// Priority 5: @RolesAllowed - requires specific roles
// Priority 6: @RouteAccess - SpEL expressions (Spring Security only)
// Priority 10+: Custom evaluators (like @RequireOwnership)
How priority affects evaluation
- Lower priority evaluators run first and can "short-circuit" the chain
@DenyAll(priority 1) runs first - if present, access is always denied@AnonymousAccess(priority 2) runs next - if present, access is always granted (even without auth)AuthenticationRequiredEvaluator(priority 3) checks if the route needs auth and user is authenticated- If no evaluator handles the route, secure-by-default logic applies
Setting priority
Set priority with the @RegisteredEvaluator annotation:
@RegisteredEvaluator(priority = 10) // Runs after built-in evaluators
public class OwnershipEvaluator implements RouteSecurityEvaluator {
// ...
}
Custom evaluators should use priority 10 or higher. Priorities 1-9 are reserved for framework evaluators. If you use a priority in the reserved range, you'll get a warning in the logs.
Combining evaluators
Evaluators that delegate to the chain can be combined to create complex authorization logic. Routes can have multiple security annotations:
Combining role checks with custom logic
@Route("/users/:userId/settings")
@RolesAllowed("USER")
@RequireOwnership("userId")
public class UserSettingsView extends Composite<Div> {
// Must have USER role AND be accessing their own settings
}
How it works:
RolesAllowedEvaluator(priority 5) checks if the user has the "USER" role- If yes, calls
chain.evaluate()to continue OwnershipEvaluator(priority 10) checks ifuserIdmatches the logged-in user- If yes, calls
chain.evaluate()to continue - Chain ends → access granted
Combining SpEL expressions with custom logic
@Route("/admin/users/:userId/edit")
@RouteAccess("hasRole('ADMIN')")
@RequireOwnership("userId")
public class AdminEditUserView extends Composite<Div> {
// Must be admin AND accessing their own account
}
What can't be combined
@AnonymousAccess and @PermitAll make terminal decisions - they immediately grant access without calling the chain. You can't combine them with custom evaluators:
// @PermitAll grants access immediately, @RequireOwnership never runs
@Route("/users/:userId/profile")
@PermitAll
@RequireOwnership("userId")
public class ProfileView extends Composite<Div> {
// ...
}
For resources that all authenticated users can access, use @RolesAllowed with a common role instead:
// @RolesAllowed delegates to chain
@Route("/users/:userId/profile")
@RolesAllowed("USER")
@RequireOwnership("userId")
public class ProfileView extends Composite<Div> {
// Must be an authenticated user AND accessing their own profile
}