skip to content

What changed architecturally moving from AccessDecisionManager/voters to AuthorizationManager, and what migration risks does deny-by-default introduce?

level: principalimportance: nice to knowfreq 30%

answer

  1. Voters + AccessDecisionManager strategies → one AuthorizationManager + combinators
  2. FilterSecurityInterceptor → AuthorizationFilter
  3. 6.0 deny-by-default; authorizeRequests → authorizeHttpRequests
  4. filterAllDispatcherTypes → ERROR/FORWARD 403 surprises
  5. Custom voters must be rewritten; test every route before upgrading

basics

~10 s

Spring Security replaced the voter-based AccessDecisionManager with a single AuthorizationManager SPI, and swapped FilterSecurityInterceptor for AuthorizationFilter. Since 6.0, web authorization is deny-by-default: any request not explicitly matched is denied, so incomplete rules break access.

solid answer

~40 s

The old model coordinated multiple AccessDecisionVoters through an AccessDecisionManager strategy (AffirmativeBased/ConsensusBased/UnanimousBased), each voter returning grant/deny/abstain integers against ConfigAttributes. That was replaced by one composable AuthorizationManager<T> returning an AuthorizationDecision, with combinators (anyOf/allOf/not) instead of voting strategies, and FilterSecurityInterceptor gave way to AuthorizationFilter. The big behavioural shift is deny-by-default: authorizeHttpRequests denies any request not matched by a rule, and Spring fails startup or requests if you leave gaps — unlike the older authorizeRequests, which was more permissive in places. Migration risks: forgetting anyRequest(), relying on implicit permits, order-sensitivity of matchers (first match wins), the ROLE_ prefix, and losing custom voters that must be rewritten as managers. Benefits: simpler mental model, lazy Authentication, better observability via AuthorizationEvent publishing and ExpressionAuthorizationDecision.

code

java · 12 lines
java
// OLD (5.x) — voter/strategy model, permissive gaps possible
// http.authorizeRequests(a -> a
//     .antMatchers("/admin/**").hasRole("ADMIN"));   // unmatched paths could slip

// NEW (6.x) — deny-by-default, explicit terminal rule required
http.authorizeHttpRequests(auth -> auth
    // permit the error dispatch so error pages don't 403
    .dispatcherTypeMatchers(DispatcherType.ERROR).permitAll()
    .requestMatchers("/admin/**").hasRole("ADMIN")
    .requestMatchers("/public/**").permitAll()
    // MUST be explicit — unmatched requests are denied
    .anyRequest().authenticated());

go deeper

for a junior

Know the new model replaced the old one and is deny-by-default.

for a middle

Explain authorizeHttpRequests vs authorizeRequests and always terminating with anyRequest().

for a senior

Map voter strategies to combinators and handle matcher ordering and the ROLE_ prefix during migration.

for a principal

Own the upgrade risk: dispatcher-type authorization, fail-closed availability trade-offs, custom voter porting, and a route-by-route test strategy plus observability via authorization events.

## The old architecture (Spring Security 5.x and earlier) Authorization was decided by an `AccessDecisionManager` that delegated to an ordered list of `AccessDecisionVoter`s. Each voter examined the secured object plus a collection of `ConfigAttribute`s (string-ish metadata like `ROLE_ADMIN` or SpEL) and returned an `int`: `ACCESS_GRANTED (1)`, `ACCESS_ABSTAIN (0)`, or `ACCESS_DENIED (-1)`. A strategy tallied them: - **AffirmativeBased** — grant if any voter grants (default). - **ConsensusBased** — majority wins. - **UnanimousBased** — grant only if no voter denies. The web enforcement point was `FilterSecurityInterceptor`; method security used `MethodSecurityInterceptor` with `@Secured`/`@PreAuthorize` metadata sources. Powerful, but a lot of moving parts and indirection. ## The new architecture (5.5 → 6.x) Everything collapses into `AuthorizationManager<T>`: - One interface, generic over the secured object. - Combinators (`AuthorizationManagers.anyOf/allOf/not`) replace voting strategies — you compose managers explicitly instead of configuring a tally strategy. - `AuthorizationFilter` replaces `FilterSecurityInterceptor`; `RequestMatcherDelegatingAuthorizationManager` maps matchers → managers. - Method security: `@EnableMethodSecurity` uses `AuthorizationManagerBeforeMethodInterceptor` / `AuthorizationManagerAfterMethodInterceptor`. - `Supplier<Authentication>` makes principal resolution lazy. - Better observability: authorization publishes `AuthorizationDeniedEvent` / `AuthorizationGrantedEvent` via an `AuthorizationEventPublisher`, and `ExpressionAuthorizationDecision` records the SpEL for diagnostics. ## Deny-by-default: the migration landmine In 6.0, `authorizeRequests` (old DSL) was removed in favour of `authorizeHttpRequests`, and the semantics hardened to **deny-by-default**: a request that matches *no* rule is denied. Furthermore, `AuthorizationFilter.setFilterAllDispatcherTypes(true)` became the default, so `ERROR`/`ASYNC`/`FORWARD` dispatches are also authorized (a common source of surprise 403s on error pages). ### Concrete migration risks 1. **Missing `anyRequest()`** — any unmatched request is now denied; previously some paths slipped through. Always terminate with `anyRequest().authenticated()` or `.permitAll()`/`.denyAll()` intentionally. 2. **Matcher ordering** — rules are evaluated top-to-bottom, first match wins. A broad matcher placed before a specific one shadows it. 3. **Dispatcher types** — forwarded/error dispatches now hit the filter; you may need `.dispatcherTypeMatchers(FORWARD, ERROR).permitAll()` or to permit the error path. 4. **Custom voters** — any bespoke `AccessDecisionVoter` (e.g. ABAC voter) must be rewritten as an `AuthorizationManager`, and voting strategies re-expressed with combinators. 5. **ROLE_ prefix** and **abstain semantics** carry subtle behaviour differences. 6. **mvcMatchers → requestMatchers** rename and the requirement to be explicit about servlet path patterns. ## Why the change was worth it - Far fewer concepts; a single testable SPI. - Explicit composition beats implicit voting strategies for reasoning about security. - Laziness improves performance for permit-heavy configs. - First-class events and expression capture improve auditability. ## Principal-level framing When leading a 5→6 upgrade, treat authorization as a behavioural-change hotspot: enumerate every route, add integration tests asserting 200/401/403 per role *before* upgrading, watch for error-page 403 regressions, and port custom voters deliberately. Deny-by-default is a security *improvement* (fail-closed) but a availability *risk* if coverage is incomplete.

  • After a 5→6 upgrade, users hit a 403 on the error page. What's the likely cause?
    AuthorizationFilter now authorizes all dispatcher types by default, including ERROR forwards. The error dispatch isn't matched by a permit rule, so deny-by-default blocks it. Permit ERROR (and often FORWARD) dispatcher types explicitly.
  • How do you re-express a UnanimousBased voter strategy in the new model?
    Use AuthorizationManagers.allOf(...) — it grants only if no delegate denies, mirroring unanimous. AffirmativeBased maps to anyOf(...).
  • How would you de-risk the upgrade for a large app?
    Write integration tests asserting the expected 200/401/403 for each route and role before upgrading, enumerate all matchers for ordering/coverage, port custom voters to managers, and specifically test error/forward dispatch paths.

saying these in an interview costs you the question

  • Assuming unmatched requests are permitted (they're denied in 6.x).
  • Forgetting that ERROR/FORWARD dispatches are now authorized, causing error-page 403s.
  • Believing voting strategies still exist — they're replaced by explicit combinators.
  • Leaving out anyRequest() and expecting it to 'just work'.

context