Skip to main content
spring internals

Method Security Proxies and SpEL Expression Evaluation

6 min read Chapter 38 of 78

Method Security Proxies and SpEL Expression Evaluation

CH13 introduced AuthorizationManagerBeforeMethodInterceptor and its role in the proxy chain. This section goes deeper into two specifics: how SpEL expressions are parsed, evaluated, and resolved against method parameters, and how the method security interceptor coexists with TransactionInterceptor inside a single proxy.

PreAuthorizeAuthorizationManager and the Expression Handler

When AuthorizationManagerBeforeMethodInterceptor calls check(), it delegates to PreAuthorizeAuthorizationManager. This manager does one thing: evaluate the SpEL expression from the @PreAuthorize annotation.

The evaluation pipeline has three components:

  1. ExpressionParser: Parses the annotation’s string value into a SpEL Expression object. Spring Security uses SpelExpressionParser with a custom MethodSecurityExpressionHandler.
  2. EvaluationContext: Provides the variables and functions available to the expression. MethodSecurityEvaluationContext exposes the Authentication object, method parameters, and security-specific functions.
  3. RootObject: A MethodSecurityExpressionRoot that provides hasRole(), hasAuthority(), hasPermission(), and other built-in methods.

The sequence for @PreAuthorize("hasRole('ADMIN') and #tenantId == authentication.principal.tenantId"):

// Inside PreAuthorizeAuthorizationManager.check()
ExpressionParser parser = handler.getExpressionParser();
Expression expression = parser.parseExpression(attribute.getAttribute());

EvaluationContext ctx = handler.createEvaluationContext(
    authentication, invocation);

boolean granted = ExpressionUtils.evaluateAsBoolean(expression, ctx);

The EvaluationContext is where the expression resolves its variables. MethodSecurityEvaluationContext extends StandardEvaluationContext and adds two critical features: it registers the Authentication as a property on the root object, and it lazily resolves method parameter names.

Parameter Name Resolution: The #paramName Mechanism

When a SpEL expression references #tenantId, the evaluation context must map that name to the correct method argument. This mapping is not automatic from the bytecode alone.

MethodSecurityEvaluationContext uses ParameterNameDiscoverer to resolve parameter names. The default discoverer is DefaultParameterNameDiscoverer, which chains two strategies:

  1. StandardReflectionParameterNameDiscoverer: Uses java.lang.reflect.Parameter.getName(). This only returns meaningful names if the code was compiled with the -parameters flag. Without it, parameter names are arg0, arg1, etc.
  2. LocalVariableTableParameterNameDiscoverer: Reads the LocalVariableTable attribute from the class file’s debug info. This works if the code was compiled with debug info (the default in most build tools), but it was deprecated in Spring Framework 6.1 and is no longer reliable with records and sealed classes.

Here is the failure:

// BROKEN: parameter name not available at runtime
@PreAuthorize("#tenantId == authentication.principal.tenantId")
@Transactional
public List<Order> getOrders(TenantId tenantId) {
    return orderRepository.findByTenantId(tenantId);
}

If compiled without -parameters, StandardReflectionParameterNameDiscoverer returns arg0 for the first parameter. The SpEL expression references #tenantId, which does not exist in the evaluation context. The result depends on Spring Security version:

  • In some versions, #tenantId resolves to null, and null == authentication.principal.tenantId evaluates to false. Access is silently denied.
  • In other versions, a SpelEvaluationException is thrown with the message “EL1008E: Property or field ‘tenantId’ cannot be found.”

Both outcomes are wrong. The first denies legitimate access. The second crashes the request. Neither tells you the root cause is a missing compiler flag.

The fix:

<!-- CORRECT: retain parameter names in compiled classes -->
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <compilerArgs>
            <arg>-parameters</arg>
        </compilerArgs>
    </configuration>
</plugin>

Or with Gradle:

// CORRECT: Gradle equivalent
tasks.withType(JavaCompile).configureEach {
    options.compilerArgs.add('-parameters')
}

With -parameters enabled, Parameter.getName() returns tenantId, the SpEL expression resolves correctly, and the authorization check works as intended.

Spring Boot 3’s parent POM includes -parameters by default. If you use spring-boot-starter-parent, you have it. If you manage your own compiler configuration, you must add it explicitly.

The Expression Root Object: Built-in Security Functions

MethodSecurityExpressionRoot extends SecurityExpressionRoot and provides the functions available in SpEL expressions. These are not standalone functions. They are methods on the root object that the SpEL engine calls.

// What the root object provides
public final boolean hasRole(String role) {
    return hasAnyRole(role);
}

public final boolean hasAnyRole(String... roles) {
    return hasAnyAuthorityName(defaultRolePrefix, roles);
}

public final boolean hasAuthority(String authority) {
    return hasAnyAuthority(authority);
}

public final boolean isAuthenticated() {
    return authentication != null
        && authentication.isAuthenticated()
        && !(authentication instanceof AnonymousAuthenticationToken);
}

The defaultRolePrefix is ROLE_. When you write hasRole('ADMIN'), the root object checks for ROLE_ADMIN in the granted authorities. When you write hasAuthority('ADMIN'), it checks for ADMIN exactly. This distinction causes confusion when migrating from older Spring Security versions where the prefix behavior changed.

For the SaaS backend, a complete authorization expression for tenant-scoped admin operations:

@PreAuthorize("hasRole('TENANT_ADMIN') and #tenantId == authentication.principal.tenantId")
@Transactional
public void updateTenantSettings(TenantId tenantId, TenantSettings settings) {
    tenantRepository.updateSettings(tenantId, settings);
    auditService.log(tenantId, "SETTINGS_UPDATED");
}

This expression requires that the caller has ROLE_TENANT_ADMIN and that the tenantId parameter matches the authenticated user’s tenant. A global admin with ROLE_ADMIN would be denied by this expression unless you compose it differently:

@PreAuthorize("hasRole('ADMIN') or (hasRole('TENANT_ADMIN') and #tenantId == authentication.principal.tenantId)")

Custom Permission Evaluators

When SpEL expressions become complex, extract the logic into a PermissionEvaluator. The built-in hasPermission() function delegates to a registered PermissionEvaluator bean:

@Component
public class SaasTenantPermissionEvaluator implements PermissionEvaluator {

    @Override
    public boolean hasPermission(Authentication authentication,
                                  Object targetDomainObject,
                                  Object permission) {
        if (!(targetDomainObject instanceof TenantId tenantId)) {
            return false;
        }
        SaasUserDetails user = (SaasUserDetails) authentication.getPrincipal();

        return switch (permission.toString()) {
            case "READ" -> user.getTenantId().equals(tenantId)
                           || user.hasRole("ADMIN");
            case "WRITE" -> user.getTenantId().equals(tenantId)
                            && user.hasRole("TENANT_ADMIN");
            default -> false;
        };
    }

    @Override
    public boolean hasPermission(Authentication authentication,
                                  Serializable targetId,
                                  String targetType,
                                  Object permission) {
        // Not used in this application
        return false;
    }
}

Register it with the expression handler:

@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {

    @Bean
    static MethodSecurityExpressionHandler methodSecurityExpressionHandler(
            SaasTenantPermissionEvaluator evaluator) {

        DefaultMethodSecurityExpressionHandler handler =
            new DefaultMethodSecurityExpressionHandler();
        handler.setPermissionEvaluator(evaluator);
        return handler;
    }
}

Now use it in annotations:

@PreAuthorize("hasPermission(#tenantId, 'WRITE')")
@Transactional
public void updateTenantSettings(TenantId tenantId, TenantSettings settings) {
    tenantRepository.updateSettings(tenantId, settings);
}

This moves authorization logic out of inline SpEL and into testable Java code. The PermissionEvaluator can be unit tested without Spring context. The annotation remains readable.

Interceptor Chain Ordering: Security Before Transaction

CH13 stated that authorization and transaction interceptors coexist in one proxy. The ordering is determined by the @Order value on each advisor:

  • AuthorizationManagerBeforeMethodInterceptor has order AuthorizationInterceptorsOrder.PRE_AUTHORIZE (100)
  • TransactionInterceptor has order Ordered.LOWEST_PRECEDENCE (Integer.MAX_VALUE)

Lower order values execute first in the before phase. The result:

method call on proxy
  -> AuthorizationManagerBeforeMethodInterceptor (order 100)
    -> [authorization check: PASS or AccessDeniedException]
    -> TransactionInterceptor (order MAX_VALUE)
      -> [begin transaction]
      -> target method execution
      -> [commit/rollback transaction]
  -> AuthorizationManagerAfterMethodInterceptor (@PostAuthorize, if present)

This is the correct order. Authorization runs before the transaction opens. A denied request never acquires a database connection. If you somehow reverse this ordering (by setting a custom order on the security interceptor higher than the transaction interceptor), you would open a transaction, acquire a connection, then throw AccessDeniedException, then rollback. Wasted resources. The defaults are correct. Do not change them without understanding the chain from CH9.

To verify the interceptor order at runtime, inject the Advisor[] from the proxy:

@Component
@RequiredArgsConstructor
public class ProxyInspector implements CommandLineRunner {

    private final OrderService orderService;

    @Override
    public void run(String... args) {
        if (AopUtils.isAopProxy(orderService)) {
            Advised advised = (Advised) orderService;
            for (Advisor advisor : advised.getAdvisors()) {
                System.out.println(advisor.getClass().getSimpleName()
                    + " -> " + advisor.getAdvice().getClass().getSimpleName());
            }
        }
    }
}

This prints the advisor chain in execution order. You will see the authorization interceptor before the transaction interceptor. If you do not, check your @Order annotations and your @EnableMethodSecurity configuration.