Spring Security

Security is not a single annotation—it is a servlet filter chain that runs before DispatcherServlet, establishes who the caller is, and decides what they may do. This chapter covers filter architecture, authentication providers, JWT and OAuth2, method security, and Boot 3's lambda DSL.

mid senior Spring Security 6

Security filter chain architecture

Spring Security integrates as a Servlet Filter—not as MVC interceptor. Every HTTP request passes through a chain of security filters before reaching DispatcherServlet.

Delegation stack

  1. DelegatingFilterProxy — registered in Servlet container (Boot auto-registers as springSecurityFilterChain); delegates to Spring bean
  2. FilterChainProxy — selects which SecurityFilterChain matches the request (multiple chains supported)
  3. SecurityFilterChain — ordered list of Spring Security filters for matched requests
sequenceDiagram
  participant C as Client
  participant DFP as DelegatingFilterProxy
  participant FCP as FilterChainProxy
  participant SCF as Security filters
  participant DS as DispatcherServlet
  C->>DFP: HTTP request
  DFP->>FCP: delegate
  FCP->>SCF: run filter chain
  SCF->>SCF: authenticate and authorize
  SCF->>DS: chain.doFilter
  DS-->>C: response

Key filters (conceptual order)

Exact order varies by configuration; these are the filters you debug most often:

FilterRole
SecurityContextHolderFilter Loads/clears SecurityContext per request (replaces legacy PersistenceFilter in Security 6)
LogoutFilter Handles /logout
UsernamePasswordAuthenticationFilter Form login POST — extracts username/password, calls AuthenticationManager
BasicAuthenticationFilter HTTP Basic Authorization: Basic …
BearerTokenAuthenticationFilter OAuth2 Resource Server JWT bearer tokens (Boot 3)
ExceptionTranslationFilter Maps AuthenticationException → login redirect / 401; AccessDeniedException → 403
AuthorizationFilter Replaces FilterSecurityInterceptor in Security 6 — enforces authorizeHttpRequests rules
🔬 Under the Hood

Boot 3 registers a default SecurityFilterChain bean when spring-boot-starter-security is on classpath. Your @Bean SecurityFilterChain replaces it. Multiple chains use @Order — lower value = higher priority matching.

🎯 Interview Tip

Authentication = who are you. Authorization = what may you do. Filters handle authentication; AuthorizationFilter checks access rules before the controller runs.

SecurityContext & SecurityContextHolder

The authenticated user's identity and authorities live in SecurityContext, accessed via SecurityContextHolder—typically ThreadLocal-bound for servlet requests.

Reading current user
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
Object principal = auth.getPrincipal();  // UserDetails or JWT claims object
StrategyStorageUse case
MODE_THREADLOCAL (default) ThreadLocal Servlet request per thread — standard MVC
MODE_INHERITABLETHREADLOCAL Inheritable ThreadLocal Child threads inherit context (use carefully)
MODE_GLOBAL Static singleton Testing only — never in production
⚠️ Pitfall

@Async methods run on different threads—SecurityContextHolder context is lost unless you use DelegatingSecurityContextAsyncTaskExecutor or pass principal explicitly. Same issue with reactive WebFlux—use ReactiveSecurityContextHolder.

Authentication

Authentication proves identity. Spring Security delegates to pluggable providers through a single facade.

sequenceDiagram
  participant F as AuthFilter
  participant AM as AuthenticationManager
  participant AP as AuthenticationProvider
  participant UDS as UserDetailsService
  participant PE as PasswordEncoder
  F->>AM: authenticate token
  AM->>AP: supports and authenticate
  AP->>UDS: loadUserByUsername
  UDS-->>AP: UserDetails
  AP->>PE: matches raw password hash
  AP-->>AM: authenticated Authentication
  AM-->>F: success set SecurityContext
ComponentRole
AuthenticationManager Facade — delegates to one or more AuthenticationProviders
AuthenticationProvider Implements specific auth type (DAO, LDAP, JWT, etc.)
UserDetailsService Loads user by username — you implement against DB/LDAP
UserDetails Username, password hash, authorities, account flags
PasswordEncoder One-way hash verification — never store plaintext passwords
UserDetailsService implementation
@Service
class DatabaseUserDetailsService implements UserDetailsService {
  private final UserRepository userRepository;

  DatabaseUserDetailsService(UserRepository userRepository) {
    this.userRepository = userRepository;
  }

  @Override
  public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    return userRepository.findByEmail(username)
        .map(u -> User.builder()
            .username(u.getEmail())
            .password(u.getPasswordHash())
            .roles(u.getRoles().toArray(new String[0]))
            .disabled(!u.isActive())
            .build())
        .orElseThrow(() -> new UsernameNotFoundException(username));
  }
}

PasswordEncoder

Store only password hashes. Spring Security 6 defaults to BCrypt; Argon2 is preferred for greenfield high-security apps.

AlgorithmClassRecommendation
BCrypt BCryptPasswordEncoder Default, battle-tested — strength 10–12 (2^10–2^12 rounds)
Argon2 Argon2PasswordEncoder PHC winner — memory-hard; OWASP recommended for new systems
PBKDF2 Pbkdf2PasswordEncoder FIPS environments; slower than Argon2 on GPUs
PasswordEncoder bean
@Bean
PasswordEncoder passwordEncoder() {
  return new BCryptPasswordEncoder(12);
}

// Registration — hash before save
String hash = passwordEncoder.encode(rawPassword);
user.setPasswordHash(hash);

// Login — AuthenticationProvider calls passwordEncoder.matches(raw, hash)
⚠️ Pitfall

Never use NoOpPasswordEncoder or MD5/SHA1 for passwords. Don't implement custom crypto—use the framework encoders with appropriate strength parameters.

Form login, HTTP Basic & Remember-Me

Built-in authentication mechanisms for browser apps and simple API clients.

Form login + custom handlers
@Bean
SecurityFilterChain formLoginChain(HttpSecurity http) throws Exception {
  return http
      .authorizeHttpRequests(auth -> auth
          .requestMatchers("/login", "/css/**").permitAll()
          .anyRequest().authenticated()
      )
      .formLogin(form -> form
          .loginPage("/login")
          .successHandler((req, res, authentication) -> {
            res.sendRedirect("/dashboard");
          })
          .failureHandler((req, res, ex) -> {
            res.sendRedirect("/login?error");
          })
      )
      .build();
}

HTTP Basic

Sends credentials on every request via Authorization: Basic base64(user:pass). Simple for tools/scripts; prefer tokens for production APIs.

HTTP Basic
http.httpBasic(Customizer.withDefaults());

Remember-Me

Persistent cookie after "remember me" checkbox — uses signed token or persistent token service. Less common in modern token-based SPAs; understand for legacy apps.

Remember-Me configuration
http.rememberMe(remember -> remember
    .key("unique-and-secret-remember-me-key")
    .tokenValiditySeconds(86400 * 14)
    .userDetailsService(userDetailsService)
);
📦 Real World

Enterprise browser apps: form login + session cookie + CSRF. Mobile/SPA/API: stateless JWT or OAuth2 — disable session creation (SessionCreationPolicy.STATELESS).

Authorization

After authentication, Spring Security evaluates whether the request is allowed—by URL pattern, HTTP method, or later by method-level annotations.

HttpSecurity DSL — Boot 3 / Security 6
@Bean
SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
  return http
      .securityMatcher("/api/**")
      .authorizeHttpRequests(auth -> auth
          .requestMatchers(HttpMethod.GET, "/api/public/**").permitAll()
          .requestMatchers("/api/admin/**").hasRole("ADMIN")
          .requestMatchers("/api/orders/**").hasAnyAuthority("orders:read", "orders:write")
          .anyRequest().authenticated()
      )
      .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
      .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
      .build();
}

Roles vs authorities

ConceptFormatUsage
Role ROLE_ADMIN stored; hasRole("ADMIN") adds prefix automatically Coarse-grained — admin, user, ops
Authority Exact string — orders:write Fine-grained permissions — hasAuthority("orders:write")
⚠️ Pitfall

hasRole("ADMIN") checks for ROLE_ADMIN — don't double-prefix: storing ROLE_ROLE_ADMIN or calling hasAuthority("ADMIN") when you meant role.

Method security

URL rules aren't enough—enforce permissions on service methods where business logic lives. Enabled with @EnableMethodSecurity (Boot 3).

Enable method security
@Configuration
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true)
class MethodSecurityConfig {}

@Service
class OrderService {
  @PreAuthorize("hasAuthority('orders:read') or #customerId == authentication.name")
  public OrderDto getOrder(long orderId, String customerId) { /* ... */ }

  @PreAuthorize("hasRole('ADMIN')")
  public void deleteOrder(long orderId) { /* ... */ }

  @PostAuthorize("returnObject.owner == authentication.name")
  public OrderDto getAndVerifyOwner(long orderId) { /* ... */ }
}
AnnotationWhen evaluatedSpEL
@PreAuthorizeBefore methodYes — hasRole, #param
@PostAuthorizeAfter methodYes — returnObject, #result
@SecuredBefore methodNo — simple role names
@RolesAllowed (JSR-250)Before methodNo — standard annotation

Common SpEL expressions

ExpressionMeaning
isAuthenticated()User is logged in (not anonymous)
hasRole('ADMIN')Has ROLE_ADMIN authority
hasAuthority('orders:write')Exact authority match
principal.usernameAccess principal properties
#orderIdMethod parameter reference
@authz.check(authentication, #id)Custom bean — complex rules
🔖 Version Note

Boot 2: @EnableGlobalMethodSecurity(prePostEnabled = true). Boot 3 / Security 6: @EnableMethodSecurity — old annotation removed.

JWT authentication

Stateless APIs authenticate via signed JSON Web Tokens in the Authorization: Bearer header—no server session.

JWT structure

header.payload.signature — Base64URL-encoded segments. Payload contains claims (sub, exp, roles); signature verifies integrity with shared secret or public key.

sequenceDiagram
  participant C as Client
  participant F as JwtAuthFilter
  participant V as JwtValidator
  participant SCH as SecurityContextHolder
  C->>F: Bearer eyJhbG...
  F->>V: parse and verify signature
  V-->>F: Jwt claims
  F->>SCH: set Authentication
  F->>F: filterChain.doFilter

Custom JWT filter

OncePerRequestFilter implementation
@Component
class JwtAuthenticationFilter extends OncePerRequestFilter {
  private final JwtService jwtService;
  private final UserDetailsService userDetailsService;

  @Override
  protected void doFilterInternal(
      HttpServletRequest request,
      HttpServletResponse response,
      FilterChain filterChain
  ) throws ServletException, IOException {
    String header = request.getHeader(HttpHeaders.AUTHORIZATION);
    if (header == null || !header.startsWith("Bearer ")) {
      filterChain.doFilter(request, response);
      return;
    }
    String token = header.substring(7);
    try {
      String username = jwtService.extractUsername(token);
      if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
        UserDetails user = userDetailsService.loadUserByUsername(username);
        if (jwtService.isTokenValid(token, user)) {
          var auth = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
          auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
          SecurityContextHolder.getContext().setAuthentication(auth);
        }
      }
    } catch (JwtException ex) {
      SecurityContextHolder.clearContext();
      response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token");
      return;
    }
    filterChain.doFilter(request, response);
  }
}

Spring Boot OAuth2 Resource Server (preferred for JWT)

Validate JWT via issuer
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.acme.com/realms/production

Refresh token pattern

  1. Access token — short-lived (5–15 min), sent on API calls
  2. Refresh token — long-lived, stored securely, exchanged for new access token at /auth/refresh
  3. Rotate refresh tokens on use (detect theft); revoke on logout
⚠️ Pitfall

Algorithm confusion — attacker changes alg to HS256 and signs with public key. Always validate expected algorithm. None algorithm — reject tokens with alg: none. localStorage — XSS steals tokens; prefer httpOnly Secure cookies for browser apps, or memory-only for SPAs with short-lived tokens.

💡 Pro Tip

Use established libraries (Nimbus, Spring Resource Server) — don't hand-roll JWT parsing. Validate exp, iss, aud, and clock skew tolerance.

OAuth2 & OpenID Connect

Delegate authentication to an identity provider. Your app becomes OAuth2 Client (login) and/or Resource Server (API token validation).

OAuth2 roles

RoleWho
Resource OwnerEnd user (owns the data)
ClientYour application requesting access
Authorization ServerKeycloak, Okta, Auth0 — issues tokens
Resource ServerYour API — validates tokens, serves protected resources

Grant types (modern usage)

GrantUse caseNotes
Authorization Code + PKCE SPAs, mobile, user login Only recommended public client flow — PKCE replaces implicit
Client Credentials Service-to-service (no user) Machine clients with client_id + secret
Refresh Token Obtain new access token Always with rotation in production

OAuth2 Client — login with Keycloak/Okta

application.yml — OAuth2 client
spring:
  security:
    oauth2:
      client:
        registration:
          keycloak:
            client-id: order-service
            client-secret: ${KEYCLOAK_CLIENT_SECRET}
            scope: openid,profile,email
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
        provider:
          keycloak:
            issuer-uri: https://auth.acme.com/realms/production
Inject authorized client for outbound calls
@Service
class DownstreamClient {
  private final WebClient webClient;

  OrderDto fetchOrder(
      @RegisteredOAuth2AuthorizedClient("keycloak") OAuth2AuthorizedClient client,
      long orderId
  ) {
    String token = client.getAccessToken().getTokenValue();
    return webClient.get()
        .uri("/orders/{id}", orderId)
        .headers(h -> h.setBearerAuth(token))
        .retrieve()
        .bodyToMono(OrderDto.class)
        .block();
  }
}

Resource Server — JWT validation vs introspection

ApproachHowWhen
JWT (local) Verify signature with JWKS from issuer Default — fast, no network per request
Opaque token introspection POST token to /oauth2/introspect Revocable opaque tokens; higher latency
📦 Real World

Keycloak/Okta: configure client scopes for roles or custom claims mapped to GrantedAuthority via JwtAuthenticationConverter. Sync groups to app permissions at token conversion time—not on every DB hit.

CSRF, CORS & security headers

Defense in depth beyond authentication—protect browser clients from cross-site attacks and harden HTTP responses.

CSRF — Cross-Site Request Forgery

Attacker tricks a logged-in user's browser into submitting a request to your site with the user's session cookie. Spring's synchronizer token pattern requires a CSRF token (header or form field) matching server-side session state.

Disable CSRF for stateless JWT APIs
@Bean
SecurityFilterChain statelessApi(HttpSecurity http) throws Exception {
  return http
      .csrf(csrf -> csrf.disable())  // OK when no session cookie auth
      .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
      .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
      .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
      .build();
}
App typeCSRF
Session + cookie browser appEnable — Spring default
JWT Bearer stateless APIDisable — no cookie auth to exploit
SPA + session cookieEnable + expose CSRF token via endpoint; send in header

Security headers

Spring Security configures defaults; customize via headers() DSL:

HeaderPurpose
X-Content-Type-Options: nosniffPrevent MIME sniffing attacks
X-Frame-Options: DENYClickjacking protection — or CSP frame-ancestors
Strict-Transport-SecurityForce HTTPS (HSTS) — enable when TLS terminated at app or trusted proxy
Content-Security-PolicyRestrict script/style/load sources — strongest XSS mitigation
Custom headers
http.headers(headers -> headers
    .contentSecurityPolicy(csp -> csp
        .policyDirectives("default-src 'self'; frame-ancestors 'none'; script-src 'self'")
    )
    .frameOptions(HeadersConfigurer.FrameOptionsConfig::deny)
    .httpStrictTransportSecurity(hsts -> hsts
        .includeSubDomains(true)
        .maxAgeInSeconds(31536000)
    )
);

CORS in security filter chain

MVC WebMvcConfigurer.addCorsMappings may not apply to security-blocked preflight requests—configure CORS on HttpSecurity:

CorsConfigurationSource bean
@Bean
CorsConfigurationSource corsConfigurationSource() {
  CorsConfiguration config = new CorsConfiguration();
  config.setAllowedOrigins(List.of("https://app.acme.com"));
  config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
  config.setAllowedHeaders(List.of("*"));
  config.setAllowCredentials(true);
  UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
  source.registerCorsConfiguration("/api/**", config);
  return source;
}

@Bean
SecurityFilterChain chain(HttpSecurity http, CorsConfigurationSource cors) throws Exception {
  return http.cors(c -> c.configurationSource(cors)).build();
}
⚠️ Pitfall

Disabling CSRF on session-based apps is a critical vulnerability. Enabling CSRF on APIs that use only Bearer tokens breaks clients unnecessarily—match CSRF policy to auth mechanism.

Spring Security 6 (Boot 3) changes

Security 6 is a breaking cleanup—deprecated APIs removed, lambda DSL mandatory, clearer defaults for modern apps.

Boot 2 / Security 5Boot 3 / Security 6
Extend WebSecurityConfigurerAdapter Removed — define @Bean SecurityFilterChain
authorizeRequests() authorizeHttpRequests()
antMatchers() requestMatchers()
Chaining .and() Lambda DSL — each config block is lambda
@EnableGlobalMethodSecurity @EnableMethodSecurity
FilterSecurityInterceptor AuthorizationFilter
javax.* imports jakarta.*
Boot 3 security — complete baseline
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig {

  @Bean
  SecurityFilterChain defaultChain(HttpSecurity http) throws Exception {
    return http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/actuator/health").permitAll()
            .requestMatchers("/api/public/**").permitAll()
            .anyRequest().authenticated()
        )
        .formLogin(Customizer.withDefaults())
        .httpBasic(Customizer.withDefaults())
        .csrf(Customizer.withDefaults())
        .build();
  }

  @Bean
  PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }
}
🔬 Under the Hood

Security 6 requires Java 17+. SecurityFilterChain beans are composed in order; use @Order(1) for higher-priority chains (e.g. actuator on separate matcher). Default password encoder is DelegatingPasswordEncoder supporting multiple prefix formats ({bcrypt}, {argon2}).

🎯 Interview Tip

Migration story: component-based security configuration replaces inheritance; lambda DSL prevents incomplete configs; method security annotation rename; path matching uses requestMatchers with MVC's PathPattern parser.

🔖 Version Note

Spring Security 6.0 shipped with Spring Boot 3.0 (Nov 2022). Security 6.2+ adds improved OAuth2 and one-time token login support. Spring Security 7 will align with Boot 4 / Spring Framework 7.