Skip to main content
spring internals

Authorization Internals: @PreAuthorize, Method Security Proxies, and SecurityContext Propagation Across Thread Boundaries

9 min read Chapter 37 of 78

Authorization Internals: @PreAuthorize, Method Security Proxies, and SecurityContext Propagation Across Thread Boundaries

You learned in CH8 that Spring wraps your beans in proxies. You learned in CH9 that those proxies chain MethodInterceptor instances. You learned in CH10 that TransactionInterceptor is one such interceptor. Now we examine another interceptor in that chain: the one that enforces authorization rules before your method executes.

@PreAuthorize is not a filter. It does not operate at the HTTP layer. It operates at the method layer, using the same proxy infrastructure you already understand. The mechanism is a MethodInterceptor registered as an AOP advisor that evaluates a SpEL expression against the current SecurityContext before allowing method execution to proceed.

Method security proxy showing PreAuthorize and PostAuthorize interceptors around the target bean method

This chapter covers how that interceptor is created, how SpEL evaluation works against the security context, and why the entire mechanism breaks when your method runs on a different thread.

@EnableMethodSecurity and the Advisor Registration

Spring Security 6 uses @EnableMethodSecurity (not the deprecated @EnableGlobalMethodSecurity). When this annotation is present, Spring Security registers three MethodInterceptor beans:

  1. AuthorizationManagerBeforeMethodInterceptor for @PreAuthorize
  2. AuthorizationManagerAfterMethodInterceptor for @PostAuthorize
  3. Additional interceptors for @PreFilter and @PostFilter

Each of these is an Advisor in the same sense you saw in CH9. They have a pointcut (methods annotated with the corresponding annotation) and an advice (the authorization check). When AbstractAutoProxyCreator post-processes your bean, it finds these advisors, matches them against your bean’s methods, and wraps the bean in a proxy if any match.

The registration happens through PrePostMethodSecurityConfiguration, which is imported by @EnableMethodSecurity. This configuration class creates the interceptor beans:

// What Spring Security registers internally
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
static MethodInterceptor preAuthorizeAuthorizationMethodInterceptor(
        ObjectProvider<PreAuthorizeAuthorizationManager> manager,
        ObjectProvider<AuthorizationEventPublisher> publisher) {

    PreAuthorizeAuthorizationManager authorizationManager =
        manager.getIfAvailable(PreAuthorizeAuthorizationManager::new);

    AuthorizationManagerBeforeMethodInterceptor interceptor =
        AuthorizationManagerBeforeMethodInterceptor.preAuthorize(authorizationManager);

    // Default order: AuthorizationInterceptorsOrder.PRE_AUTHORIZE (100)
    return interceptor;
}

The static keyword matters. This is an infrastructure bean that must be created before any user beans, because it participates in proxy creation during the BeanPostProcessor phase. The same pattern you saw with TransactionInterceptor in CH10.

AuthorizationManagerBeforeMethodInterceptor: The Mechanism

AuthorizationManagerBeforeMethodInterceptor implements MethodInterceptor. It sits in the proxy’s advisor chain alongside TransactionInterceptor, your custom @Around aspects, and any caching interceptors. The chain is built the same way CH9 described.

When a proxied method is called, ReflectiveMethodInvocation.proceed() walks the chain. When it reaches the authorization interceptor, the interceptor:

  1. Retrieves the current Authentication from SecurityContextHolder.getContext().getAuthentication()
  2. Passes the Authentication and the MethodInvocation to PreAuthorizeAuthorizationManager
  3. The manager evaluates the SpEL expression from @PreAuthorize
  4. If the expression evaluates to false, the interceptor throws AccessDeniedException
  5. If true, it calls invocation.proceed() to continue the chain
// Simplified from AuthorizationManagerBeforeMethodInterceptor
public Object invoke(MethodInvocation invocation) throws Throwable {
    Supplier<Authentication> authentication = SecurityContextHolder.getContext()::getAuthentication;
    AuthorizationDecision decision = this.authorizationManager.check(authentication, invocation);

    if (decision != null && !decision.isGranted()) {
        throw new AccessDeniedException("Access Denied");
    }

    return invocation.proceed();
}

The Supplier<Authentication> is lazy. The SecurityContext is not read until check() is called. This matters for reactive pipelines, but in servlet-based applications, it resolves immediately from the ThreadLocal.

SpEL Evaluation: How @PreAuthorize Reads the SecurityContext

The SpEL expression in @PreAuthorize("hasRole('ADMIN')") is not string matching. Spring Security compiles it into an Expression object using the SpEL parser, then evaluates it against a MethodSecurityEvaluationContext that exposes the Authentication object and the method parameters.

The evaluation context makes several objects available to expressions:

  • authentication : the current Authentication object
  • principal : shortcut for authentication.getPrincipal()
  • hasRole('X') : checks authentication.getAuthorities() for ROLE_X
  • hasAuthority('X') : checks for the exact authority string
  • #paramName : references method parameters by name
  • returnObject : available in @PostAuthorize

For the SaaS backend, a multi-tenant authorization check looks like this:

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

    @PreAuthorize("hasRole('ADMIN') or #tenantId == authentication.principal.tenantId")
    @Transactional
    public List<Order> getOrders(TenantId tenantId) {
        return orderRepository.findByTenantId(tenantId);
    }

    @PreAuthorize("hasRole('ADMIN')")
    @Transactional
    public void deleteOrder(TenantId tenantId, OrderId orderId) {
        orderRepository.deleteByTenantIdAndOrderId(tenantId, orderId);
    }
}

When getOrders(new TenantId("tenant-42")) is called, the proxy chain executes the authorization interceptor first. The SpEL expression hasRole('ADMIN') or #tenantId == authentication.principal.tenantId is evaluated:

  1. hasRole('ADMIN') checks if the Authentication has ROLE_ADMIN in its granted authorities
  2. If not, the expression evaluator resolves #tenantId to the first method parameter
  3. It resolves authentication.principal.tenantId by navigating the property chain on the Authentication.getPrincipal() object
  4. The equality check determines the final result

The #tenantId resolution requires that the parameter name is available at runtime. This uses DefaultParameterNameDiscoverer, which tries two strategies: StandardReflectionParameterNameDiscoverer (reads names retained by the -parameters compiler flag) and LocalVariableTableParameterNameDiscoverer (reads debug info from class files). If neither works, the parameter name is not available and the expression fails. More on this in CH13-S1.

Method Security and Transaction Proxies: Two Interceptors, One Proxy

A common question: if OrderService has both @PreAuthorize and @Transactional, does Spring create two proxies?

No. As explained in CH8, AbstractAutoProxyCreator creates one proxy per bean. The proxy contains multiple advisors in its chain. For OrderService, the chain looks like:

Client call
  -> CGLIB proxy
    -> AuthorizationManagerBeforeMethodInterceptor (@PreAuthorize)
      -> TransactionInterceptor (@Transactional)
        -> actual OrderService.getOrders()

The authorization interceptor runs first because its order (AuthorizationInterceptorsOrder.PRE_AUTHORIZE, value 100) is higher priority (lower number) than TransactionInterceptor’s default order (Ordered.LOWEST_PRECEDENCE). This means the security check executes before the transaction begins. If authorization fails, no transaction is opened, no database connection is acquired. This is correct. You do not want to start a transaction for a request that will be denied.

SecurityContext and ThreadLocal: Where the Identity Lives

Everything above depends on one assumption: the SecurityContext is available when the interceptor reads it. In a servlet-based application, SecurityContextHolder stores the context in a ThreadLocal. The filter chain (covered in CH11) populates this ThreadLocal at the start of request processing:

HTTP Request
  -> SecurityContextPersistenceFilter (or SecurityContextHolderFilter in Spring Security 6)
    -> writes SecurityContext to SecurityContextHolder (ThreadLocal)
      -> ... filter chain ...
        -> DispatcherServlet
          -> Controller
            -> Service proxy (reads SecurityContext from ThreadLocal)

The ThreadLocal is scoped to the request-processing thread. As long as all code runs on that thread, SecurityContextHolder.getContext().getAuthentication() returns the authenticated user. The interceptor works. The SpEL expression evaluates correctly.

The problem starts when code moves off that thread.

The @Async Failure: SecurityContext Lost Across Thread Boundaries

The SaaS backend sends notification emails after order creation. The notification is asynchronous to avoid blocking the response:

// BROKEN: @Async method loses SecurityContext
@Service
@RequiredArgsConstructor
public class NotificationService {

    private final EmailClient emailClient;
    private final AuditRepository auditRepository;

    @Async
    @PreAuthorize("hasRole('USER')")
    public void sendOrderConfirmation(TenantId tenantId, OrderId orderId) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        // auth is NULL on the async thread

        String userEmail = ((SaasUserDetails) auth.getPrincipal()).getEmail();
        emailClient.send(userEmail, "Order Confirmed", "Order " + orderId + " confirmed.");

        auditRepository.save(AuditEntry.of(tenantId, "ORDER_CONFIRMED", auth.getName()));
    }
}

This code throws NullPointerException on auth.getPrincipal(). The @Async annotation causes the method to execute on a thread from the SimpleAsyncTaskExecutor or a configured thread pool. That thread does not have the SecurityContext in its ThreadLocal. The authentication is null.

The @PreAuthorize interceptor runs on the calling thread (where the context exists), so the authorization check passes. But the method body runs on the async thread, where the context is gone. The interceptor and the method body execute on different threads.

The Solution: DelegatingSecurityContextAsyncTaskExecutor

The fix is to configure an AsyncTaskExecutor that copies the SecurityContext from the calling thread to the async thread before task execution:

// CORRECT: SecurityContext is propagated to async threads
@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean
    public AsyncTaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("saas-async-");
        executor.initialize();

        return new DelegatingSecurityContextAsyncTaskExecutor(executor);
    }
}

DelegatingSecurityContextAsyncTaskExecutor wraps every submitted Runnable in a DelegatingSecurityContextRunnable. This wrapper captures the SecurityContext from the calling thread at submission time and sets it on the worker thread before the task runs. After the task completes, it clears the context from the worker thread.

Calling thread (has SecurityContext)
  -> submit task to DelegatingSecurityContextAsyncTaskExecutor
    -> DelegatingSecurityContextRunnable created (captures SecurityContext)
      -> worker thread starts
        -> SecurityContextHolder.setContext(captured context)
          -> sendOrderConfirmation() runs (SecurityContext available)
        -> SecurityContextHolder.clearContext()

Now SecurityContextHolder.getContext().getAuthentication() returns the correct Authentication on the async thread.

Alternative: MODE_INHERITABLETHREADLOCAL (And Why It Breaks With Pools)

SecurityContextHolder supports an alternative storage strategy: MODE_INHERITABLETHREADLOCAL. This uses Java’s InheritableThreadLocal, which automatically copies the parent thread’s value to child threads:

SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);

This works when the async thread is a new thread spawned directly from the request thread. It does not work with thread pools. A pooled thread is not a child of the request thread. It was created once by the pool and reused across requests. The InheritableThreadLocal value was set when the thread was created, not when the task was submitted. The context on the pooled thread belongs to whatever request happened to create that thread, not the current request.

This is a subtle, non-deterministic bug. In testing with a pool size of 1, it might appear to work because the single pooled thread happens to have the right context. In production with many concurrent requests, it will intermittently use the wrong user’s identity. This is a security vulnerability.

Use DelegatingSecurityContextAsyncTaskExecutor. Not MODE_INHERITABLETHREADLOCAL with thread pools.

Debugging Method Security

When authorization fails unexpectedly, enable debug logging:

logging.level.org.springframework.security.authorization=TRACE

This logs every AuthorizationDecision with the expression, the authentication details, and the result. Combined with the proxy type inspection techniques from CH8 (printing bean.getClass().getName() to confirm CGLIB proxy), you can trace the full path from proxy interception to authorization decision.

For the async propagation issue, the diagnostic is simpler. If SecurityContextHolder.getContext().getAuthentication() returns null inside an @Async method, the context was not propagated. Check your AsyncTaskExecutor configuration.

CH13-S1 covers SpEL expression evaluation internals, parameter name resolution, and custom permission evaluators. CH13-S2 covers every SecurityContextHolder strategy and structured concurrency implications in Java 21.