What is Spring Security, Architecturally?
Spring Security is a chain of Filters โ plain Servlet filters,
the same mechanism covered in
HTTP Protocol & Methods and
contrasted with Spring's own interceptors in
Spring MVC โ inserted in front of your application via
a single DelegatingFilterProxy. That proxy delegates to a
FilterChainProxy, which holds an ordered list of
SecurityFilterChain beans, each guarding a set of URL patterns.
The first chain whose pattern matches the request wins โ everything inside it
(authentication, CSRF, authorization decisions) runs before the request ever
reaches DispatcherServlet.
The conceptual distinction between authentication and authorization is
covered on its own dedicated page:
Authentication vs
Authorization. This page assumes that distinction and focuses on how
Spring Security specifically implements it โ the filter chain, the
AuthenticationManager, and the two fundamentally different
configuration shapes (session-based vs stateless) that follow from it.
WebSecurityConfigurerAdapter โ extending a base class and
overriding configure(HttpSecurity) โ was deprecated in
Spring Security 5.7 and removed entirely in Spring Security 6
(Spring Boot 3+). The current, and only, approach is exposing a
SecurityFilterChain as a @Bean, using the lambda
DSL shown throughout this page. A huge amount of security tutorials online
still teach the removed API โ treat that as a strong signal the content
predates Boot 3.
Setup and the Default Behaviour
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Auto-configuration registers a default SecurityFilterChain
that requires authentication for every endpoint, serves a
default login form at /login, and generates a random
password for an in-memory user named user, printed once to
the console at startup. This is a deliberately restrictive default โ the
framework assumes an unconfigured app should fail closed, not open.
The Authentication Flow โ What Actually Happens
/*
* Login request โโโถ UsernamePasswordAuthenticationFilter
* โ builds an unauthenticated Authentication (username + password)
* โผ
* AuthenticationManager
* โ delegates to the first AuthenticationProvider that supports it
* โผ
* DaoAuthenticationProvider
* โ 1. UserDetailsService.loadUserByUsername(username)
* โ 2. PasswordEncoder.matches(rawPassword, storedHash)
* โผ
* Authenticated Authentication object
* โ (principal + authorities, password discarded)
* โผ
* SecurityContextHolder.getContext().setAuthentication(...)
* โ stored for the rest of THIS request (and the session, if any)
*/
Every piece here is replaceable independently. Provide your own
UserDetailsService to load users from a database instead of
memory; the AuthenticationManager/DaoAuthenticationProvider
machinery around it stays exactly the same. This is why a
custom database-backed UserDetailsService,
further down, plugs in without touching anything else in the configuration.
Session-Based Configuration (Form Login)
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
// Order matters: rules are evaluated top to bottom, first match wins.
// A specific /admin/** rule AFTER a catch-all anyRequest() would never run.
.requestMatchers("/public/**", "/login").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/user/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/dashboard")
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/login?logout")
.permitAll()
);
// CSRF stays ENABLED here โ the default, and correct, for a session-based app
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
hasRole("ADMIN") automatically prefixes ROLE_
under the hood โ it actually checks for the authority
"ROLE_ADMIN". If your UserDetailsService already
stores authorities with the prefix baked in and a rule elsewhere
uses hasAuthority("ROLE_ADMIN") (no auto-prefixing), both
happen to work โ but mixing conventions across a codebase, or double-
prefixing to "ROLE_ROLE_ADMIN", produces an authorization
check that silently never matches. No exception, no log โ the user is
just always denied. Pick one convention (hasRole +
unprefixed stored authorities is the idiomatic default) and stay
consistent.
UserDetailsService โ Where Users Actually Come From
In-memory (testing only)
@Bean
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
UserDetails user = User.builder()
.username("user")
.password(passwordEncoder.encode("password"))
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
Calling passwordEncoder() directly from inside another
@Bean method in the same
@Configuration class does still return the singleton โ Spring
CGLIB-proxies full @Configuration classes precisely so
internal @Bean-to-@Bean calls resolve correctly.
Declaring it as a method parameter instead, as above, is
preferred style regardless โ it works identically if the encoder bean ever
moves to a different configuration class, and it doesn't rely on the
reader knowing about CGLIB proxying to trust it's correct.
Database-backed (real applications)
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) {
AppUser appUser = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
// stored password is ALREADY a BCrypt hash โ never re-encode it here
return org.springframework.security.core.userdetails.User.builder()
.username(appUser.getUsername())
.password(appUser.getPassword())
.authorities(appUser.getRoles().stream()
.map(r -> new SimpleGrantedAuthority("ROLE_" + r.getName()))
.toList())
.disabled(!appUser.isEnabled())
.build();
}
}
Spring Security's default, BCryptPasswordEncoder, remains a
solid choice and needs no configuration to use correctly. For the
reasoning behind salted hashing, work-factor tuning, and where Argon2 fits
in, see Password Hashing โ
this page only covers how the encoder plugs into Spring Security's
authentication flow.
Stateless Configuration (JWT) โ a Genuinely Different Shape, Not a Variant
A REST API with no server-side session needs a different
SecurityFilterChain altogether โ not the form-login one from
Section 3 with a filter bolted on. Pick one shape per
application, not both.
@Configuration
@EnableWebSecurity
public class JwtSecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
public JwtSecurityConfig(JwtAuthenticationFilter jwtAuthFilter) {
this.jwtAuthFilter = jwtAuthFilter;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable) // see the warning below BEFORE copying this line
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // no HttpSession is ever created
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
CSRF exploits the browser's behaviour of automatically
attaching cookies to any request to the site that set them โ a malicious
page can trigger a request your browser silently authenticates via the
session cookie. A bearer token sent in an Authorization
header must be attached explicitly by your own
JavaScript; a forged cross-site request has no way to add that header, so
the attack vector genuinely doesn't apply โ disabling CSRF here is
correct, not a shortcut.
This reasoning breaks entirely if the JWT is instead stored in a
cookie (common for XSS-mitigation reasons, using
HttpOnly). A cookie-stored token is attached
automatically by the browser, exactly like a session cookie โ CSRF
protection is still required in that case, disabling it re-opens the exact
vulnerability this section just explained why it was safe to skip.
// The filter's only job: read the header, validate the token, populate SecurityContext.
// Token issuing/validation logic (JwtService) is covered on the dedicated JWT page.
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
public JwtAuthenticationFilter(JwtService jwtService, UserDetailsService userDetailsService) {
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String jwt = authHeader.substring(7);
String username = jwtService.extractUsername(jwt);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(jwt, userDetails)) {
var authToken = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}
The overridden method is declared by OncePerRequestFilter as
throws ServletException, IOException. Java does not permit an
overriding method to declare a broader checked exception
than the method it overrides โ declaring throws Exception
here, as some tutorials do, is a genuine compile error, not a style
preference.
JwtService's internals โ signing algorithm, expiry handling,
refresh tokens, secret/key management โ are covered in depth on
JWT (JSON Web Tokens). This section
only covers how a token, once validated, gets plugged into Spring
Security's SecurityContext via a custom filter.
Method-Level Security
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {}
@Service
public class UserService {
@PreAuthorize("hasRole('ADMIN')") // checked BEFORE the method body runs
public void deleteUser(Long id) { }
@PreAuthorize("#userId == authentication.principal.id") // can reference method parameters directly
public User getProfile(Long userId) { ... }
@PostAuthorize("returnObject.owner == authentication.name") // checked AFTER โ needs the return value to decide
public Document getDocument(Long id) { ... }
@PostFilter("filterObject.owner == authentication.name") // filters the returned collection element-by-element
public List<Document> getAllDocuments() {
return documentRepository.findAll();
}
}
getAllDocuments() above calls
documentRepository.findAll() โ every document
row is fetched from the database first. @PostFilter then
discards the ones that don't match the SpEL expression, entirely in Java,
after the fact. On a table with a million documents belonging to
thousands of users, this fetches all of them to return maybe a dozen. As
with the N+1 problem covered in
Spring Data JPA, the fix is pushing the
condition down into the query itself โ a
findByOwner(owner) derived method or a
Specification โ and reserving @PostFilter for
collections that are already small by the time they reach this layer.
Accessing the Current User
@RestController
public class ProfileController {
// Preferred โ resolved directly from SecurityContext by an argument resolver,
// no manual lookup, works with your own UserDetails implementation too
@GetMapping("/profile")
public String getProfile(@AuthenticationPrincipal UserDetails user) {
return "Hello, " + user.getUsername();
}
// Equivalent, framework-agnostic โ Authentication implements java.security.Principal
@GetMapping("/profile-principal")
public String getProfileViaPrincipal(Principal principal) {
return "Hello, " + principal.getName();
}
}
@AuthenticationPrincipal is resolved through the exact same
SecurityContextHolder both alternatives ultimately read from,
so inside a controller there's no functional difference โ just less
boilerplate. Calling
SecurityContextHolder.getContext().getAuthentication()
directly remains necessary in code that has no access to the request at
all, such as a scheduled batch job or a domain service several layers
away from any controller parameter.
CORS vs CSRF โ Not the Same Problem
These get confused because both are three-letter acronyms about cross-origin requests, but they solve opposite problems. CORS is the browser relaxing its same-origin policy so your own frontend, on a different origin, is allowed to call your API. CSRF is a defence against a request the browser sends without the user's intent, triggered by a malicious third-party page. Configuring one has no effect on the other.
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://app.example.com")); // never "*" if allowCredentials is true
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
// Wired in: http.cors(cors -> cors.configurationSource(corsConfigurationSource()))
Testing Secured Endpoints
@WebMvcTest(UserController.class)
class UserControllerSecurityTest {
@Autowired private MockMvc mockMvc;
@Test
void deleteUser_asAdmin_succeeds() throws Exception {
mockMvc.perform(delete("/admin/users/1").with(csrf()))
.andExpect(status().isNoContent());
}
@Test
@WithMockUser(roles = "USER") // simulates an authenticated USER โ no real login flow needed
void deleteUser_asRegularUser_isForbidden() throws Exception {
mockMvc.perform(delete("/admin/users/1").with(csrf()))
.andExpect(status().isForbidden());
}
}
It injects a pre-built Authentication straight into the test's
SecurityContext, skipping UserDetailsService and
PasswordEncoder entirely. That's correct for testing
authorization rules โ which roles can reach which endpoint. It
deliberately does not exercise the login flow itself; a real
@SpringBootTest hitting /login is what verifies
that part.
Interview Questions
Q: What does adding spring-boot-starter-security alone do to an app?
It secures every endpoint by default, requiring authentication, and serves a
default login form with a single generated in-memory user โ a deliberately
restrictive default rather than an opt-in one.
Q: What is the role of PasswordEncoder, and why can't you compare passwords with .equals()?
Passwords are stored as a one-way hash, never in plain text.
PasswordEncoder.matches(raw, hash) re-hashes the raw input with
the same algorithm and salt and compares the result โ you cannot reverse a
hash back to plain text to compare it directly.
Q: What's the difference between @AuthenticationPrincipal and Principal in a controller?
Both ultimately read from the same SecurityContext.
Principal is the generic java.security interface;
@AuthenticationPrincipal can be typed as your actual
UserDetails implementation, giving direct access to custom
fields without an extra cast.
Q: Walk through what happens, component by component, from a login POST to an authenticated SecurityContext.
UsernamePasswordAuthenticationFilter builds an unauthenticated
Authentication from the submitted credentials and hands it to the
AuthenticationManager, which delegates to the first
AuthenticationProvider that supports it โ typically
DaoAuthenticationProvider, which calls
UserDetailsService.loadUserByUsername and then
PasswordEncoder.matches. On success, a fully authenticated
Authentication (principal + authorities, password discarded) is
stored in SecurityContextHolder for the remainder of the
request โ and the session, if one exists.
Q: Why is CSRF disabled for a JWT-based API, and under what condition does that reasoning stop applying?
CSRF exploits the browser automatically attaching cookies to cross-site
requests; a bearer token in an Authorization header must be
added explicitly by application JavaScript, so a forged cross-site request
has no way to include it โ the attack vector doesn't apply. This reasoning
fails the moment the token is instead stored in a cookie (even
HttpOnly): a cookie is attached automatically regardless of
where the token lives, restoring exactly the vulnerability CSRF protection
exists to prevent.
Q: Why can hasRole("ADMIN") and hasAuthority("ROLE_ADMIN") produce inconsistent results across a codebase?
hasRole silently prepends ROLE_ before checking;
hasAuthority checks the literal string given. If authorities are
stored with the prefix already included, mixing the two conventions โ or
double-prefixing โ produces an authorization check that never matches for
that user, with no exception and no log line, since "not authorized" and
"misconfigured comparison" look identical from the outside.
Q: Why is @PostFilter potentially dangerous on a large dataset, and what's the correct fix?
@PostFilter evaluates its SpEL expression against a collection
that has already been fully loaded โ typically by a
repository's findAll() โ discarding non-matching elements in
Java afterward. It never becomes a WHERE clause. On any
non-trivial dataset, the fix is pushing the ownership condition into the query
itself (a derived method or Specification), the same principle
as fixing an N+1 query: filter where the database can use an index, not after
every row has already been fetched.