When would you choose @PostAuthorize over @PreAuthorize, and what are the risks of guarding a method after it has already executed?
answer
- PostAuthorize = decision needs the returned object
- body already executed -> side effects happen on denial
- only the return value is discarded
- prefer query-scoping / PreAuthorize for writes
- PostAuthorize is all-or-nothing; PostFilter filters elements
basics
~20 sUse @PostAuthorize when the decision depends on the object the method returns — for example an ownership check on the loaded entity. The risk is that the method body runs first: any side effects, expensive work, or data loading happen even when access is finally denied, and only the return value is thrown away.
solid answer
~40 sChoose @PostAuthorize only when authorization genuinely depends on the returned object and can't be expressed up front — the classic case is `findById` where you must load the entity to know its owner: `@PostAuthorize("returnObject.ownerId == principal.id")`. The core risk is that the method has *already fully executed* before the check runs, so it must be side-effect-free and safe to run speculatively: a `@PostAuthorize` on a method that also mutates state, sends an email, or is expensive means that work happens even when the caller is ultimately denied — only the result is discarded (an AccessDeniedException is thrown). It's also a subtle information-boundary concern: you loaded protected data into memory. Prefer `@PreAuthorize` with an argument-based rule (or filtering at the query level) whenever the decision can be made before execution; reserve `@PostAuthorize` for read-only fetches.
code
java · 19 lines// GOOD: read-only fetch, ownership only knowable after load
@PostAuthorize("returnObject.ownerId == principal.id or hasRole('ADMIN')")
public Document findById(Long id) {
return repository.findById(id).orElseThrow();
}
// RISKY: side effect runs even when access is ultimately denied
@PostAuthorize("returnObject.ownerId == principal.id")
public Document archive(Long id) {
Document d = repository.findById(id).orElseThrow();
d.setArchived(true); // <-- ALREADY persisted before the check!
auditLog.record("archived", id);
return d;
}
// BETTER: scope the query so non-owners get nothing loaded at all
public Optional<Document> findOwned(Long id, Long principalId) {
return repository.findByIdAndOwnerId(id, principalId);
}go deeper
Know @PostAuthorize checks the returned object and runs after the method.
Give the findById ownership example and note the method runs first.
Explain the side-effect/cost risk and prefer @PreAuthorize or query-scoping for anything mutating.
Reason about speculative execution, transaction rollback interaction, data-in-memory exposure, @PostFilter vs @PostAuthorize, and design the authorization boundary to avoid loading protected data at all.
**The decision rule.** Reach for `@PostAuthorize` **only** when the authorization decision depends on data you do not have until the method returns. The canonical example is loading a single entity and checking ownership: ```java @PostAuthorize("returnObject.ownerId == principal.id or hasRole('ADMIN')") public Document findById(Long id) { return repository.findById(id).orElseThrow(); } ``` You can't write this as `@PreAuthorize` on `#id` because at pre-time you only have the id, not the owner. `@PostAuthorize` runs after `findById` returns, binds the entity to `returnObject`, and evaluates the rule. **The fundamental risk: the body already ran.** Because `@PostAuthorize` fires *after* `invocation.proceed()`, the method's full side effects have already happened by the time access is denied. Only the **return value** is discarded (Spring throws `AccessDeniedException`/`AuthorizationDeniedException`). Concretely: - If the method also **writes** — persists an entity, sends an email, charges a card, enqueues a job — those effects are permanent even for a denied caller. `@PostAuthorize` gives a false sense of protection here. - If the method is **expensive** — heavy query, external API call — the cost is paid regardless of the outcome, which is a DoS/abuse vector if an attacker can trigger it repeatedly. - **Data exposure in memory** — you materialized protected data before deciding the caller may not see it. In most apps this is acceptable (it never leaves the JVM), but in high-assurance systems loading the data at all can be part of the threat model. **Therefore:** annotate only **read-only, idempotent, cheap** methods with `@PostAuthorize`. Never combine it with a method that mutates state you can't afford to apply to unauthorized users. **Better alternatives, in order:** 1. **Push the check into the query.** Instead of loading then checking, scope the query: `repository.findByIdAndOwnerId(id, principalId)` returns empty for non-owners. This never loads other users' data, avoids the speculative-execution problem, and is usually faster. For lists this is essential (`@PostFilter` over a large result set is both slow and loads everything). 2. **@PreAuthorize when arguments suffice.** If the caller passes enough to decide up front (e.g. `#ownerId == principal.id`), use `@PreAuthorize` — the body never runs on denial. 3. **Delegate to a bean** for complex ownership rules: `@PostAuthorize("@docAuthz.canView(returnObject, authentication)")`, keeping logic testable. **Interaction with transactions.** If the method is `@Transactional` and `@PostAuthorize` denies, the thrown `AccessDeniedException` propagates out of the method and, being a `RuntimeException`, typically **rolls back** the transaction — so read-only fetches are fine, but a method that both writes and is `@PostAuthorize`-guarded may roll back the write (order of interceptors matters, and this is subtle — don't rely on it as an authorization mechanism). **Ordering with @PostFilter.** On one method, `@PostAuthorize` runs *before* `@PostFilter`. If you need per-element filtering of a returned collection, that's `@PostFilter` (using `filterObject`), not `@PostAuthorize` (which is an all-or-nothing check on the whole `returnObject`). **Summary heuristic:** `@PreAuthorize` and query-scoping are the default; `@PostAuthorize` is a targeted tool for read-only, single-object ownership checks that truly require the loaded object.
- You need to authorize a returned list per-element, not all-or-nothing. Which annotation?@PostFilter with the filterObject variable, e.g. @PostFilter("filterObject.ownerId == principal.id"). @PostAuthorize can only accept or reject the whole returnObject. Beware @PostFilter loads and iterates the full list in memory — query-scoping is preferable for large results.
- Does a @PostAuthorize denial roll back a @Transactional method?Typically yes — it throws AccessDeniedException (a RuntimeException) which triggers rollback. But relying on that to undo writes is fragile and order-dependent; don't use @PostAuthorize as a write guard. Decide before writing.
saying these in an interview costs you the question
- Using @PostAuthorize on a method that writes/mutates and assuming unauthorized callers cause no effect
- Believing @PostAuthorize prevents the method from running
- Using @PostAuthorize to filter a collection (that's @PostFilter's job)
- Loading all rows then @PostFilter-ing instead of scoping the query, for large result sets