Why can a @PreAuthorize on a method be silently bypassed when another method in the same class calls it, and how do you fix that?
answer
- this = raw target, not the proxy
- internal call skips interceptor -> check silently skipped
- same root cause as @Transactional self-invocation
- fix: separate bean / self-inject proxy / AopContext
- final/private/constructor calls also bypass
basics
~20 sMethod security runs through a proxy that wraps the bean. When one method calls another method on the same object with this, the call never leaves the object, so the proxy is skipped and the @PreAuthorize check doesn't run. Call through the injected bean instead, or move the method to another bean.
solid answer
~40 sMethod security is enforced by a Spring AOP proxy around the bean. The interceptor only sees calls that arrive *through* that proxy. An internal call — `this.secured()` from another method of the same class — invokes the real target directly, bypassing the proxy, so `@PreAuthorize` is never evaluated. This is the classic self-invocation problem (identical to @Transactional). Fixes: (1) put the secured method on a *separate* bean and inject it; (2) self-inject the proxy (`@Autowired ThisService self;`) and call `self.secured()`; (3) obtain the proxy via `AopContext.currentProxy()` with `exposeProxy = true`; or (4) rethink the design so external callers hit the guarded entry point. The cleanest is usually to keep authorization at a public boundary and not call guarded methods internally.
code
java · 17 lines@Service
public class DocumentService {
// Option A (preferred): route through the injected proxy
@Autowired
private DocumentService self;
public void batchDelete(List<Long> ids) {
// this.deleteSecured(id) -> BYPASSES @PreAuthorize (self-invocation)
ids.forEach(id -> self.deleteSecured(id)); // goes through proxy -> checked
}
@PreAuthorize("hasRole('ADMIN')")
public void deleteSecured(Long id) {
repository.deleteById(id);
}
}go deeper
Awareness that internal calls can skip the check is enough.
Explain that a proxy enforces the check and this.method() bypasses it; know one fix.
Enumerate multiple fixes with trade-offs and connect it to @Transactional; note private/final/constructor pitfalls.
Argue for boundary-based authorization design that avoids self-invocation entirely and how to test the bypass at the public entry point.
**Why it happens.** `@EnableMethodSecurity` works by wrapping each bean that has security annotations in a **Spring AOP proxy** (JDK dynamic proxy if the bean has an interface, CGLIB subclass proxy otherwise). The security interceptor lives *on the proxy*. When Spring injects `DocumentService` into a controller, the controller actually holds a reference to the proxy, so every call it makes is intercepted and `@PreAuthorize` runs. But *inside* the bean, `this` refers to the **raw target object**, not the proxy. So when one method calls another method on the same instance: ```java public void batch() { // called through the proxy — intercepted for (Long id : ids) { this.deleteSecured(id); // 'this' = raw target — proxy skipped } } @PreAuthorize("hasRole('ADMIN')") public void deleteSecured(Long id) { ... } ``` The `deleteSecured` call goes straight to the target; the proxy — and therefore the `AuthorizationManagerBeforeMethodInterceptor` — is never on the call stack. The check is **silently skipped**, which is a security hole, not just a bug. (The exact same mechanism causes `@Transactional`, `@Cacheable`, and `@Async` to be ignored on self-invocation.) **Note on scope of this leaf:** the AOP proxy *mechanism* itself (how proxies are created/chosen) belongs to Spring AOP; here the point is the security-visible *consequence* — the interceptor never runs. **Fixes, roughly in order of preference:** 1. **Redesign so the boundary is external.** Keep authorization on the public entry point and don't call other guarded methods internally. If `batch()` is the entry point, guard `batch()` itself. This avoids proxies-within-proxies entirely and is the clearest design. 2. **Move the secured method to a different bean.** Extract `deleteSecured` into `SecuredDeleteService`, inject it, and call `securedDeleteService.deleteSecured(id)`. Now the call crosses a bean boundary and passes through that bean's proxy. 3. **Self-injection.** Inject the bean into itself and route the call through the proxy reference: ```java @Autowired private DocumentService self; // the proxy public void batch() { ids.forEach(id -> self.deleteSecured(id)); } ``` Spring resolves `self` to the proxy, so the interceptor fires. (Constructor self-injection can cause a circular reference; field/setter or `@Lazy` avoids it.) 4. **AopContext.currentProxy().** Enable it with `@EnableMethodSecurity` combined with proxy exposure (`@EnableAspectJAutoProxy(exposeProxy = true)` / `proxyTargetClass` config), then call `((DocumentService) AopContext.currentProxy()).deleteSecured(id)`. Works but couples code to AOP internals — least preferred. **Related gotchas.** - Annotating a **non-public** method: with Spring AOP CGLIB proxies, private methods can't be advised at all, and even protected/package methods are inconsistent — put security annotations on **public** methods. - Marking a method `final` (or a `final` class) prevents CGLIB from subclassing it, so the proxy — and the security check — silently can't apply. - Calling a secured method from the bean's **constructor** or `@PostConstruct` also bypasses the proxy (the proxy isn't in the picture yet). **How to detect it** — enable proxy-mode debugging or simply write an integration test that calls the *public entry point* as an unprivileged user and asserts `AccessDeniedException`. A unit test that calls the method directly on the raw instance will never catch the bypass because it also skips the proxy.
- Which other Spring annotations suffer from the exact same self-invocation limitation?Any proxy-based advice: @Transactional, @Cacheable/@CacheEvict, @Async, @Retryable, and validation via @Validated. All are ignored on internal this.method() calls for the same reason.
- Why does putting @PreAuthorize on a private or final method fail?Spring AOP proxies advise via interface implementation (JDK) or subclassing (CGLIB). Private methods aren't visible to subclasses and final methods can't be overridden, so the proxy can't intercept them — the annotation is silently ignored.
saying these in an interview costs you the question
- Believing @PreAuthorize protects a method no matter who calls it, including callers inside the same class
- Testing only by calling the method directly on the raw bean instance (which itself bypasses the proxy and hides the bug)
- Putting security annotations on private/final methods and assuming they're enforced
- Fixing it with new DocumentService() or a manual instance instead of an injected proxy