As a technical lead, how would you decide when method-level @PreFilter/@PostFilter is the right authorization mechanism versus enforcing access control elsewhere, and what pitfalls would you call out in review?
answer
- small/bounded -> filter annotation; large/paged -> query
- silent prune vs auditable deny (PreAuthorize)
- per-element SpEL = hidden N+1
- proxy: self-invocation & non-public bypass
- one authoritative enforcement layer, test both paths
basics
~20 sUse @PreFilter/@PostFilter only for small, per-element visibility rules on service methods. Prefer data-layer filtering for large or paged reads, keep authorization out of the persistence path for correctness, and watch for immutable-collection breakage, silent result-shrinking, and proxy/self-invocation gaps.
solid answer
~50 sI treat @PreFilter/@PostFilter as ergonomic, element-level authorization that is fine for small bounded collections but not the load-bearing boundary. The decision axes are: dataset size and pagination (large/paged reads must filter in the query for correct counts and to avoid over-fetching); where the rule can be expressed most reliably (SQL WHERE/tenant filter versus in-memory SpEL); and whether the effect should be silent pruning versus an explicit deny (@PreAuthorize/@PostAuthorize) for auditability. In review I flag: immutable-collection arguments that break @PreFilter with UnsupportedOperationException; missing filterTarget on multi-collection methods; @PostFilter on Page/large results; SpEL referencing beans that hide expensive per-element calls (N+1); AOP proxy pitfalls where self-invocation and non-public methods skip the interceptor; and unsupported Map targets. I also insist the same rule be enforced at exactly one clear layer to avoid drift, and that tests cover both allowed and dropped elements.
code
java · 17 lines@Service
public class DocService {
// RED FLAG in review: bean call per element -> N+1 / remote call per row.
@PostFilter("@aclService.canRead(filterObject, authentication)")
public List<Doc> risky() { return repo.findAll(); }
// RED FLAG: self-invocation bypasses the security proxy entirely.
public List<Doc> caller() {
return risky(); // 'this.risky()' -> @PostFilter interceptor is NOT applied
}
// PREFERRED: authorize + page at the data layer; annotation optional as defense-in-depth.
public Page<Doc> listMine(String me, Pageable pageable) {
return repo.findByOwner(me, pageable); // WHERE owner = :me, correct counts/paging
}
}go deeper
Recognize filtering is convenient but that big/paged lists should be filtered in the database.
List the concrete gotchas: mutability, filterTarget, unsupported Map, pagination breakage.
Add proxy/self-invocation limits and per-element SpEL cost, and argue for a single enforcement layer.
Own the standard: data-layer filtering for scaled/audited access, method annotations as small-scope convenience/defense-in-depth, with tests covering both kept and dropped elements.
## The decision framework When a reviewer/lead asks 'should this be `@PreFilter`/`@PostFilter`?', weigh: 1. **Scale & pagination.** Element filtering happens **in memory after** (for `@PostFilter`) the data is fetched. For large or **paginated** reads this over-fetches and corrupts page sizes/totals/offsets. → Filter in the **query** (WHERE clause, `Specification`, tenant filter, row-level security). Reserve `@PostFilter` for **small, bounded** collections. 2. **Expressiveness & source of truth.** If the rule is naturally a data predicate (`owner_id = :me`, tenant scoping), the database is the reliable place. If it depends on runtime context awkward to encode in SQL, in-memory SpEL may be justified for small sets. 3. **Silent pruning vs explicit deny.** Filtering **removes elements silently** — great for 'show me only mine', bad when a *forbidden access should be an auditable event*. For that, use `@PreAuthorize`/`@PostAuthorize` which throw `AccessDeniedException` (and can be logged/audited). 4. **Single enforcement point.** Duplicating the rule in a query *and* a filter invites drift; pick the authoritative layer and document it. ## Concrete review pitfalls to call out - **Immutable-collection breakage:** `@PreFilter` mutates in place; callers passing `List.of(...)`/`unmodifiableList` cause `UnsupportedOperationException`. Require mutable inputs. - **Missing `filterTarget`:** multi-collection-argument methods throw `IllegalArgumentException` without an explicit `filterTarget` naming the parameter. - **Unsupported types:** only `Collection`, array, `Stream` are filterable; a **`Map`** target throws. Watch for methods returning maps. - **`@PostFilter` on `Page`/large sets:** breaks pagination and over-fetches (see scale axis). - **Expensive per-element SpEL:** `@PostFilter("@acl.canRead(filterObject, authentication)")` runs **once per element** — a hidden N+1 or remote call per row. Prefer batch/query-level checks. - **AOP proxy limitations:** method security is applied by a Spring **AOP proxy**. **Self-invocation** (a bean calling its own annotated method via `this`) **bypasses** the interceptor, and by default only **public** methods on Spring beans are advised. Annotations on private/internal or self-called methods silently do nothing. - **Ordering with other annotations:** combining `@PreAuthorize` (gate) with `@PostFilter` (prune) is valid, but keep each single-purpose; don't rely on filtering to enforce a decision that should fail fast. - **Testing:** because filtering is silent, require tests asserting both that allowed elements survive and disallowed ones are removed — otherwise a broken expression silently returns everything or nothing. ## When @PreFilter/@PostFilter genuinely shines - Small, in-memory collections already scoped by the query, needing a final per-element visibility trim. - Prototyping/domain-simple apps where pushing the predicate into every query is overkill. - As **defense-in-depth** layered on top of a query that is already scoped. ## The leadership takeaway Method-level filtering is a **convenience and a secondary guard**, not the architectural authorization boundary. For anything large, paged, or security-critical, the authoritative filter belongs at the data-access layer, with method annotations reserved for small, clearly-bounded cases and explicit deny handled by `@PreAuthorize`/`@PostAuthorize`.
- Why does @PostFilter on a bean-backed SpEL like @acl.canRead(filterObject, ...) worry you at scale?The expression is evaluated once per element, so a bean method that hits the DB or a remote service becomes an N+1 pattern — one round-trip per row. At scale that is a latency and load problem; batch the authorization or push it into the query instead.
- A teammate put @PreFilter on a method that another method in the same class calls directly, and it does nothing. Why?Spring method security is applied via an AOP proxy. Self-invocation through 'this' bypasses the proxy, so the interceptor never runs. The annotated method must be invoked through the injected bean reference (the proxy), and it must be a public method on a Spring-managed bean.
- When would you still accept @PostFilter in a design review?For a small, already-bounded collection where a query-level predicate is impractical, or as a secondary defense-in-depth guard on top of a scoped query — never as the sole boundary for a large or paginated endpoint, and never where a forbidden access must be audited rather than silently dropped.
saying these in an interview costs you the question
- Treating @PostFilter as the authoritative authorization boundary for large/paged reads.
- Ignoring that self-invocation and non-public methods bypass the security proxy.
- Putting a per-element DB/remote call in the filter SpEL (N+1).
- Assuming duplicate enforcement in query and filter stays consistent over time.