Spring · 09

Spring Security Architecture

Spring Security is a chain of servlet filters in front of your controllers. This page opens the chain: how a request is matched to a SecurityFilterChain, how credentials become an Authentication, where the SecurityContext lives, how URL and method rules decide, and how CSRF, CORS, sessions and password hashing actually work - with a runnable 150-line imitation.

14
Chapters
9
Diagrams
16
Default filters
10
Interview Q&A
01
Chapter One · Why It Exists

Security Is a Cross-Cutting Concern

Every endpoint of a web application needs the same three answers before it runs a single line of business code: who is calling? (authentication), may they do this? (authorisation), and is this request really from them, not from a page they happen to have open? (CSRF / CORS). If each controller answers these itself, the rules drift apart, one handler forgets a check, and the first audit finds an open endpoint. Spring Security's job is to move those answers out of your handlers and into a chain of servlet filters that runs before DispatcherServlet is ever reached.

The framework is large, but it is large because the problem is: password storage, sessions, cookies, headers, token formats, remember-me, logout, OAuth2 login, method-level rules. Almost all of it hangs off three ideas: a filter chain that every request walks through, an Authentication object that says who the caller is, and a SecurityContext that holds that object for the duration of the request.

The same rule, enforced in 3 places vs 1 place
HAND-ROLLED: check pasted into every handler OrderController if (!tokenOk(req)) return 401; if (!admin) 403 UserController if (!tokenOk(req)) return 401; if (!admin) 403 ReportController if (!tokenOk(req)) return 401; if (!admin) 403 forget one line in one handler = open endpoint FRAMEWORK: one gate, handlers stay clean Request any URL Security FilterChain authn authz OrderCtl no auth code UserCtl no auth code ReportCtl no auth code 401 / 403 here
Before — authentication copied into every handler
@RestController class OrderController { private final TokenService tokens; private final OrderService orders; @GetMapping("/orders/{id}") Order get(@PathVariable long id, HttpServletRequest req) { String header = req.getHeader("Authorization"); if (header == null || !tokens.isValid(header)) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); // copy 1 of N } String user = tokens.userOf(header); if (!orders.isOwner(id, user) && !tokens.hasRole(header, "ADMIN")) { throw new ResponseStatusException(HttpStatus.FORBIDDEN); // copy 2 of N } return orders.find(id); } }
After — the handler only contains business logic
@RestController class OrderController { private final OrderService orders; @GetMapping("/orders/{id}") @PreAuthorize("hasRole('ADMIN') or @orderAccess.isOwner(#id, authentication)") Order get(@PathVariable long id) { return orders.find(id); // no header parsing, no 401/403 logic } @GetMapping("/me") String me(@AuthenticationPrincipal UserDetails user) { return user.getUsername(); // the framework hands you the caller } }
Analogy: A building with a security desk. Visitors do not show ID to every office; they show it once at the desk, receive a badge (the Authentication in the SecurityContext), and each door only checks the badge. The desk is the filter chain. Moving the checks to the desk means you can change who is allowed in without rewiring a single office.
What you get for one dependency (spring-boot-starter-security):
  • The moment it is on the classpath, every endpoint requires login: Boot auto-configures a default chain with form login and HTTP Basic, and a user named user with a random password printed at startup (Using generated security password: ...).
  • Default protections turned on: CSRF, security response headers (X-Content-Type-Options, Cache-Control: no-cache for secured responses, X-Frame-Options: DENY), session fixation protection, and a logout endpoint.
  • Everything is replaceable: declare your own SecurityFilterChain bean and Boot's default backs off (see the Boot Internals page for how @ConditionalOnMissingBean does that).
02
Chapter Two · Mental Model

The Filter Chain Is the Whole Story

Servlet containers only know Filter and Servlet, not Spring beans
▶
Boot registers one servlet filter, DelegatingFilterProxy, whose only job is to look up the bean named springSecurityFilterChain in the Spring context and delegate to it
That bean is a FilterChainProxy
▶
It holds a list of SecurityFilterChain objects, each with a RequestMatcher; the first chain that matches the request is the only one that runs
The chosen chain is a list of ~16 ordinary Filters
▶
The request is wrapped in a VirtualFilterChain that calls them one at a time. A filter either calls chain.doFilter() (continue) or writes a response and returns (stop: 401, 403, redirect)
Filters run around the rest of the chain, like AOP advice
▶
Code before doFilter sees the request on the way in; code after it sees the response on the way out. ExceptionTranslationFilter relies on this: it wraps the filters after it in a try/catch
Reaching the end of the chain means every filter said yes
▶
Only then does DispatcherServlet find a controller. A @RestController never sees an unauthenticated request unless you explicitly permitted it
Where Spring Security sits: one servlet filter that fans out into a private chain
Browser GET /orders Tomcat servlet filter chain Delegating FilterProxy "springSecurity FilterChain" FilterChain Proxy picks ONE chain by RequestMatcher SecurityFilter Chain #n 16 filters, in fixed order Other filters: CharacterEncoding, FormContent, RequestContext... Dispatcher Servlet Controller @GetMapping #1 /api/** stateless #2 any request form login First chain whose matcher accepts the request wins. If a filter does NOT call chain.doFilter(...) the request stops there (401, 403, redirect)

Two consequences follow. First, security runs before Spring MVC, so it knows nothing about @RequestMapping; it matches URLs, which is why a rule for /admin/** can be bypassed if a different path also reaches the same controller (see Chapter 6). Second, exceptions thrown by controllers are not translated by ExceptionTranslationFilter into 401/403 unless they are AccessDeniedException or AuthenticationException: your @ControllerAdvice can accidentally swallow a method-security denial and turn it into a 500 or 200.

See it yourself: set logging.level.org.springframework.security=TRACE. At startup you get one line per chain listing its filters (Will secure any request with [...]); for each request you get Securing GET /orders followed by Invoking <FilterName> lines, then the reason for any rejection. This is the single most useful debugging switch in the framework.
03
Chapter Three · SecurityFilterChain

Declaring the Chain (Security 6 Lambda DSL)

Since Spring Security 5.7 you no longer extend WebSecurityConfigurerAdapter (removed in 6.0). You publish one or more SecurityFilterChain beans and configure an HttpSecurity builder with lambdas. Each .xxx(customizer) call adds a configurer; http.build() turns the configurers into the actual filter list, sorted by a fixed registry of filter order (so the order of your DSL calls does not change the filter order, only the configuration).

The default SecurityFilterChain, in the order the filters run
01 DisableEncodeUrlFilter 02 WebAsyncManagerIntegrationFilter 03 SecurityContextHolderFilter 04 HeaderWriterFilter 05 CorsFilter 06 CsrfFilter 07 LogoutFilter 08 UsernamePasswordAuthenticationFi.. 09 DefaultLoginPageGeneratingFilter 10 BasicAuthenticationFilter 11 RequestCacheAwareFilter 12 SecurityContextHolderAwareReques.. 13 AnonymousAuthenticationFilter 14 SessionManagementFilter 15 ExceptionTranslationFilter 16 AuthorizationFilter Boot 3 / Security 6 default chain with formLogin + httpBasic. Gold = web-attack defences, green = authentication, red = decision.

A realistic two-chain setup

Most real applications have two audiences: a browser UI (sessions, forms, CSRF) and a JSON API (stateless, bearer tokens, no CSRF). Give each its own chain. securityMatcher decides which requests a chain owns; @Order decides which chain is asked first.

SecurityConfig.java — API chain first, UI chain as the catch-all
@Configuration @EnableWebSecurity @EnableMethodSecurity // turns on @PreAuthorize / @PostAuthorize (Security 6; replaces @EnableGlobalMethodSecurity) public class SecurityConfig { @Bean @Order(1) SecurityFilterChain api(HttpSecurity http) throws Exception { http .securityMatcher("/api/**") // this chain ONLY handles /api/** .authorizeHttpRequests(auth -> auth .requestMatchers("/api/public/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/orders/**").hasAnyRole("USER", "ADMIN") .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated()) // ALWAYS end with a catch-all .httpBasic(Customizer.withDefaults()) // or oauth2ResourceServer(...) — next page .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .csrf(csrf -> csrf.disable()) // OK ONLY because there is no cookie auth here .exceptionHandling(e -> e.authenticationEntryPoint( new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))); // 401 JSON clients, no login redirect return http.build(); } @Bean @Order(2) SecurityFilterChain ui(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/", "/css/**", "/js/**", "/login").permitAll() .anyRequest().authenticated()) .formLogin(form -> form.loginPage("/login").defaultSuccessUrl("/dashboard", true)) .logout(l -> l.logoutSuccessUrl("/login?logout")) .headers(h -> h.frameOptions(f -> f.sameOrigin())); return http.build(); // no securityMatcher = matches everything } }
DSL callAdds / configuresNotes
securityMatcher(...)Which requests this chain applies toNot the same as requestMatchers inside authorizeHttpRequests, which is a per-rule matcher inside one chain
authorizeHttpRequestsAuthorizationFilterReplaces authorizeRequests (removed). First matching rule wins
formLoginUsernamePasswordAuthenticationFilter + generated login pageHandles POST /login; success handler redirects to the saved request
httpBasicBasicAuthenticationFilterReads Authorization: Basic ... on every request; pair with STATELESS
csrfCsrfFilterOn by default; see Chapter 7
corsCorsFilterOff until you call it; reads a CorsConfigurationSource bean
sessionManagementSessionManagementFilter + context repository choiceSTATELESS also switches the SecurityContext repository
exceptionHandlingExceptionTranslationFilter collaboratorsEntry point (401) and access-denied handler (403)
oauth2ResourceServerBearerTokenAuthenticationFilterNext page: JWT & OAuth2
⚠ Real production gotchas:
  • Two catch-all chains. If the first chain has no securityMatcher it matches everything and the second can never run. Startup fails with UnreachableFilterChainException: A filter chain that matches any request [...] has already been configured, which means that this filter chain [...] will never get invoked. Fix: give the narrower chain a securityMatcher and a lower @Order number.
  • Chains without @Order are ordered by bean registration, which is brittle (a refactor or a component-scan change reorders your security). Always number them explicitly, narrowest matcher first.
  • Calling requestMatchers after anyRequest() throws IllegalStateException: Can't configure requestMatchers after anyRequest. anyRequest() must be the last rule.
  • Still extending WebSecurityConfigurerAdapter or calling antMatchers, mvcMatchers, authorizeRequests: all removed in Security 6 (compile errors after upgrading to Boot 3). Replace with SecurityFilterChain, requestMatchers, authorizeHttpRequests.
  • Static resources and the H2 console behind the chain: use requestMatchers with explicit paths rather than turning security off with WebSecurityCustomizer.ignoring(), which also drops security headers and the context filter for those paths.
  • Multiple servlets (for example the H2 console or Actuator on a different servlet path) can produce This method cannot decide whether these patterns are Spring MVC patterns or not from requestMatchers(String). Either pass an explicit matcher for that rule, for example AntPathRequestMatcher.antMatcher("/h2-console/**"), or fix the servlet layout.
04
Chapter Four · Authentication

From Credentials to an Authentication Object

Authentication is a small contract. A filter extracts credentials from the request and wraps them in an unauthenticated Authentication (for passwords: UsernamePasswordAuthenticationToken.unauthenticated(username, password)). It hands that to an AuthenticationManager, whose single method authenticate(Authentication) either returns a fully populated Authentication (principal, authorities, authenticated = true) or throws an AuthenticationException. The filter then stores the result in the SecurityContext.

Form / Basic login: who calls whom (green = success path)
Username Password AuthFilter Provider Manager Dao AuthProvider UserDetails Service Password Encoder Security Context Holder authenticate(token) supports(token)? loadUserByUsername UserDetails + hash matches(raw, hash) true Authentication authenticated=true setContext(...) Any step may throw AuthenticationException: UsernameNotFound is converted to BadCredentials, wrong hash -> BadCredentials, disabled/locked -> DisabledException/LockedException. ExceptionTranslationFilter turns them into 401.

The default manager is ProviderManager. It owns a list of AuthenticationProviders and asks each in turn supports(tokenClass). The first provider that supports the token and returns non-null wins; if all return null the manager tries its optional parent, then throws ProviderNotFoundException. For username/password the provider is DaoAuthenticationProvider, which composes exactly two collaborators you supply: a UserDetailsService (load the stored user) and a PasswordEncoder (compare the hash).

PieceResponsibilityYou write it when...
AuthenticationValue object: principal, credentials, authorities, authenticated flagYou introduce a new credential type (API key, JWT)
AuthenticationManagerSingle entry point authenticate()Almost never; inject Boot's via AuthenticationConfiguration
ProviderManagerDelegates to the first provider that supports() the tokenYou need two login mechanisms side by side
AuthenticationProviderVerifies one kind of tokenCustom credentials (OTP, API key, LDAP variant)
UserDetailsServiceloadUserByUsername returns a UserDetails (hash + authorities + flags)Always, to read users from your database
PasswordEncoderencode, matches, upgradeEncodingDeclare one bean (Chapter 10)

UserDetailsService backed by JPA

JpaUserDetailsService.java
@Service public class JpaUserDetailsService implements UserDetailsService { private final UserAccountRepository users; public JpaUserDetailsService(UserAccountRepository users) { this.users = users; } @Override @Transactional(readOnly = true) public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { UserAccount u = users.findByEmailIgnoreCase(email) .orElseThrow(() -> new UsernameNotFoundException("No user " + email)); return User.withUsername(u.getEmail()) // org.springframework.security.core.userdetails.User .password(u.getPasswordHash()) // already encoded: "{bcrypt}$2a$10$..." .roles(u.getRoles().toArray(String[]::new)) // adds the ROLE_ prefix for you .disabled(!u.isEnabled()) // -> DisabledException .accountLocked(u.isLocked()) // -> LockedException .build(); } } @Configuration class AuthBeans { @Bean PasswordEncoder passwordEncoder() { return PasswordEncoderFactories.createDelegatingPasswordEncoder(); // bcrypt by default } @Bean AuthenticationManager authenticationManager(AuthenticationConfiguration cfg) throws Exception { return cfg.getAuthenticationManager(); // needed only for a hand-written /login endpoint } }

When Boot finds exactly one UserDetailsService bean and one PasswordEncoder bean it wires them into a DaoAuthenticationProvider automatically; you never construct the provider. If you declare a UserDetailsService bean, Boot's in-memory default user (and the generated password) disappears.

What the exceptions really mean

ExceptionThrown whenClient sees
BadCredentialsException ("Bad credentials")Wrong password or unknown user (see below)401 (Basic) or redirect to /login?error (form)
UsernameNotFoundExceptionThrown by your service; DaoAuthenticationProvider converts it to BadCredentialsException by default (hideUserNotFoundExceptions = true) so attackers cannot enumerate accountsSame as above
DisabledException / LockedException / AccountExpiredException / CredentialsExpiredExceptionThe matching flag on UserDetails is set, checked before the password for disabled/locked/expired accounts401; message is specific, so decide whether you want to leak it
ProviderNotFoundExceptionNo provider supports() the token class500 or 401 depending on filter; a wiring bug, not a user error
InsufficientAuthenticationExceptionAnonymous user reached a rule that needs full authenticationEntry point: 401 or redirect
Timing side channel handled for you: when the user does not exist, DaoAuthenticationProvider still runs a dummy PasswordEncoder.matches against a precomputed hash (mitigateAgainstTimingAttack), so "unknown user" takes about as long as "wrong password". If you replace the provider, keep that behaviour.
⚠ Real production gotchas:
  • Plain-text passwords in the database. Logging in fails with IllegalArgumentException: There is no PasswordEncoder mapped for the id "null": the DelegatingPasswordEncoder expects a {bcrypt}-style prefix. Re-hash existing rows (or store {bcrypt} in front of legacy bcrypt hashes); never "fix" it with NoOpPasswordEncoder.
  • Two UserDetailsService beans (for example one for admins, one for customers): Boot backs off its auto-wiring and you must build the DaoAuthenticationProviders yourself and publish an AuthenticationManager/ProviderManager.
  • Lazy collections in loadUserByUsername: LazyInitializationException because the roles are touched outside a transaction. Fetch roles in the query (join fetch) or annotate with @Transactional(readOnly = true) as above.
  • Putting the JPA entity itself in the Authentication: it gets serialised into the HTTP session (stale, large, and lazy-proxy-ridden). Build a small immutable principal instead.
  • eraseCredentials: ProviderManager clears the password from the returned Authentication after success. If you rely on getCredentials() later you will see null.
05
Chapter Five · SecurityContext

Who Is the Current User, and How Does It Survive?

SecurityContextHolder is a static facade over a ThreadLocal<SecurityContext> (the default strategy MODE_THREADLOCAL). The context contains one thing: the current Authentication. That is why SecurityContextHolder.getContext().getAuthentication() works anywhere on the request thread, and why it returns null (or an anonymous token) on any other thread.

SecurityContext: per-request ThreadLocal, persisted between requests by a repository
REQUEST 1 POST /login Holder Filter empty context authenticate() user = alice saveContext(...) to repository clear ThreadLocal HttpSession SPRING_SECURITY _CONTEXT Set-Cookie: JSESSIONID REQUEST 2 GET /orders (Cookie: JSESSIONID=...) Holder Filter deferred load Authorization Filter: ok Controller @AuthPrincipal clear ThreadLocal loaded from session only when something calls getContext() ThreadLocal is cleared after every request; the session (or a bearer token) is the only thing that survives. Security 6: you must save the context explicitly if you authenticate by hand.

Because a ThreadLocal vanishes after the request, something must persist the context between requests. That is the SecurityContextRepository. For a session-based app it is HttpSessionSecurityContextRepository (stores the context under the session attribute SPRING_SECURITY_CONTEXT); for a stateless API it is RequestAttributeSecurityContextRepository, which lives only for the request. SecurityContextHolderFilter (Security 6; it replaced SecurityContextPersistenceFilter) loads the context lazily but no longer saves it automatically at the end of the request: the authenticating filter or your code must call saveContext.

Reading the caller

Four ways to get the current user
@GetMapping("/whoami") Map<String, Object> whoami(Authentication authentication, // 1: injected by MVC @AuthenticationPrincipal UserDetails principal, // 2: just the principal @AuthenticationPrincipal(expression = "username") String name, // 3: SpEL on the principal Principal simple) { // 4: java.security.Principal Authentication viaStatic = SecurityContextHolder.getContext().getAuthentication(); // 5: static, any layer return Map.of( "name", authentication.getName(), "authorities", authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList(), "same", viaStatic == authentication); }

Authenticating by hand (custom JSON login)

If you build your own POST /api/login instead of using formLogin, you are responsible for storing the context. This is the number-one silent break when upgrading from Security 5 to 6: the login call returns 200, but the next request is anonymous because the session never received the context.

LoginController.java — Security 6 requires the explicit save
@RestController @RequestMapping("/api") class LoginController { private final AuthenticationManager authenticationManager; private final SecurityContextRepository contextRepository = new HttpSessionSecurityContextRepository(); private final SecurityContextHolderStrategy holder = SecurityContextHolder.getContextHolderStrategy(); LoginController(AuthenticationManager authenticationManager) { this.authenticationManager = authenticationManager; } record LoginRequest(@NotBlank String username, @NotBlank String password) {} @PostMapping("/login") ResponseEntity<Void> login(@Valid @RequestBody LoginRequest body, HttpServletRequest request, HttpServletResponse response) { Authentication auth = authenticationManager.authenticate( UsernamePasswordAuthenticationToken.unauthenticated(body.username(), body.password())); SecurityContext context = holder.createEmptyContext(); context.setAuthentication(auth); holder.setContext(context); contextRepository.saveContext(context, request, response); // without this line: next request is anonymous return ResponseEntity.noContent().build(); } }

Threads, async and virtual threads

SituationDoes the context follow?What to do
Normal MVC request threadYesNothing
@Async / your own ExecutorServiceNo: new thread, empty ThreadLocalWrap the executor in DelegatingSecurityContextExecutor / DelegatingSecurityContextAsyncTaskExecutor, or pass the user id explicitly
Callable returned from a controller, DeferredResultYes for the Callable case (WebAsyncManagerIntegrationFilter)Keep return types simple; verify with a test
CompletableFuture.supplyAsync on the common poolNoCapture the Authentication first, or use a delegating executor
Reactive (WebFlux)Not a ThreadLocal at allSeparate world: ReactiveSecurityContextHolder

MODE_INHERITABLETHREADLOCAL exists but copies the context only at thread creation, so with thread pools it leaks the wrong user into reused threads. Prefer explicit delegation.

⚠ Real production gotchas:
  • Authentication works but the next request is anonymous (after a custom login or after migrating to Security 6): the context was never saved. Call securityContextRepository.saveContext(...), or use formLogin/httpBasic which do it for you.
  • Wrong user in a background job: a task submitted from request threads to a shared pool reads whatever the pool thread held. Use DelegatingSecurityContextRunnable.create(task, SecurityContext) and clear after.
  • Session bloat and stale roles: the whole Authentication is serialised into the session. Changing a user's role in the database does not affect logged-in users until they re-authenticate (or you invalidate sessions through SessionRegistry).
  • Calling SecurityContextHolder in domain code makes services untestable and tied to the web layer; pass the user id or an AuditorAware instead.
06
Chapter Six · Authorisation

URL Rules and Method Security

Authorisation answers "may this authentication do this thing?" and has two layers that you normally use together. URL rules run in AuthorizationFilter, early and cheap, and protect whole path families. Method security runs inside the application through AOP proxies, and protects business operations regardless of how they are reached (REST, messaging, scheduled job).

Why you sometimes get 401 and sometimes 403
AuthorizationFilter AuthorizationManager granted? rule match yes chain continues DispatcherServlet no: throw AccessDeniedException caught upstream ExceptionTranslationFilter who is the caller? anonymous AuthenticationEntryPoint 401 + WWW-Authenticate or redirect to /login logged in AccessDeniedHandler 403 Forbidden Rule of thumb 401 = I do not know who you are 403 = I know you, and the answer is still no (401 should say how to log in)

Roles are just prefixed authorities

Spring Security has one concept, GrantedAuthority (a string). A "role" is only an authority whose name starts with ROLE_. hasRole("ADMIN") checks for the authority ROLE_ADMIN; hasAuthority("ROLE_ADMIN") is the same check written longer. Scopes from OAuth2 appear as authorities named SCOPE_orders:read. Choose one vocabulary per application and keep it consistent.

Rules are evaluated top to bottom, first match wins
http.authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.POST, "/api/orders").hasAuthority("SCOPE_orders:write") // most specific first .requestMatchers("/api/orders/**").hasAuthority("SCOPE_orders:read") .requestMatchers("/api/admin/**").hasRole("ADMIN") // = ROLE_ADMIN .requestMatchers("/actuator/health/**").permitAll() .requestMatchers("/actuator/**").hasRole("OPS") // everything else under actuator .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // only if you do not use cors() .anyRequest().denyAll()); // deny by default

Method security

@EnableMethodSecurity registers advisors (AuthorizationManagerBeforeMethodInterceptor, AuthorizationManagerAfterMethodInterceptor) that wrap any bean with @PreAuthorize, @PostAuthorize, @PreFilter, @PostFilter. The expressions are SpEL, evaluated against authentication, the method arguments (#id), returnObject, and any bean through @beanName.

OrderService.java
@Service public class OrderService { @PreAuthorize("hasRole('ADMIN') or #customerId == authentication.name") public List<Order> forCustomer(String customerId) { ... } @PostAuthorize("returnObject.ownerId == authentication.name or hasRole('ADMIN')") public Order find(long id) { ... } // runs the query FIRST, then decides @PreAuthorize("@orderAccess.canCancel(#id, authentication)") public void cancel(long id) { ... } // delegate complex rules to a bean you can unit-test @PreAuthorize("hasAuthority('SCOPE_orders:write')") @Transactional public Order create(NewOrder cmd) { ... } } @Component("orderAccess") class OrderAccess { private final OrderRepository orders; OrderAccess(OrderRepository orders) { this.orders = orders; } boolean canCancel(long id, Authentication auth) { return orders.findOwnerId(id).map(owner -> owner.equals(auth.getName())).orElse(false); } }

Because method security is proxy-based, everything from the AOP page applies: calls inside the same class bypass the check (self-invocation), private and final methods cannot be intercepted, and annotations on an interface only work with interface-based proxies unless you annotate the implementation. Also note @PostAuthorize executes the method (and any side effects, even writes) before denying the response; use it for reads only.

LayerWhere it runsGood forWeak at
URL rules (authorizeHttpRequests)Filter, before MVCWhole sections, deny-by-default, health/static pathsPer-object rules ("only my orders"); path aliasing mistakes
@PreAuthorizeAOP proxy, before the methodRole/scope checks, argument-based ownership checksSelf-invocation; rules scattered across services
@PostAuthorizeAOP proxy, after the method"Owner of the loaded entity" rulesSide effects already happened; wasted query for denied callers
Query-level filtering (WHERE owner = :user)RepositoryLists: never load rows the user may not seeForgetting it in one query
⚠ Real production gotchas:
  • hasRole("ROLE_ADMIN") throws IllegalArgumentException: role should not start with 'ROLE_' since it is automatically inserted. Got 'ROLE_ADMIN'. Use hasRole("ADMIN") or hasAuthority("ROLE_ADMIN").
  • Roles stored as ADMIN in GrantedAuthority (no prefix): hasRole("ADMIN") never matches and every admin gets 403. Check what your UserDetailsService or JWT converter actually emits.
  • A broader rule above a narrower one (/api/** authenticated, then /api/admin/** admin): the admin rule is dead code. Order from most to least specific.
  • A rule protects URL patterns, not controllers. If the same handler is reachable through a second mapping (an extra path in @RequestMapping, a forward, a different servlet), the second path needs its own rule: keep anyRequest().authenticated() or denyAll() as the final rule so unknown paths fail closed. Security 6's StrictHttpFirewall already rejects ;, encoded slashes and // with RequestRejectedException; do not loosen it to make a client work.
  • Method security annotation on a controller method called internally or on a private helper: silently unprotected. Put @PreAuthorize on public methods of a Spring bean invoked through its proxy, and add a test that calls it as the wrong user (see the Testing page).
  • The legacy @EnableGlobalMethodSecurity is removed in Security 6; use @EnableMethodSecurity. Note it also defaults prePostEnabled to true, whereas the old one required it.
07
Chapter Seven · CSRF

Cross-Site Request Forgery

Browsers attach cookies to a request based on the destination, not on which page triggered it. If you are logged in to bank.example with a session cookie and visit evil.example, a hidden form on that page can POST to bank.example/transfer and the browser will happily include your cookie. The attacker cannot read the response, but the state change already happened. The defence is a secret that the attacker's page cannot know: a CSRF token the server issues, and a legitimate page echoes back in a header or form field.

CSRF: the browser sends credentials the attacker never sees
Browser logged in to bank bank.example POST /transfer evil.example hidden form 1 login Set-Cookie JSESSIONID 2 visits evil page HTML + auto-submit form 3 POST /transfer (+cookie) no CSRF defence: transfer executes CsrfFilter: no token -> AccessDeniedException -> 403 the browser attaches the cookie automatically - that is the bug evil.example cannot read the token (same-origin policy)

How CsrfFilter works (Security 6)

Every request: CsrfFilter asks the CsrfTokenRepository for the token
▶
Default repository is HttpSessionCsrfTokenRepository (token kept in the session). In Security 6 loading is deferred: no session is created or read until something needs the token
Method is GET, HEAD, TRACE or OPTIONS ("safe")
▶
No check; the request continues. This is why state changes must never be done with GET
Any other method
▶
The filter reads the submitted token from the header X-CSRF-TOKEN or the request parameter _csrf and compares it with the stored one; mismatch means InvalidCsrfTokenException or MissingCsrfTokenException handed to the AccessDeniedHandler: 403
Default request handler is XorCsrfTokenRequestAttributeHandler
▶
Each response embeds a freshly XOR-masked copy of the token (BREACH protection) while the stored token stays constant; Thymeleaf and <form:form> insert the hidden field automatically

When to disable it, and when never to

Application typeCredentials travel asCSRF?
Server-rendered pages, form loginSession cookieKeep enabled; forms include the token
SPA on the same site using session cookieSession cookieKeep enabled; use cookie repository so JS can read the token (below)
Pure API, Authorization: Bearer header, STATELESSHeader set by your JS/client codeSafe to disable: browsers never attach that header automatically
API that also accepts a cookie (JWT in cookie)CookieKeep enabled (or rely on SameSite plus custom header checks consciously)
Machine-to-machine webhooks with HMACSignature headerDisable for that path only via csrf.ignoringRequestMatchers(...)
CSRF for a cookie-authenticated SPA
@Bean SecurityFilterChain spa(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) // cookie XSRF-TOKEN, readable by JS .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()) // plain (non-XOR) so the cookie value is usable .ignoringRequestMatchers("/webhooks/**")) .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class) .authorizeHttpRequests(a -> a.anyRequest().authenticated()) .formLogin(Customizer.withDefaults()); return http.build(); } /** Deferred tokens are only generated when read; touching it forces the cookie onto every response. */ final class CsrfCookieFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException { CsrfToken token = (CsrfToken) req.getAttribute(CsrfToken.class.getName()); if (token != null) token.getToken(); // render => cookie written chain.doFilter(req, res); } }

Angular's HttpClient and Axios read the XSRF-TOKEN cookie and send it back as X-XSRF-TOKEN by default; that is the header name CookieCsrfTokenRepository expects. Defence in depth: set the session cookie SameSite=Lax (Boot property server.servlet.session.cookie.same-site=lax), which already stops most cross-site POSTs, but do not rely on it alone (older browsers, sub-domain takeover, same-site attackers).

⚠ Real production gotchas:
  • "It works in Postman but POST returns 403 in the browser" (or the reverse): the 403 body is Invalid CSRF Token 'null' was found on the request parameter '_csrf' or header 'X-CSRF-TOKEN'. Send the header, or fix the cookie/header names.
  • csrf().disable() copied from a tutorial onto an app that authenticates by session cookie: you have removed the only CSRF defence. Disable it only when the credentials cannot be sent implicitly by the browser.
  • Logout via GET with CSRF enabled: Security 6 requires POST /logout; a link <a href="/logout"> just shows a confirmation page.
  • Multiple server instances behind a load balancer with HttpSessionCsrfTokenRepository and no sticky sessions/session replication: token not found, random 403s. Use Spring Session or the cookie repository.
  • Token in the URL (?_csrf=...) leaks via logs and Referer. Use headers or hidden form fields.
08
Chapter Eight · CORS

Cross-Origin Resource Sharing

CORS is not a security feature of your server; it is the browser's relaxation of the same-origin policy. By default JavaScript on https://app.example.com may not read responses from https://api.example.com (different origin = scheme + host + port). The API opts in by sending Access-Control-Allow-* headers. For "non-simple" requests (custom headers like Authorization, methods like PUT, JSON content types) the browser first sends a preflight OPTIONS request with no credentials and only continues if the answer is positive.

CORS preflight: a permission check the browser makes before your real request
Browser tab Origin: app.example.com API api.example.com OPTIONS /orders (preflight, NO cookies, NO Authorization) 200 + Access-Control-Allow-Origin / -Methods / -Headers PUT /orders + Authorization: Bearer ... 200 + Allow-Origin (needed on the real response too) CorsFilter runs BEFORE authentication, so the preflight is answered without a 401 Browser enforces this, not the server: curl and Postman ignore CORS No Access-Control-Allow-Origin header on the response = browser blocks JS from reading it.

The Spring-specific trap is where the preflight goes. If your authentication filters see the unauthenticated OPTIONS first, they answer 401 and the browser reports "CORS error" even though your CORS configuration is perfect. http.cors() installs CorsFilter before the authentication filters (position 5 in the Chapter 3 diagram), and it answers valid preflights itself without ever reaching authorisation.

CORS configured once, enforced in the security chain
@Bean CorsConfigurationSource corsConfigurationSource() { CorsConfiguration cfg = new CorsConfiguration(); cfg.setAllowedOrigins(List.of("https://app.example.com", "https://admin.example.com")); // exact origins cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); cfg.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-XSRF-TOKEN")); cfg.setExposedHeaders(List.of("Location", "ETag")); // headers JS may read from the response cfg.setAllowCredentials(true); // cookies / Authorization with fetch(credentials: 'include') cfg.setMaxAge(Duration.ofHours(1)); // browser caches the preflight answer UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/api/**", cfg); return source; // bean name is picked up by http.cors(withDefaults()) } // in the SecurityFilterChain: http.cors(Customizer.withDefaults());
CORSCSRF
Protects againstOther sites reading your API responses from a browserOther sites causing state changes using the victim's cookies
Enforced byThe browser (server only sends headers)The server (CsrfFilter)
A permissive setting meansForeign pages can read authenticated dataForeign pages can trigger actions
Fixes the other?No. A correct CORS policy does not stop a plain cross-site form POST, and CSRF tokens do not allow cross-origin reads
⚠ Real production gotchas:
  • allowedOrigins("*") with allowCredentials(true) fails at startup or first request: IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot contain the special value "*" since that cannot be set on the "Access-Control-Allow-Origin" response header. To allow credentials to a set of origins, list them explicitly or consider using "allowedOriginPatterns" instead.
  • CORS configured only through @CrossOrigin or WebMvcConfigurer.addCorsMappings, but the preflight is rejected by security (401/403 on OPTIONS): enable http.cors(...) so CorsFilter runs first. Symptom in DevTools: preflight request red with status 401.
  • Error responses without CORS headers: the browser hides the 401/500 body and shows only "blocked by CORS policy". Test the failure path with curl -i -H 'Origin: ...' before blaming the browser.
  • Reflecting any Origin (custom code echoing the header, or allowedOriginPatterns("*") with credentials): any website can read your users' data. Allow-list exact origins.
  • Forgetting Vary: Origin when writing your own filter: a CDN caches the response for one origin and serves it to others. Spring's CorsFilter adds it for you.
09
Chapter Nine · Sessions

Session Management and Statelessness

There are two ways to remember a user between requests. Stateful: the server keeps a session (HttpSession) keyed by a cookie, and the SecurityContext lives inside it. Stateless: nothing is remembered server-side; every request carries a self-contained credential (HTTP Basic, a JWT, an opaque token that is introspected) and the context is rebuilt for that request only. The choice affects CSRF (cookies vs headers), scaling (sticky sessions vs none), and revocation (delete a session vs wait for a token to expire).

SessionCreationPolicyCreates a session?Uses an existing one?Typical use
IF_REQUIRED (default)When something needs it (form login, CSRF token)YesServer-rendered apps
ALWAYSOn every requestYesRare; wasteful
NEVERNo (Spring Security will not)Yes, if the container made oneMixed with legacy code that creates sessions
STATELESSNoNo (context kept per request only)Bearer-token REST APIs

Session fixation, concurrency, timeouts

Session fixation: an attacker plants a known session id in the victim's browser, waits for the victim to log in, and then reuses that id. Spring Security's default is to call request.changeSessionId() on successful authentication, so the id the attacker knew dies at login. You get this for free; do not disable it. Concurrent sessions are limited with maximumSessions(n), which needs a HttpSessionEventPublisher bean so the registry hears about expired sessions.

Session policy for a UI chain
http.sessionManagement(s -> s .maximumSessions(1) // a second login expires the first session... .maxSessionsPreventsLogin(false)) // ...(true instead rejects the second login) .logout(l -> l .deleteCookies("JSESSIONID") .invalidateHttpSession(true)); @Bean HttpSessionEventPublisher httpSessionEventPublisher() { // required for maximumSessions to work return new HttpSessionEventPublisher(); }
application.yml — cookie hardening and timeout
server: servlet: session: timeout: 30m # idle timeout cookie: http-only: true # JS cannot read the session cookie (default true in Boot) secure: true # only over HTTPS same-site: lax # mitigates CSRF and cross-site leakage
⚠ Real production gotchas:
  • Declaring STATELESS but the API still sets JSESSIONID: something else created a session (a CSRF repository, a controller calling request.getSession(), an @SessionScope bean). STATELESS tells Spring Security not to create or use one; it cannot stop your code.
  • Users logged out "randomly" behind a load balancer: sessions are in memory per node. Use sticky sessions, or move sessions to Redis/JDBC with Spring Session.
  • Roles changed but the user keeps old rights: the old Authentication sits in the session. Expire it through SessionRegistry.getAllSessions(principal, false) and expireNow(), or keep sessions short.
  • Surprise logouts in development: Boot's default idle timeout is 30 minutes (server.servlet.session.timeout), and the in-memory session store is lost on every restart, including devtools restarts.
10
Chapter Ten · Password Storage

Hashing, Salting and the DelegatingPasswordEncoder

A password database should be useless to whoever steals it. That requires a slow, salted, one-way hash: slow so each guess costs real CPU or memory, salted so identical passwords produce different hashes and precomputed tables are worthless, one-way so there is nothing to decrypt. General-purpose hashes (MD5, SHA-1, SHA-256) are built to be fast and are therefore the wrong tool even when salted. Spring Security's PasswordEncoder abstraction exposes exactly the three operations you need: encode, matches, and upgradeEncoding.

Anatomy of a DelegatingPasswordEncoder bcrypt hash
{bcrypt}$2a$10$N9qo8uLOickgx2ZMRZoMyeIjdQb7Ux7eHT0oVwD1G0R8xQ1Hn1 {bcrypt} algorithm id $2a$ version 10$ cost = 2^10 N9qo8uLOickgx2ZMRZoMye 22 chars = 128-bit random salt IjdQb7Ux7eHT0oVwD1G0R8... 31 chars = 184-bit hash output The salt and the cost live inside the stored string: matches(raw, stored) re-derives everything it needs from it. cost 10 : ~65 ms cost 12 : ~250 ms cost 14 : ~1 s each +1 doubles the work (typical laptop) Attacker with a stolen table must pay this cost PER GUESS, PER USER, because each user has a different salt - no rainbow tables, no shared work.

PasswordEncoderFactories.createDelegatingPasswordEncoder() returns a DelegatingPasswordEncoder. It encodes with the current default (bcrypt) and prefixes the result with {id}; on matches it reads the prefix and routes to the right encoder. That is what lets you move from one algorithm to another without a flag day: old rows keep verifying, and DaoAuthenticationProvider can re-hash a user's password to the new default at their next successful login when you provide a UserDetailsPasswordService (upgradeEncoding).

AlgorithmHard againstNotes
bcrypt (BCryptPasswordEncoder)CPU, some GPUDefault. Cost factor (strength) defaults to 10; only the first 72 bytes of the input matter. Tune cost so a login takes ~100-250 ms on production hardware
Argon2id (Argon2PasswordEncoder)CPU and memory (GPU/ASIC)OWASP's first choice where available; needs Bouncy Castle on the classpath
scrypt (SCryptPasswordEncoder)MemoryGood alternative; also needs Bouncy Castle
PBKDF2 (Pbkdf2PasswordEncoder)CPU onlyFIPS-friendly; OWASP suggests 600,000 iterations with HMAC-SHA256. Pure JDK
Plain, MD5, SHA-*, NoOpPasswordEncoderNothingNever in production; {noop} exists only for demos and tests
Registering and upgrading encoders
@Bean PasswordEncoder passwordEncoder() { String current = "bcrypt"; Map<String, PasswordEncoder> encoders = new HashMap<>(); encoders.put("bcrypt", new BCryptPasswordEncoder(12)); // new passwords: cost 12 encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()); // already in some rows encoders.put("sha256", new MessageDigestPasswordEncoder("SHA-256")); // legacy, to be migrated away return new DelegatingPasswordEncoder(current, encoders); } // Re-hash on successful login when the stored hash uses an older id or lower cost: @Service class PasswordUpgrade implements UserDetailsPasswordService { private final UserAccountRepository users; PasswordUpgrade(UserAccountRepository users) { this.users = users; } @Override @Transactional public UserDetails updatePassword(UserDetails user, String newEncodedPassword) { UserAccount u = users.findByEmailIgnoreCase(user.getUsername()).orElseThrow(); u.setPasswordHash(newEncodedPassword); return User.withUserDetails(user).password(newEncodedPassword).build(); } }
⚠ Real production gotchas:
  • Encoding the same value twice (once in the service, once in a JPA listener): login fails because the stored hash is a hash of a hash. Encode in exactly one place, ideally the registration service.
  • Cost factor chosen on a laptop, deployed to a 0.25 vCPU container: logins take seconds and become a denial-of-service vector. Measure on target hardware, and rate-limit the login endpoint.
  • Comparing passwords or tokens with String.equals in your own code: use MessageDigest.isEqual (constant-time). The encoders do it internally; custom API-key checks usually do not.
  • Logging the request body of the login endpoint (a debug CommonsRequestLoggingFilter, an access log with payloads): plaintext passwords end up in your log store.
  • Password reset tokens stored raw: store only a hash of the token, give it a short expiry, and make it single-use.
11
Chapter Eleven · Build It Yourself

A Filter Chain, a Password Hash and a CSRF Check in 150 Lines

The fastest way to stop treating Spring Security as magic is to build the skeleton. The program below imitates the real architecture with no Spring and no servlet API: a Filter interface with chain.next(), a ThreadLocal context, a ProviderManager-style authenticate that hides "user not found", a PBKDF2 encoder with a per-user salt and a constant-time comparison, a CSRF filter, an exception-translation filter that maps denied while anonymous to 401 and denied while logged in to 403, and a last filter that applies first-match-wins rules. It uses only JDK classes (javax.crypto, MessageDigest).

MiniSecurity.java
import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.SecureRandom; import java.util.*; import javax.crypto.SecretKeyFactory; import javax.crypto.spec.PBEKeySpec; /** A 120-line imitation of Spring Security's servlet architecture. No Spring, no servlet API. */ public class MiniSecurity { // ---- request / response ------------------------------------------------ record Request(String method, String path, Map<String, String> headers, Map<String, String> session) {} static class Response { int status = 200; String body = ""; } // ---- exceptions (mirror AuthenticationException / AccessDeniedException) ----- static class AuthException extends RuntimeException { AuthException(String m) { super(m); } } static class DeniedException extends RuntimeException { DeniedException(String m) { super(m); } } // ---- authentication model ------------------------------------------------ record Authentication(String name, Set<String> authorities, boolean authenticated) { static Authentication anonymous() { return new Authentication("anonymousUser", Set.of("ROLE_ANONYMOUS"), false); } } static final ThreadLocal<Authentication> CONTEXT = new ThreadLocal<>(); // SecurityContextHolder // ---- password encoder (DelegatingPasswordEncoder style: {id}salt$hash) ------ static String encode(String raw, int iterations) throws Exception { byte[] salt = new byte[16]; new SecureRandom().nextBytes(salt); return "{pbkdf2}" + iterations + "$" + HexFormat.of().formatHex(salt) + "$" + hash(raw, salt, iterations); } static String hash(String raw, byte[] salt, int it) throws Exception { var spec = new PBEKeySpec(raw.toCharArray(), salt, it, 256); return HexFormat.of().formatHex(SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded()); } static boolean matches(String raw, String stored) throws Exception { String[] p = stored.substring("{pbkdf2}".length()).split("\\$"); String candidate = hash(raw, HexFormat.of().parseHex(p[1]), Integer.parseInt(p[0])); return MessageDigest.isEqual(candidate.getBytes(StandardCharsets.UTF_8), p[2].getBytes(StandardCharsets.UTF_8)); // constant time } // ---- UserDetailsService + DaoAuthenticationProvider ------------------------- record UserRecord(String hash, Set<String> authorities) {} static final Map<String, UserRecord> USERS = new HashMap<>(); static Authentication authenticate(String user, String password) throws Exception { UserRecord u = USERS.get(user); if (u == null) throw new AuthException("Bad credentials"); // hideUserNotFoundExceptions = true if (!matches(password, u.hash())) throw new AuthException("Bad credentials"); return new Authentication(user, u.authorities(), true); } // ---- filter chain ------------------------------------------------------------- interface Chain { void next(Request req, Response res) throws Exception; } interface Filter { void doFilter(Request req, Response res, Chain chain) throws Exception; } static final List<String> TRACE = new ArrayList<>(); static Filter named(String name, Filter f) { return (req, res, chain) -> { TRACE.add(">" + name); f.doFilter(req, res, chain); TRACE.add("<" + name); }; } static Chain build(List<Filter> filters, Chain terminal) { Chain c = terminal; for (int i = filters.size() - 1; i >= 0; i--) { Filter f = filters.get(i); Chain next = c; c = (req, res) -> f.doFilter(req, res, next); } return c; } public static void main(String[] args) throws Exception { USERS.put("alice", new UserRecord(encode("s3cret", 50_000), Set.of("ROLE_USER"))); USERS.put("root", new UserRecord(encode("r00t", 50_000), Set.of("ROLE_USER", "ROLE_ADMIN"))); Filter contextFilter = named("SecurityContextHolderFilter", (req, res, chain) -> { CONTEXT.set(Authentication.anonymous()); try { chain.next(req, res); } finally { CONTEXT.remove(); } // always clear the ThreadLocal }); Filter csrfFilter = named("CsrfFilter", (req, res, chain) -> { if (!Set.of("GET", "HEAD", "OPTIONS").contains(req.method())) { String expected = req.session().get("csrf"), actual = req.headers().get("X-CSRF-TOKEN"); if (expected == null || actual == null || !MessageDigest.isEqual(expected.getBytes(), actual.getBytes())) { res.status = 403; res.body = "Invalid CSRF token"; return; // short-circuit: chain stops here } } chain.next(req, res); }); Filter basicFilter = named("BasicAuthenticationFilter", (req, res, chain) -> { String h = req.headers().get("Authorization"); if (h != null && h.startsWith("Basic ")) { String[] up = new String(Base64.getDecoder().decode(h.substring(6))).split(":", 2); try { CONTEXT.set(authenticate(up[0], up[1])); } catch (AuthException e) { res.status = 401; res.body = e.getMessage(); return; } } chain.next(req, res); }); Filter exceptionFilter = named("ExceptionTranslationFilter", (req, res, chain) -> { try { chain.next(req, res); } catch (DeniedException e) { if (!CONTEXT.get().authenticated()) { res.status = 401; res.body = "Unauthorized (entry point)"; } else { res.status = 403; res.body = "Forbidden (access denied handler)"; } } }); Filter authorizationFilter = named("AuthorizationFilter", (req, res, chain) -> { Authentication a = CONTEXT.get(); String p = req.path(); if (p.startsWith("/public/")) { /* permitAll */ } else if (p.startsWith("/admin/")) { if (!a.authorities().contains("ROLE_ADMIN")) throw new DeniedException("admin only"); } else if (!a.authenticated()) throw new DeniedException("anyRequest().authenticated()"); // anyRequest last chain.next(req, res); }); Chain chain = build(List.of(contextFilter, csrfFilter, basicFilter, exceptionFilter, authorizationFilter), (req, res) -> { TRACE.add(" DispatcherServlet -> controller as " + CONTEXT.get().name()); res.body = "OK"; }); String basicAlice = "Basic " + Base64.getEncoder().encodeToString("alice:s3cret".getBytes()); String basicBad = "Basic " + Base64.getEncoder().encodeToString("alice:wrong".getBytes()); String basicRoot = "Basic " + Base64.getEncoder().encodeToString("root:r00t".getBytes()); Map<String, String> sess = Map.of("csrf", "tok-123"); Object[][] cases = { {"GET", "/public/info", null, null}, {"GET", "/orders", null, null}, {"GET", "/orders", basicBad, null}, {"GET", "/orders", basicAlice, null}, {"GET", "/admin/users", basicAlice, null}, {"GET", "/admin/users", basicRoot, null}, {"POST", "/orders", basicAlice, null}, {"POST", "/orders", basicAlice, "tok-123"}, }; for (Object[] c : cases) { Map<String, String> headers = new HashMap<>(); if (c[2] != null) headers.put("Authorization", (String) c[2]); if (c[3] != null) headers.put("X-CSRF-TOKEN", (String) c[3]); TRACE.clear(); Response res = new Response(); chain.next(new Request((String) c[0], (String) c[1], headers, sess), res); System.out.printf("%-4s %-13s auth=%-6s csrf=%-5s -> %d %s%n", c[0], c[1], c[2] == null ? "none" : (c[2] == basicAlice ? "alice" : c[2] == basicRoot ? "root" : "bad"), c[3] == null ? "-" : "ok", res.status, res.body); System.out.println(" entered: " + TRACE.stream().filter(t -> !t.startsWith("<")) .map(t -> t.replaceFirst("^>|^ +", "").replace("Filter", "")).reduce((a, b) -> a + " > " + b).orElse("")); } // password hashing cost for (int it : new int[]{1_000, 50_000, 600_000}) { long t0 = System.nanoTime(); encode("s3cret", it); System.out.printf("PBKDF2-HMAC-SHA256 %,8d iterations: %4d ms%n", it, (System.nanoTime() - t0) / 1_000_000); } String h1 = encode("s3cret", 1000), h2 = encode("s3cret", 1000); System.out.println("same password, two salts -> equal hashes? " + h1.equals(h2) + ", both verify? " + (matches("s3cret", h1) && matches("s3cret", h2))); } }

Compile and run it with javac MiniSecurity.java && java MiniSecurity. This is real output from that run (only the timing numbers vary by machine):

Output
GET /public/info auth=none csrf=- -> 200 OK entered: SecurityContextHolder > Csrf > BasicAuthentication > ExceptionTranslation > Authorization > DispatcherServlet -> controller as anonymousUser GET /orders auth=none csrf=- -> 401 Unauthorized (entry point) entered: SecurityContextHolder > Csrf > BasicAuthentication > ExceptionTranslation > Authorization GET /orders auth=bad csrf=- -> 401 Bad credentials entered: SecurityContextHolder > Csrf > BasicAuthentication GET /orders auth=alice csrf=- -> 200 OK entered: SecurityContextHolder > Csrf > BasicAuthentication > ExceptionTranslation > Authorization > DispatcherServlet -> controller as alice GET /admin/users auth=alice csrf=- -> 403 Forbidden (access denied handler) entered: SecurityContextHolder > Csrf > BasicAuthentication > ExceptionTranslation > Authorization GET /admin/users auth=root csrf=- -> 200 OK entered: SecurityContextHolder > Csrf > BasicAuthentication > ExceptionTranslation > Authorization > DispatcherServlet -> controller as root POST /orders auth=alice csrf=- -> 403 Invalid CSRF token entered: SecurityContextHolder > Csrf POST /orders auth=alice csrf=ok -> 200 OK entered: SecurityContextHolder > Csrf > BasicAuthentication > ExceptionTranslation > Authorization > DispatcherServlet -> controller as alice PBKDF2-HMAC-SHA256 1,000 iterations: 1 ms PBKDF2-HMAC-SHA256 50,000 iterations: 24 ms PBKDF2-HMAC-SHA256 600,000 iterations: 270 ms same password, two salts -> equal hashes? false, both verify? true
Row 2 and row 5: same AccessDeniedException-style denial, different status
▶
The exception filter inspects who the caller is: anonymous means 401 (log in), authenticated means 403 (you may not). That is the real ExceptionTranslationFilter logic
Row 3: wrong password stops at BasicAuthentication
▶
The "entered" list shows no exception or authorization filter ran: an authentication filter that fails writes the response itself and never calls chain.next
Row 7: POST without a token stops at Csrf
▶
The request is rejected before the password is even checked, which is exactly why CsrfFilter sits near the top of the real chain
Last lines: cost and salting
▶
600,000 iterations cost roughly 10x more than 50,000 (compare the timings above): that ratio is the attacker's cost per guess. Two encodings of the same password differ because the salt differs, yet both verify
Try these experiments:
  • Swap the order of exceptionFilter and authorizationFilter in the list. Denials now escape as raw exceptions: filter order is behaviour, not decoration.
  • Replace MessageDigest.isEqual with String.equals in matches and measure comparison time for hashes that differ in the first vs last byte over a million runs: the difference is tiny in Java but it is the reason crypto code never uses early-exit comparison.
  • Delete the final else if (!a.authenticated()) branch in authorizationFilter (the anyRequest() rule) and watch every unlisted path become reachable by anonymous callers: that is what fail-open looks like.
12
Chapter Twelve · Debugging & Pitfalls

Misconfigurations That Ship to Production

Most Spring Security incidents are not exploits of the framework; they are configuration that does less than the author believed. Use the table as a review checklist, and keep logging.level.org.springframework.security=DEBUG (or TRACE) handy to see which filter produced a response.

SymptomLikely causeFix / how to confirm
Every endpoint returns a login page or 401 after adding the starterDefault chain is activeDeclare your own SecurityFilterChain; read Using generated security password in the log
403 on every POST/PUT/DELETE from a browser or curl, GETs fineCSRF enabled for a client that cannot supply the tokenSend the token; or disable CSRF only for stateless bearer APIs
Browser "CORS error", server log shows 401 on OPTIONScors() not in the security chainhttp.cors(...) plus a CorsConfigurationSource bean
Admin gets 403 although they have the roleAuthority stored as ADMIN but rule uses hasRole("ADMIN") (needs ROLE_ADMIN)Log authentication.getAuthorities(); align the prefix
Login succeeds, next request anonymous (custom login)Context not saved (Security 6)securityContextRepository.saveContext(...)
Endpoint reachable without login that "should" be protectedRule order, a permitAll pattern too wide (/**), a second mapping, or the path ignored by WebSecurityCustomizerWrite negative tests; end with anyRequest().authenticated() or denyAll()
H2 console shows blank frameX-Frame-Options: DENY defaultDev profile only: headers(h -> h.frameOptions(f -> f.sameOrigin())) and a matcher for the console path
Actuator endpoints open to the internetExposed in config and covered by permitAllExpose a minimum list, secure /actuator/** with a role or a separate management port
@PreAuthorize has no effectMissing @EnableMethodSecurity, self-invocation, or private methodCall through another bean; verify with a test as the wrong user
RequestRejectedException: The request was rejected because the URL contained a potentially malicious StringStrictHttpFirewall blocking ; or // or encoded slashFix the client; only as a last resort customise the firewall bean
Every user suddenly gets Bad credentials after a library upgradeEncoder id/prefix mismatch, or default strength changedCheck stored prefix ({bcrypt}) and DelegatingPasswordEncoder registrations
Fail open vs fail closed

A permitAll or ignored path that "just works" is invisible in testing. Finish every chain with anyRequest().authenticated() (or denyAll()) so new endpoints are protected by default.

Test the negative

For every protected route write three tests: anonymous (expect 401), wrong role (403), right role (200). See the Testing page for @WithMockUser and SecurityMockMvcRequestPostProcessors.

Layer the defences

URL rule at the edge, method rule at the service, query filter at the repository. A bug in one layer should not be a breach.

Never roll your own crypto

Use the encoders, the JOSE library behind JwtDecoder (next page), and TLS from the platform. The Chapter 11 code is a teaching model, not a library.

13
Chapter Thirteen · Interview Angles

Ten Questions You Will Be Asked

1. Explain the Spring Security architecture in one minute.

A DelegatingFilterProxy registers one servlet filter that delegates to FilterChainProxy; it chooses the first matching SecurityFilterChain, a fixed-order list of filters. Authentication filters build an Authentication via AuthenticationManager and store it in the SecurityContext; AuthorizationFilter decides; ExceptionTranslationFilter maps failures to 401/403. Trap: "it is an interceptor" (no, it runs before Spring MVC).

2. 401 vs 403?

401 = not authenticated (or bad credentials), carries a challenge; 403 = authenticated but not permitted. ExceptionTranslationFilter picks the AuthenticationEntryPoint for anonymous callers and the AccessDeniedHandler for authenticated ones. Trap: CSRF failures are also 403.

3. How does username/password authentication work internally?

Filter creates an unauthenticated token, ProviderManager finds a provider that supports it, DaoAuthenticationProvider calls UserDetailsService and PasswordEncoder.matches, returns an authenticated token, the filter saves it to the context. Trap: why "user not found" becomes "Bad credentials" (account enumeration).

4. hasRole vs hasAuthority?

Same mechanism; hasRole("X") checks the authority ROLE_X. Trap: hasRole("ROLE_X") throws.

5. Why can you disable CSRF for a JWT API but not for a session app?

CSRF abuses credentials the browser attaches automatically (cookies). A bearer header is added by your own code, so a foreign page cannot make the browser send it. Trap: JWT stored in a cookie is automatically attached again, so CSRF is back.

6. CORS vs CSRF?

CORS lets a browser read cross-origin responses; CSRF forges writes using ambient credentials. CORS is enforced by browsers; CSRF defence by the server. Neither replaces the other.

7. How do you store passwords?

Slow adaptive salted hash through DelegatingPasswordEncoder (bcrypt default; Argon2id/scrypt/PBKDF2 are alternatives), with upgradeEncoding for migrations. Trap: "SHA-256 with a salt" is still too fast.

8. What changed from Security 5 to 6?

WebSecurityConfigurerAdapter, antMatchers, authorizeRequests, @EnableGlobalMethodSecurity removed; authorizeHttpRequests/requestMatchers/@EnableMethodSecurity and lambda DSL; SecurityContextHolderFilter no longer saves the context automatically; deferred CSRF token with XOR masking.

9. Why does SecurityContextHolder return null in my @Async method?

ThreadLocal strategy; the pool thread has its own empty context. Use DelegatingSecurityContextAsyncTaskExecutor or pass the identity explicitly.

10. Where do you put authorisation: URL rules, method security, or both?

Both: URL rules give coarse deny-by-default, method security protects business operations and per-object rules, repositories filter data. Trap: @PreAuthorize is proxy-based, so self-invocation bypasses it.

14
Chapter Fourteen · Summary

Cheat Sheet and Where to Go Next

QuestionComponentConfigure with
Which chain handles this request?FilterChainProxysecurityMatcher + @Order
Who is calling?Authentication in SecurityContextformLogin, httpBasic, oauth2ResourceServer
Are the credentials right?AuthenticationManager → providerUserDetailsService + PasswordEncoder beans
May they do this URL?AuthorizationFilterauthorizeHttpRequests (first match wins)
May they call this method / see this object?Method interceptors@EnableMethodSecurity, @PreAuthorize
Is this POST really from my page?CsrfFiltercsrf(...), token repository
May another origin read my API?CorsFiltercors(...) + CorsConfigurationSource
Remembered between requests?SecurityContextRepositorysessionManagement, STATELESS
Passwords at rest?DelegatingPasswordEncoderbcrypt / Argon2id + upgrade path
∑ Spring Security — what to remember
  • It is a chain of servlet filters that runs before MVC; the first matching SecurityFilterChain owns the request and filter order is fixed by the framework.
  • Authentication = a token in, an Authentication out via AuthenticationManager; you supply UserDetailsService and PasswordEncoder, Boot wires the rest.
  • The SecurityContext is a ThreadLocal per request; a repository (session or none) is what carries it between requests, and Security 6 does not save it for you.
  • Authorise in layers: URL rules (first match wins, finish with a catch-all), then method security on service beans, then query-level filtering.
  • CSRF protects cookie-based sessions; CORS is a browser read-permission; they solve different problems, and a stateless bearer API can drop CSRF.
  • Hash passwords with a slow salted algorithm through DelegatingPasswordEncoder and plan the upgrade path from day one.
Related pages: