A production-ready JWT auth setup in Spring Boot — filter chain, token validation, refresh tokens, and the common mistakes that make your API insecure.
JWT authentication is one of those things where every tutorial looks simple but production code is full of subtle bugs. After building auth for multiple Spring Boot services, here's the setup I actually trust.
Spring Security works via a chain of filters. You plug JWT validation in as a OncePerRequestFilter that runs before UsernamePasswordAuthenticationFilter. If the token is valid, you set the Authentication in SecurityContextHolder and let the request through.
1@Component
2@RequiredArgsConstructor
3public class JwtAuthFilter extends OncePerRequestFilter {
4
5 private final JwtService jwtService;
6 private final UserDetailsService userDetailsService;
7
8 @Override
9 protected void doFilterInternal(HttpServletRequest req,
10 HttpServletResponse res,
11 FilterChain chain) throws ServletException, IOException {
12
13 String header = req.getHeader("Authorization");
14 if (header == null || !header.startsWith("Bearer ")) {
15 chain.doFilter(req, res);
16 return;
17 }
18
19 String token = header.substring(7);
20 String username = jwtService.extractUsername(token);
21
22 if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
23 UserDetails user = userDetailsService.loadUserByUsername(username);
24 if (jwtService.isTokenValid(token, user)) {
25 UsernamePasswordAuthenticationToken auth =
26 new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
27 auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(req));
28 SecurityContextHolder.getContext().setAuthentication(auth);
29 }
30 }
31 chain.doFilter(req, res);
32 }
33}1@Service
2public class JwtService {
3
4 @Value("${jwt.secret}")
5 private String secret;
6
7 private static final long EXPIRY_MS = 1000 * 60 * 60 * 24; // 24h
8
9 public String generateToken(UserDetails user) {
10 return Jwts.builder()
11 .setSubject(user.getUsername())
12 .setIssuedAt(new Date())
13 .setExpiration(new Date(System.currentTimeMillis() + EXPIRY_MS))
14 .signWith(getSignKey(), SignatureAlgorithm.HS256)
15 .compact();
16 }
17
18 public boolean isTokenValid(String token, UserDetails user) {
19 return extractUsername(token).equals(user.getUsername())
20 && !isTokenExpired(token);
21 }
22
23 private Key getSignKey() {
24 return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
25 }
26}Never hardcode your JWT secret in code. Store it in environment variables and use a minimum 256-bit secret for HS256. Rotate it if it's ever exposed.
The refresh token in DB is the kill switch. Even if an access token is stolen, you can invalidate the session by deleting the refresh token. This is the only production-safe pattern.
1@Configuration
2@EnableWebSecurity
3@RequiredArgsConstructor
4public class SecurityConfig {
5
6 private final JwtAuthFilter jwtAuthFilter;
7
8 @Bean
9 public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
10 return http
11 .csrf(AbstractHttpConfigurer::disable)
12 .sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
13 .authorizeHttpRequests(auth -> auth
14 .requestMatchers("/api/auth/**").permitAll()
15 .requestMatchers("/api/admin/**").hasRole("ADMIN")
16 .anyRequest().authenticated()
17 )
18 .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
19 .build();
20 }
21
22 @Bean
23 public AuthenticationManager authManager(AuthenticationConfiguration config)
24 throws Exception {
25 return config.getAuthenticationManager();
26 }
27
28 @Bean
29 public PasswordEncoder passwordEncoder() {
30 return new BCryptPasswordEncoder(12);
31 }
32}1@RestController
2@RequestMapping("/api/auth")
3@RequiredArgsConstructor
4public class AuthController {
5
6 private final AuthenticationManager authManager;
7 private final JwtService jwtService;
8 private final RefreshTokenService refreshTokenService;
9
10 @PostMapping("/login")
11 public ResponseEntity<AuthResponse> login(@RequestBody @Valid LoginRequest req) {
12 authManager.authenticate(
13 new UsernamePasswordAuthenticationToken(req.email(), req.password())
14 );
15 UserDetails user = userDetailsService.loadUserByUsername(req.email());
16 String accessToken = jwtService.generateToken(user);
17 String refreshToken = refreshTokenService.create(req.email());
18
19 return ResponseEntity.ok(new AuthResponse(accessToken, refreshToken));
20 }
21
22 @PostMapping("/refresh")
23 public ResponseEntity<AuthResponse> refresh(@RequestBody RefreshRequest req) {
24 RefreshToken token = refreshTokenService.validate(req.refreshToken());
25 String newAccess = jwtService.generateToken(token.getUser());
26 return ResponseEntity.ok(new AuthResponse(newAccess, req.refreshToken()));
27 }
28
29 @PostMapping("/logout")
30 public ResponseEntity<Void> logout(@RequestBody RefreshRequest req) {
31 refreshTokenService.delete(req.refreshToken());
32 return ResponseEntity.noContent().build();
33 }
34}1@RestController
2@RequestMapping("/api/admin")
3@PreAuthorize("hasRole('ADMIN')")
4public class AdminController {
5
6 @GetMapping("/users")
7 public List<User> getAllUsers() { ... }
8
9 // Method-level override
10 @GetMapping("/public-stats")
11 @PreAuthorize("permitAll()")
12 public Stats publicStats() { ... }
13}Add @EnableMethodSecurity on your config class to enable @PreAuthorize. Without it, the annotations are silently ignored — no error, no protection. This is the most common 'why isn't my security working' bug.
More in Backend Engineering