Authorization Internals: @PreAuthorize, Method Security Proxies, and SecurityContext Propagation Across Thread Boundaries
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.
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:
AuthorizationManagerBeforeMethodInterceptorfor@PreAuthorizeAuthorizationManagerAfterMethodInterceptorfor@PostAuthorize- Additional interceptors for
@PreFilterand@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:
- Retrieves the current
AuthenticationfromSecurityContextHolder.getContext().getAuthentication() - Passes the
Authenticationand theMethodInvocationtoPreAuthorizeAuthorizationManager - The manager evaluates the SpEL expression from
@PreAuthorize - If the expression evaluates to
false, the interceptor throwsAccessDeniedException - If
true, it callsinvocation.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 currentAuthenticationobjectprincipal: shortcut forauthentication.getPrincipal()hasRole('X'): checksauthentication.getAuthorities()forROLE_XhasAuthority('X'): checks for the exact authority string#paramName: references method parameters by namereturnObject: 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:
hasRole('ADMIN')checks if theAuthenticationhasROLE_ADMINin its granted authorities- If not, the expression evaluator resolves
#tenantIdto the first method parameter - It resolves
authentication.principal.tenantIdby navigating the property chain on theAuthentication.getPrincipal()object - 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.