Spring Security is a chain of servlet filters in front of your application. Every question below is ultimately about that chain: what runs in it, in what order, and what it puts where.
Authentication and authorisation
What are they, and which comes first?
- Authentication — establishing who the caller is. Are these credentials valid?
- Authorisation — deciding whether that caller may do this.
Authentication first, necessarily: you cannot decide what someone is allowed to do until you know who they are. In HTTP terms, failing the first is 401 and failing the second is 403.
Is security a cross-cutting concern? How is it implemented?
Yes, and Spring implements it two ways at once. At the web layer it is a
filter chain in front of the DispatcherServlet. At the method layer it is
AOP — @PreAuthorize is a proxy, with all the proxy rules that implies.
The filter chain
What is the DelegatingFilterProxy?
A servlet filter registered with the container that does nothing but delegate to a Spring bean. It exists because filters are created by the servlet container, which knows nothing about the Spring context — so the container gets a thin proxy and the real filter gets to be a fully injected bean.
What is the security filter chain?
The ordered list of filters each request passes through. Each has one job, and the order is the design. Abbreviated, in order:
SecurityContextHolderFilter— loads any existing context.CsrfFilter.LogoutFilter.- Authentication filters —
UsernamePasswordAuthenticationFilter, and custom ones like a JWT filter. ExceptionTranslationFilter— turns security exceptions into 401 or 403.AuthorizationFilter— applies the rules; last, because by now the caller is known.
Inspect the real chain at startup with
logging.level.org.springframework.security=DEBUG. It prints every filter in order,
which beats guessing.
What is the SecurityContext?
The holder of the current Authentication — the principal, its credentials and its
granted authorities. It lives in SecurityContextHolder, which by default is backed by a
ThreadLocal.
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String email = auth.getName();Two consequences of that ThreadLocal worth being able to state:
- The context is cleared at the end of the request, because the thread goes back to a pool and the next request must not inherit it.
- It does not propagate to a new thread. An
@Asyncmethod sees an empty context unless you setMODE_INHERITABLETHREADLOCALor useDelegatingSecurityContextExecutor.
Configuration
How do you configure it today?
By publishing a SecurityFilterChain bean and configuring it with the lambda DSL.
WebSecurityConfigurerAdapter was deprecated in Spring Security 5.7 and removed in 6, and
Spring Security 7 removed the older chained-setter style as well — so examples written against
Spring Security 5 do not compile.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.cors(Customizer.withDefaults())
// Safe to disable ONLY because this API is stateless and token-based:
// there is no session cookie for a cross-site request to ride on.
// Never do this for a cookie-authenticated app.
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/register", "/api/auth/login").permitAll()
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
// SPECIFIC BEFORE GENERAL. Order history needs an account...
.requestMatchers(HttpMethod.GET, "/api/orders/mine").authenticated()
// ...but placing an order must not - this is guest checkout.
.requestMatchers(HttpMethod.POST, "/api/orders").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// Default deny: anything not listed needs authentication, so a
// new endpoint is closed until someone deliberately opens it.
.anyRequest().authenticated())
.addFilterBefore(jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}Why do you need URL rules at all, and in what order must they be written?
First match wins, so specific patterns must come before general ones. Put
/api/orders/** above /api/orders/mine and the specific rule is unreachable
— and nothing warns you. This is the single most common Spring Security misconfiguration.
Always finish with anyRequest().authenticated(). Default-deny means a newly added
controller is closed until someone opens it deliberately; without it, a forgotten rule is a public
endpoint.
What does ** match? Any number of path segments, including
none. A single * matches within one segment only. So /api/orders/* matches
/api/orders/42 but not /api/orders/42/items, while
/api/orders/** matches both.
Why is an MVC matcher safer than an Ant matcher? Because it matches the way
Spring MVC actually routes, so the two cannot disagree. An Ant matcher works on the raw path, and the
classic exploit is a request that MVC maps to a protected handler but whose literal path does not
match the pattern — /admin/users.json, a trailing slash, an encoded character. Under
Spring Security 6+, requestMatchers(...) chooses an MVC matcher automatically when Spring
MVC is present, which is why the modern answer is "use requestMatchers and do not
hand-pick".
Passwords
Does Spring Security support hashing? What is salting?
Yes. Register a PasswordEncoder bean — BCryptPasswordEncoder is the
usual choice.
A salt is random data mixed into the password before hashing, so that two users
with the same password get different hashes and a precomputed rainbow table is useless. BCrypt
generates the salt itself and stores it inside the hash string, which is why
matches() needs nothing but the raw password and the stored hash.
/**
* BCrypt is deliberately slow, and that is the point: it makes brute-forcing a stolen
* password table expensive. Never store a password with a fast hash such as MD5 or SHA-256.
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}The interface is encode() and matches() — note there is no
decode(), because a hash is one-way. If a system can email you your existing password,
it is not hashing them.
Method security
Why do you need it? What is typically secured that way?
Because URL rules protect one entry point. A service is a bean that any future controller, scheduled job or message listener can inject — and the annotation travels with the method, so the guard cannot be left behind when the caller changes. It is the service layer that gets secured this way: the layer where a business operation lives, not the layer that happens to be reachable over HTTP.
@Service
public class AdminUserServiceImpl implements AdminUserService {
@Override
@PreAuthorize("hasRole('ADMIN')")
@Transactional
public void deleteUser(String actingAdminEmail, UUID userId) { ... }
}Switch it on with @EnableMethodSecurity.
@Secured versus @RolesAllowed versus
@PreAuthorize?
| Annotation | From | Takes |
|---|---|---|
@Secured | Spring Security | a list of role strings, OR-ed |
@RolesAllowed | Jakarta (JSR-250) | the same, but portable |
@PreAuthorize | Spring Security | a SpEL expression |
Which one takes SpEL? @PreAuthorize and
@PostAuthorize — and that is why they are the ones to use. Only they can express a rule
about the arguments or the result:
@PreAuthorize("hasRole('ADMIN')")
@PreAuthorize("hasAnyRole('ADMIN','STAFF')")
@PreAuthorize("#email == authentication.name") // only your own record
@PostAuthorize("returnObject.ownerEmail == authentication.name") // checked AFTER the call@PostAuthorize runs after the method and can inspect the return value — useful, but
note the method has already executed, so it is no defence against a method with side effects.
⚠️ hasRole("ADMIN") checks for the authority
ROLE_ADMIN. The prefix is added by the framework. Storing "ROLE_ADMIN" in the
database and then calling hasRole("ROLE_ADMIN") looks for
ROLE_ROLE_ADMIN and silently denies everything. Use
hasAuthority() if you want no prefix at all.
// Where the prefix is added in the pizza API - once, in the filter, not in the database.
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role));⚠️ And it is proxy-based, so it has the same blind spot as
@Transactional: a call from another method inside the same class skips the check
entirely.
Stateless authentication with JWT
A JWT is signed, not encrypted — anyone holding one can read its payload, so never put anything secret in the claims. The signature only guarantees that you issued it and nobody edited it.
public String generateToken(User user) {
Instant now = Instant.now();
return Jwts.builder()
.subject(user.getEmail())
.claim("role", user.getRole().name())
.issuedAt(Date.from(now))
.expiration(Date.from(now.plusSeconds(expirationMinutes * 60)))
.signWith(key)
.compact();
}
/** Returns the claims if valid, or null if malformed, forged or expired. */
public Claims parse(String token) {
try {
return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload();
} catch (JwtException | IllegalArgumentException ex) {
// Any failure means "not authenticated". Distinguishing them would only help an
// attacker probe the implementation.
return null;
}
}The filter that consumes it:
/**
* Extends OncePerRequestFilter because a plain Filter can run more than once per request
* (forwards, error dispatches), and re-authenticating each time is wasted work.
*
* Note what it does NOT do: it never rejects a request. A missing or bad token simply
* leaves the context anonymous, and the authorization rules then decide whether that is
* acceptable. Keeping authentication and authorization separate is what lets guest
* checkout share an endpoint with authenticated ordering.
*/
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = extractToken(request);
if (token != null && SecurityContextHolder.getContext().getAuthentication() == null) {
Claims claims = jwtService.parse(token);
if (claims != null) {
var authorities =
List.of(new SimpleGrantedAuthority("ROLE_" + claims.get("role", String.class)));
var authentication = new UsernamePasswordAuthenticationToken(
claims.getSubject(), null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(request, response); // ALWAYS continue the chain
}
}The trade-off to be able to state: a stateless token cannot be revoked before it expires. Real systems pair a short-lived access token with a refresh token, or keep a deny-list — which reintroduces exactly the state the design removed.
CSRF and CORS — different problems
- CSRF exploits credentials the browser sends automatically — cookies.
If your API authenticates with a
Bearerheader, there is nothing to ride on, and disabling CSRF is correct. If it authenticates with a session cookie, disabling CSRF is a vulnerability. - CORS is the browser asking whether another origin may read your response. It is a browser policy, not a security control — it does not stop a request being made, only a script from reading the reply.
Is hiding UI enough?
Is it enough to hide sections of a page from users who may not use them?
No, and this is the question to answer emphatically. Hiding a button is a usability improvement,
not a security control — the endpoint is still there and anyone can call it with
curl. Every rule must be enforced on the server. The UI may hide, the
server must refuse.
Enforcing it twice is fine and is what the pizza API does: the URL rules guard the HTTP entry
points and @PreAuthorize guards the service methods, so a new controller that forgets its
URL rule is still refused.
What to remember
- Authentication is who; authorisation is what. 401 versus 403.
- It is a filter chain;
AuthorizationFilterruns last because by then the caller is known. - The
SecurityContextis a ThreadLocal — cleared per request, not inherited by new threads. SecurityFilterChainbean plus the lambda DSL. NoWebSecurityConfigurerAdapter.- Matchers are first-match-wins: specific before general, and always end with
anyRequest().authenticated(). hasRole("ADMIN")means the authorityROLE_ADMIN.- Hiding UI is not security.