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.
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.
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.
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
userwith 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-cachefor secured responses,X-Frame-Options: DENY), session fixation protection, and a logout endpoint. - Everything is replaceable: declare your own
SecurityFilterChainbean and Boot's default backs off (see the Boot Internals page for how@ConditionalOnMissingBeandoes that).
The Filter Chain Is the Whole Story
Filter and Servlet, not Spring beansDelegatingFilterProxy, whose only job is to look up the bean named springSecurityFilterChain in the Spring context and delegate to itFilterChainProxySecurityFilterChain objects, each with a RequestMatcher; the first chain that matches the request is the only one that runsFiltersVirtualFilterChain that calls them one at a time. A filter either calls chain.doFilter() (continue) or writes a response and returns (stop: 401, 403, redirect)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/catchDispatcherServlet find a controller. A @RestController never sees an unauthenticated request unless you explicitly permitted itTwo 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.
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.
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).
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.
| DSL call | Adds / configures | Notes |
|---|---|---|
securityMatcher(...) | Which requests this chain applies to | Not the same as requestMatchers inside authorizeHttpRequests, which is a per-rule matcher inside one chain |
authorizeHttpRequests | AuthorizationFilter | Replaces authorizeRequests (removed). First matching rule wins |
formLogin | UsernamePasswordAuthenticationFilter + generated login page | Handles POST /login; success handler redirects to the saved request |
httpBasic | BasicAuthenticationFilter | Reads Authorization: Basic ... on every request; pair with STATELESS |
csrf | CsrfFilter | On by default; see Chapter 7 |
cors | CorsFilter | Off until you call it; reads a CorsConfigurationSource bean |
sessionManagement | SessionManagementFilter + context repository choice | STATELESS also switches the SecurityContext repository |
exceptionHandling | ExceptionTranslationFilter collaborators | Entry point (401) and access-denied handler (403) |
oauth2ResourceServer | BearerTokenAuthenticationFilter | Next page: JWT & OAuth2 |
- Two catch-all chains. If the first chain has no
securityMatcherit matches everything and the second can never run. Startup fails withUnreachableFilterChainException: 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 asecurityMatcherand a lower@Ordernumber. - Chains without
@Orderare 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
requestMatchersafteranyRequest()throwsIllegalStateException: Can't configure requestMatchers after anyRequest.anyRequest()must be the last rule. - Still extending
WebSecurityConfigurerAdapteror callingantMatchers,mvcMatchers,authorizeRequests: all removed in Security 6 (compile errors after upgrading to Boot 3). Replace withSecurityFilterChain,requestMatchers,authorizeHttpRequests. - Static resources and the H2 console behind the chain: use
requestMatcherswith explicit paths rather than turning security off withWebSecurityCustomizer.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 notfromrequestMatchers(String). Either pass an explicit matcher for that rule, for exampleAntPathRequestMatcher.antMatcher("/h2-console/**"), or fix the servlet layout.
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.
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).
| Piece | Responsibility | You write it when... |
|---|---|---|
Authentication | Value object: principal, credentials, authorities, authenticated flag | You introduce a new credential type (API key, JWT) |
AuthenticationManager | Single entry point authenticate() | Almost never; inject Boot's via AuthenticationConfiguration |
ProviderManager | Delegates to the first provider that supports() the token | You need two login mechanisms side by side |
AuthenticationProvider | Verifies one kind of token | Custom credentials (OTP, API key, LDAP variant) |
UserDetailsService | loadUserByUsername returns a UserDetails (hash + authorities + flags) | Always, to read users from your database |
PasswordEncoder | encode, matches, upgradeEncoding | Declare one bean (Chapter 10) |
UserDetailsService backed by JPA
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
| Exception | Thrown when | Client sees |
|---|---|---|
BadCredentialsException ("Bad credentials") | Wrong password or unknown user (see below) | 401 (Basic) or redirect to /login?error (form) |
UsernameNotFoundException | Thrown by your service; DaoAuthenticationProvider converts it to BadCredentialsException by default (hideUserNotFoundExceptions = true) so attackers cannot enumerate accounts | Same as above |
DisabledException / LockedException / AccountExpiredException / CredentialsExpiredException | The matching flag on UserDetails is set, checked before the password for disabled/locked/expired accounts | 401; message is specific, so decide whether you want to leak it |
ProviderNotFoundException | No provider supports() the token class | 500 or 401 depending on filter; a wiring bug, not a user error |
InsufficientAuthenticationException | Anonymous user reached a rule that needs full authentication | Entry point: 401 or redirect |
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.
- Plain-text passwords in the database. Logging in fails with
IllegalArgumentException: There is no PasswordEncoder mapped for the id "null": theDelegatingPasswordEncoderexpects a{bcrypt}-style prefix. Re-hash existing rows (or store{bcrypt}in front of legacy bcrypt hashes); never "fix" it withNoOpPasswordEncoder. - Two
UserDetailsServicebeans (for example one for admins, one for customers): Boot backs off its auto-wiring and you must build theDaoAuthenticationProviders yourself and publish anAuthenticationManager/ProviderManager. - Lazy collections in
loadUserByUsername:LazyInitializationExceptionbecause 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:ProviderManagerclears the password from the returnedAuthenticationafter success. If you rely ongetCredentials()later you will seenull.
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.
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
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.
Threads, async and virtual threads
| Situation | Does the context follow? | What to do |
|---|---|---|
| Normal MVC request thread | Yes | Nothing |
@Async / your own ExecutorService | No: new thread, empty ThreadLocal | Wrap the executor in DelegatingSecurityContextExecutor / DelegatingSecurityContextAsyncTaskExecutor, or pass the user id explicitly |
Callable returned from a controller, DeferredResult | Yes for the Callable case (WebAsyncManagerIntegrationFilter) | Keep return types simple; verify with a test |
CompletableFuture.supplyAsync on the common pool | No | Capture the Authentication first, or use a delegating executor |
| Reactive (WebFlux) | Not a ThreadLocal at all | Separate 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.
- 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 useformLogin/httpBasicwhich 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
Authenticationis 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 throughSessionRegistry). - Calling
SecurityContextHolderin domain code makes services untestable and tied to the web layer; pass the user id or anAuditorAwareinstead.
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).
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.
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.
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.
| Layer | Where it runs | Good for | Weak at |
|---|---|---|---|
URL rules (authorizeHttpRequests) | Filter, before MVC | Whole sections, deny-by-default, health/static paths | Per-object rules ("only my orders"); path aliasing mistakes |
@PreAuthorize | AOP proxy, before the method | Role/scope checks, argument-based ownership checks | Self-invocation; rules scattered across services |
@PostAuthorize | AOP proxy, after the method | "Owner of the loaded entity" rules | Side effects already happened; wasted query for denied callers |
Query-level filtering (WHERE owner = :user) | Repository | Lists: never load rows the user may not see | Forgetting it in one query |
hasRole("ROLE_ADMIN")throwsIllegalArgumentException: role should not start with 'ROLE_' since it is automatically inserted. Got 'ROLE_ADMIN'. UsehasRole("ADMIN")orhasAuthority("ROLE_ADMIN").- Roles stored as
ADMINinGrantedAuthority(no prefix):hasRole("ADMIN")never matches and every admin gets 403. Check what yourUserDetailsServiceor 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: keepanyRequest().authenticated()ordenyAll()as the final rule so unknown paths fail closed. Security 6'sStrictHttpFirewallalready rejects;, encoded slashes and//withRequestRejectedException; do not loosen it to make a client work. - Method security annotation on a controller method called internally or on a
privatehelper: silently unprotected. Put@PreAuthorizeon 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
@EnableGlobalMethodSecurityis removed in Security 6; use@EnableMethodSecurity. Note it also defaultsprePostEnabledto true, whereas the old one required it.
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.
How CsrfFilter works (Security 6)
CsrfFilter asks the CsrfTokenRepository for the tokenHttpSessionCsrfTokenRepository (token kept in the session). In Security 6 loading is deferred: no session is created or read until something needs the tokenX-CSRF-TOKEN or the request parameter _csrf and compares it with the stored one; mismatch means InvalidCsrfTokenException or MissingCsrfTokenException handed to the AccessDeniedHandler: 403XorCsrfTokenRequestAttributeHandler<form:form> insert the hidden field automaticallyWhen to disable it, and when never to
| Application type | Credentials travel as | CSRF? |
|---|---|---|
| Server-rendered pages, form login | Session cookie | Keep enabled; forms include the token |
| SPA on the same site using session cookie | Session cookie | Keep enabled; use cookie repository so JS can read the token (below) |
Pure API, Authorization: Bearer header, STATELESS | Header set by your JS/client code | Safe to disable: browsers never attach that header automatically |
| API that also accepts a cookie (JWT in cookie) | Cookie | Keep enabled (or rely on SameSite plus custom header checks consciously) |
| Machine-to-machine webhooks with HMAC | Signature header | Disable for that path only via csrf.ignoringRequestMatchers(...) |
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).
- "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
HttpSessionCsrfTokenRepositoryand 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 andReferer. Use headers or hidden form fields.
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.
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 | CSRF | |
|---|---|---|
| Protects against | Other sites reading your API responses from a browser | Other sites causing state changes using the victim's cookies |
| Enforced by | The browser (server only sends headers) | The server (CsrfFilter) |
| A permissive setting means | Foreign pages can read authenticated data | Foreign 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 | |
allowedOrigins("*")withallowCredentials(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
@CrossOriginorWebMvcConfigurer.addCorsMappings, but the preflight is rejected by security (401/403 onOPTIONS): enablehttp.cors(...)soCorsFilterruns 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, orallowedOriginPatterns("*")with credentials): any website can read your users' data. Allow-list exact origins. - Forgetting
Vary: Originwhen writing your own filter: a CDN caches the response for one origin and serves it to others. Spring'sCorsFilteradds it for you.
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).
SessionCreationPolicy | Creates a session? | Uses an existing one? | Typical use |
|---|---|---|---|
IF_REQUIRED (default) | When something needs it (form login, CSRF token) | Yes | Server-rendered apps |
ALWAYS | On every request | Yes | Rare; wasteful |
NEVER | No (Spring Security will not) | Yes, if the container made one | Mixed with legacy code that creates sessions |
STATELESS | No | No (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.
- Declaring STATELESS but the API still sets
JSESSIONID: something else created a session (a CSRF repository, a controller callingrequest.getSession(), an@SessionScopebean). 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
Authenticationsits in the session. Expire it throughSessionRegistry.getAllSessions(principal, false)andexpireNow(), 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.
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.
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).
| Algorithm | Hard against | Notes |
|---|---|---|
bcrypt (BCryptPasswordEncoder) | CPU, some GPU | Default. 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) | Memory | Good alternative; also needs Bouncy Castle |
PBKDF2 (Pbkdf2PasswordEncoder) | CPU only | FIPS-friendly; OWASP suggests 600,000 iterations with HMAC-SHA256. Pure JDK |
Plain, MD5, SHA-*, NoOpPasswordEncoder | Nothing | Never in production; {noop} exists only for demos and tests |
- 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.equalsin your own code: useMessageDigest.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.
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).
Compile and run it with javac MiniSecurity.java && java MiniSecurity. This is real output from that run (only the timing numbers vary by machine):
AccessDeniedException-style denial, different statusExceptionTranslationFilter logicBasicAuthenticationchain.nextCsrfCsrfFilter sits near the top of the real chain- Swap the order of
exceptionFilterandauthorizationFilterin the list. Denials now escape as raw exceptions: filter order is behaviour, not decoration. - Replace
MessageDigest.isEqualwithString.equalsinmatchesand 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 inauthorizationFilter(theanyRequest()rule) and watch every unlisted path become reachable by anonymous callers: that is what fail-open looks like.
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.
| Symptom | Likely cause | Fix / how to confirm |
|---|---|---|
| Every endpoint returns a login page or 401 after adding the starter | Default chain is active | Declare your own SecurityFilterChain; read Using generated security password in the log |
| 403 on every POST/PUT/DELETE from a browser or curl, GETs fine | CSRF enabled for a client that cannot supply the token | Send the token; or disable CSRF only for stateless bearer APIs |
Browser "CORS error", server log shows 401 on OPTIONS | cors() not in the security chain | http.cors(...) plus a CorsConfigurationSource bean |
| Admin gets 403 although they have the role | Authority 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 protected | Rule order, a permitAll pattern too wide (/**), a second mapping, or the path ignored by WebSecurityCustomizer | Write negative tests; end with anyRequest().authenticated() or denyAll() |
| H2 console shows blank frame | X-Frame-Options: DENY default | Dev profile only: headers(h -> h.frameOptions(f -> f.sameOrigin())) and a matcher for the console path |
| Actuator endpoints open to the internet | Exposed in config and covered by permitAll | Expose a minimum list, secure /actuator/** with a role or a separate management port |
@PreAuthorize has no effect | Missing @EnableMethodSecurity, self-invocation, or private method | Call through another bean; verify with a test as the wrong user |
RequestRejectedException: The request was rejected because the URL contained a potentially malicious String | StrictHttpFirewall blocking ; or // or encoded slash | Fix the client; only as a last resort customise the firewall bean |
Every user suddenly gets Bad credentials after a library upgrade | Encoder id/prefix mismatch, or default strength changed | Check stored prefix ({bcrypt}) and DelegatingPasswordEncoder registrations |
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.
For every protected route write three tests: anonymous (expect 401), wrong role (403), right role (200). See the Testing page for @WithMockUser and SecurityMockMvcRequestPostProcessors.
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.
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.
Ten Questions You Will Be Asked
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).
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.
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).
hasRole vs hasAuthority?Same mechanism; hasRole("X") checks the authority ROLE_X. Trap: hasRole("ROLE_X") throws.
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.
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.
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.
WebSecurityConfigurerAdapter, antMatchers, authorizeRequests, @EnableGlobalMethodSecurity removed; authorizeHttpRequests/requestMatchers/@EnableMethodSecurity and lambda DSL; SecurityContextHolderFilter no longer saves the context automatically; deferred CSRF token with XOR masking.
SecurityContextHolder return null in my @Async method?ThreadLocal strategy; the pool thread has its own empty context. Use DelegatingSecurityContextAsyncTaskExecutor or pass the identity explicitly.
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.
Cheat Sheet and Where to Go Next
| Question | Component | Configure with |
|---|---|---|
| Which chain handles this request? | FilterChainProxy | securityMatcher + @Order |
| Who is calling? | Authentication in SecurityContext | formLogin, httpBasic, oauth2ResourceServer |
| Are the credentials right? | AuthenticationManager → provider | UserDetailsService + PasswordEncoder beans |
| May they do this URL? | AuthorizationFilter | authorizeHttpRequests (first match wins) |
| May they call this method / see this object? | Method interceptors | @EnableMethodSecurity, @PreAuthorize |
| Is this POST really from my page? | CsrfFilter | csrf(...), token repository |
| May another origin read my API? | CorsFilter | cors(...) + CorsConfigurationSource |
| Remembered between requests? | SecurityContextRepository | sessionManagement, STATELESS |
| Passwords at rest? | DelegatingPasswordEncoder | bcrypt / Argon2id + upgrade path |
- It is a chain of servlet filters that runs before MVC; the first matching
SecurityFilterChainowns the request and filter order is fixed by the framework. - Authentication = a token in, an
Authenticationout viaAuthenticationManager; you supplyUserDetailsServiceandPasswordEncoder, Boot wires the rest. - The
SecurityContextis 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
DelegatingPasswordEncoderand plan the upgrade path from day one.
- Next: JWT, OAuth2 & OIDC — replace the session with bearer tokens and a resource server.
- AOP & Proxies — why
@PreAuthorizehas the self-invocation trap. - REST Controllers & the MVC Request Path — where the filter chain hands over to
DispatcherServlet. - Validation & Error Handling — consistent error bodies for 401/403.
- Testing Spring Applications —
@WithMockUser,jwt()post-processors and negative tests. - Actuator & Observability — securing management endpoints.