Most Spring Security problems I see in code review come from the same place: copy-pasting a Stack Overflow answer from 2019 that still uses WebSecurityConfigurerAdapter. That class was deprecated in Spring Security 5.7 and removed in 6.0. If your security config extends it, you are running code that the framework no longer supports, and upgrading will break things in ways that are not obvious.

This guide covers how Spring Security actually works in Spring Boot 3.x (Security 6.x), how to set up JWT authentication properly, and how to add OAuth2 login without wiring everything together manually. The goal is patterns you can use in production, not toy examples.

How Spring Security Filters Work

Spring Security operates as a chain of servlet filters that sits in front of your application. Every incoming request passes through this chain before it reaches your controllers. Each filter has a specific job: one extracts credentials, another checks them, another handles exceptions.

The critical concept is the SecurityFilterChain bean. In Spring Security 6, you define security configuration by declaring one of these beans — no inheritance required.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            );

        return http.build();
    }
}

A few things to notice here. csrf is disabled because stateless REST APIs using JWT do not use session cookies, so CSRF protection provides no benefit and only creates friction. SessionCreationPolicy.STATELESS tells Spring Security not to create or use HTTP sessions — again, appropriate for JWT-based APIs. If you are building a traditional server-rendered app with session cookies, keep CSRF enabled and remove the stateless session policy.

The authorizeHttpRequests block is where you define which URLs require what level of access. The order matters — rules are evaluated top-to-bottom and the first match wins. anyRequest().authenticated() at the end acts as a catch-all.

Password Encoding

Never store plain-text passwords. Never use MD5 or SHA-1. Spring Security ships with BCryptPasswordEncoder, which uses a slow adaptive hashing algorithm designed to be computationally expensive — that’s the point.

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder(12);
}

The 12 is the strength factor (log rounds). The default is 10. Going to 12 roughly quadruples the computation time. On modern hardware a strength of 12 takes around 300-500ms, which is imperceptible to users but makes brute-force attacks orders of magnitude harder. Adjust based on your hardware — you want encoding to take at least 200ms.

When you create or update a user’s password:

@Service
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public User createUser(String username, String rawPassword) {
        String encoded = passwordEncoder.encode(rawPassword);
        return userRepository.save(new User(username, encoded));
    }

    public boolean verifyPassword(String rawPassword, String encodedPassword) {
        return passwordEncoder.matches(rawPassword, encodedPassword);
    }
}

BCryptPasswordEncoder.matches() handles the salt extraction and timing-safe comparison internally. Do not write your own comparison logic.

JWT Authentication

JSON Web Tokens are the standard mechanism for stateless authentication in REST APIs. The flow is: user submits credentials, server validates them, server issues a signed JWT, client includes that JWT in subsequent requests.

Spring Security does not ship a JWT implementation. You need a library. java-jwt from Auth0 and jjwt from Stormpath are both well-maintained. This example uses jjwt-api.

Maven dependency:

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.6</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>

Token service:

@Service
public class JwtService {

    @Value("${app.jwt.secret}")
    private String jwtSecret;

    @Value("${app.jwt.expiration-ms}")
    private long jwtExpirationMs;

    private SecretKey getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(jwtSecret);
        return Keys.hmacShaKeyFor(keyBytes);
    }

    public String generateToken(UserDetails userDetails) {
        return Jwts.builder()
            .subject(userDetails.getUsername())
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + jwtExpirationMs))
            .signWith(getSigningKey())
            .compact();
    }

    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
    }

    private boolean isTokenExpired(String token) {
        return extractClaim(token, Claims::getExpiration).before(new Date());
    }

    private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        Claims claims = Jwts.parser()
            .verifyWith(getSigningKey())
            .build()
            .parseSignedClaims(token)
            .getPayload();
        return claimsResolver.apply(claims);
    }
}

Store the secret as a Base64-encoded string in your application properties. For production, use an environment variable or secrets manager — never commit it to source control.

app:
  jwt:
    secret: ${JWT_SECRET}
    expiration-ms: 86400000  # 24 hours

JWT filter:

The filter intercepts every request, extracts the token from the Authorization header, validates it, and sets the authentication in the security context.

@Component
public 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 {

        final String authHeader = request.getHeader("Authorization");

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        final String jwt = authHeader.substring(7);
        final String username = jwtService.extractUsername(jwt);

        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);

            if (jwtService.isTokenValid(jwt, userDetails)) {
                UsernamePasswordAuthenticationToken authToken =
                    new UsernamePasswordAuthenticationToken(
                        userDetails,
                        null,
                        userDetails.getAuthorities()
                    );
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }

        filterChain.doFilter(request, response);
    }
}

Register this filter in your SecurityFilterChain before the username/password filter:

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http,
        JwtAuthenticationFilter jwtAuthFilter) throws Exception {
    http
        .csrf(AbstractHttpConfigurer::disable)
        .sessionManagement(session ->
            session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/auth/**").permitAll()
            .anyRequest().authenticated()
        )
        .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

    return http.build();
}

OAuth2 Login with Spring Boot

Spring Boot auto-configures OAuth2 login when you include the right dependency and add properties. This covers Google as the provider, but GitHub, Facebook, and Okta follow the same pattern.

Dependency:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>

Properties:

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope:
              - email
              - profile

Spring Boot reads these properties and registers the Google OAuth2 client automatically. The login endpoint /oauth2/authorization/google is created for you.

Security configuration:

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/", "/login", "/error").permitAll()
            .anyRequest().authenticated()
        )
        .oauth2Login(oauth2 -> oauth2
            .loginPage("/login")
            .defaultSuccessUrl("/dashboard", true)
            .failureUrl("/login?error=true")
        );

    return http.build();
}

If you need to handle the OAuth2 success event to create or update a user in your database, implement OAuth2UserService:

@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);

        String email = oAuth2User.getAttribute("email");
        String name = oAuth2User.getAttribute("name");

        userRepository.findByEmail(email)
            .orElseGet(() -> userRepository.save(new User(email, name, Role.USER)));

        return oAuth2User;
    }
}

Register it in the security config:

.oauth2Login(oauth2 -> oauth2
    .userInfoEndpoint(userInfo ->
        userInfo.userService(customOAuth2UserService))
    .defaultSuccessUrl("/dashboard", true)
)

Method-Level Security

URL-based rules in SecurityFilterChain handle coarse-grained access. For fine-grained control, use method-level security annotations.

Enable it with @EnableMethodSecurity:

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
    // ...
}

Then use @PreAuthorize on service or controller methods:

@Service
public class ArticleService {

    @PreAuthorize("hasRole('ADMIN')")
    public void deleteArticle(Long id) {
        articleRepository.deleteById(id);
    }

    @PreAuthorize("hasRole('ADMIN') or #username == authentication.name")
    public Article getArticleForUser(Long id, String username) {
        return articleRepository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Article not found"));
    }

    @PostAuthorize("returnObject.author == authentication.name")
    public Article getArticle(Long id) {
        return articleRepository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Article not found"));
    }
}

@PreAuthorize evaluates before the method runs. @PostAuthorize evaluates after, with access to the return value. The SpEL expressions support roles, permissions, and comparisons against the authenticated user’s details.

CORS Configuration

If your frontend lives on a different origin than your API, you need CORS configuration. Do not use @CrossOrigin scattered across your controllers — configure it centrally.

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration configuration = new CorsConfiguration();
    configuration.setAllowedOrigins(List.of("https://yourfrontend.com"));
    configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
    configuration.setAllowedHeaders(List.of("Authorization", "Content-Type"));
    configuration.setAllowCredentials(true);
    configuration.setMaxAge(3600L);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", configuration);
    return source;
}

Then apply it in your filter chain:

http.cors(cors -> cors.configurationSource(corsConfigurationSource()))

For development, you might allow localhost:3000 or localhost:5173. Make sure you are not accidentally shipping that configuration to production.

Common Mistakes to Avoid

Permitting all actuator endpoints. management.endpoints.web.exposure.include=* exposes heap dumps, environment variables, and thread state. Either restrict actuator to a separate internal port or require authentication for sensitive endpoints.

Using .antMatchers() instead of .requestMatchers(). AntPathRequestMatcher was the default in Spring Security 5 but requestMatchers() in 6 defaults to MVC matching, which is aware of your servlet mapping. If you have a servlet path prefix, antMatchers will silently miss it.

Not handling AuthenticationException and AccessDeniedException for JSON APIs. By default, Spring Security redirects to a login page on authentication failure. REST clients expect a 401 or 403 JSON response. Configure exception handling:

http.exceptionHandling(ex -> ex
    .authenticationEntryPoint((request, response, e) -> {
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.getWriter().write("{\"error\": \"Unauthorized\"}");
    })
    .accessDeniedHandler((request, response, e) -> {
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.getWriter().write("{\"error\": \"Forbidden\"}");
    })
);

Storing JWTs in localStorage. JWTs stored in localStorage are accessible to JavaScript, which means XSS vulnerabilities can steal them. Use httpOnly cookies for JWT storage in browser-based applications. For mobile or non-browser clients, localStorage is acceptable because XSS does not apply in the same way.

Putting It Together

The patterns in this guide cover the majority of Spring Boot security scenarios. The short version: use SecurityFilterChain beans (not WebSecurityConfigurerAdapter), encode passwords with BCrypt, validate JWTs in a filter that runs before authentication, use @EnableMethodSecurity for fine-grained authorization, and handle auth exceptions properly for JSON APIs.

Spring Security has a lot of surface area, but the default auto-configuration is reasonable. You are mostly overriding specific pieces rather than building security from scratch. The goal is knowing which pieces to override and why.

For OAuth2 with JWT tokens (rather than session-based OAuth2), Spring Authorization Server is the current recommended path. It replaces the old Spring Security OAuth project and is actively maintained as a first-party Spring project.