Backend EngineeringJune 15, 20258 min read

Spring Boot JWT Authentication: Complete Guide

A production-ready JWT auth setup in Spring Boot — filter chain, token validation, refresh tokens, and the common mistakes that make your API insecure.

JavaSpring BootSpring SecurityJWTREST APIs

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.

The Filter Chain

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.

JwtAuthFilter.java
java
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}

Token Generation and Validation

JwtService.java
java
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.

Refresh Token Pattern

  • Access token: short-lived (15min–24h), stateless, validated via signature
  • Refresh token: long-lived (7–30 days), stored in DB, invalidatable
  • On expiry, client sends refresh token to /auth/refresh — server validates DB entry and issues new access token
  • Logout = delete refresh token from DB, client discards access token

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.

Security Configuration

SecurityConfig.java
java
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}

Login Endpoint

AuthController.java
java
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}

Role-Based Access with @PreAuthorize

AdminController.java
java
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.

Common Mistakes

  • Storing JWT in localStorage — vulnerable to XSS. Use HttpOnly cookie or memory-only storage.
  • Not validating token expiry in the filter — always check isTokenExpired() before setting authentication.
  • Same secret across environments — use different secrets for dev/staging/prod.
  • Not handling token theft — implement token rotation: issue a new refresh token on every /refresh call and invalidate the old one.
  • Missing CORS configuration — your JWT filter will reject preflight OPTIONS requests if CORS isn't configured before the auth filter.

More in Backend Engineering