JWT is stateless, scalable, and works across microservices. But implementing it correctly — with token refresh, revocation, and proper security — requires more than copying a tutorial.
JOptimize Team
JWT (JSON Web Token) authentication is stateless — the server doesn't need to store session state, every token is self-contained, and any instance of your service can validate tokens independently. This makes it ideal for stateless microservices and horizontal scaling.
But most JWT tutorials stop at the happy path: generate a token, validate a token, done. Production JWT implementation requires refresh tokens, revocation, secure storage guidance for clients, and correct Spring Security integration. This guide covers all of it.
Before writing code, it's worth being clear about the flow:
/auth/loginAuthorization: Bearer <token> header on every request/auth/refresh to get a new access token<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </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>
@Service public class JwtTokenService { @Value("${jwt.secret}") private String secret; private SecretKey signingKey() { return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)); } public String generateAccessToken(UserDetails user) { return Jwts.builder() .subject(user.getUsername()) .claim("roles", user.getAuthorities().stream() .map(GrantedAuthority::getAuthority).toList()) .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() + 15 * 60 * 1000)) // 15 min .signWith(signingKey()) .compact(); } public String generateRefreshToken(String username) { return Jwts.builder() .subject(username) .claim("type", "refresh") .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000L)) // 7 days .signWith(signingKey()) .compact(); } public Claims validateAndParse(String token) { return Jwts.parser() .verifyWith(signingKey()) .build() .parseSignedClaims(token) .getPayload(); // Throws JwtException if expired, tampered, or invalid } public String extractUsername(String token) { return validateAndParse(token).getSubject(); } public boolean isAccessToken(String token) { Claims claims = validateAndParse(token); return !"refresh".equals(claims.get("type")); } }
The filter extracts and validates the JWT on every request:
@Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenService tokenService; private final UserDetailsService userDetailsService; private final TokenRevocationService revocationService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { String authHeader = request.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { chain.doFilter(request, response); return; } String token = authHeader.substring(7); try { if (!tokenService.isAccessToken(token)) { // Refuse refresh tokens used as access tokens response.sendError(HttpStatus.UNAUTHORIZED.value(), "Invalid token type"); return; } if (revocationService.isRevoked(token)) { response.sendError(HttpStatus.UNAUTHORIZED.value(), "Token revoked"); return; } String username = tokenService.extractUsername(token); UserDetails userDetails = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(auth); } catch (JwtException e) { // Token expired, tampered, or invalid signature response.sendError(HttpStatus.UNAUTHORIZED.value(), "Invalid or expired token"); return; } chain.doFilter(request, response); } }
@Configuration @EnableWebSecurity @EnableMethodSecurity @RequiredArgsConstructor public class SecurityConfig { private final JwtAuthenticationFilter jwtFilter; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) // JWT is stateless — CSRF not needed .sessionManagement(s -> s .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/v1/auth/**").permitAll() .requestMatchers("/actuator/health").permitAll() .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); // Cost factor 12 — balance of security and speed } }
Access tokens expire in 15 minutes. The refresh flow issues new access tokens without re-entering credentials:
@Service @RequiredArgsConstructor public class TokenRevocationService { private final RedisTemplate<String, String> redis; private static final String PREFIX = "revoked:"; // Store the token JTI (JWT ID) in Redis until expiry public void revoke(String token) { Claims claims = jwtTokenService.validateAndParse(token); long ttl = claims.getExpiration().getTime() - System.currentTimeMillis(); if (ttl > 0) { redis.opsForValue().set( PREFIX + token, "revoked", Duration.ofMillis(ttl) ); } } public boolean isRevoked(String token) { return Boolean.TRUE.equals(redis.hasKey(PREFIX + token)); } } @RestController @RequestMapping("/api/v1/auth") @RequiredArgsConstructor public class AuthController { private final JwtTokenService tokenService; private final TokenRevocationService revocationService; private final AuthenticationManager authManager; @PostMapping("/login") public AuthResponse login(@RequestBody @Valid LoginRequest req, HttpServletResponse response) { Authentication auth = authManager.authenticate( new UsernamePasswordAuthenticationToken(req.username(), req.password())); UserDetails user = (UserDetails) auth.getPrincipal(); String accessToken = tokenService.generateAccessToken(user); String refreshToken = tokenService.generateRefreshToken(user.getUsername()); // Refresh token in HttpOnly cookie — not accessible to JavaScript ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken) .httpOnly(true) .secure(true) .path("/api/v1/auth/refresh") .maxAge(Duration.ofDays(7)) .sameSite("Strict") .build(); response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); return new AuthResponse(accessToken); } @PostMapping("/refresh") public AuthResponse refresh(@CookieValue("refreshToken") String refreshToken) { if (revocationService.isRevoked(refreshToken)) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Token revoked"); } String username = tokenService.extractUsername(refreshToken); UserDetails user = userDetailsService.loadUserByUsername(username); return new AuthResponse(tokenService.generateAccessToken(user)); } @PostMapping("/logout") public void logout(@CookieValue("refreshToken") String refreshToken, @RequestHeader("Authorization") String authHeader) { revocationService.revoke(authHeader.substring(7)); // Revoke access token revocationService.revoke(refreshToken); // Revoke refresh token } }
HS256 for symmetric (single service) or RS256 for asymmetric (multiple services validating tokens issued by one auth service)JWT authentication in Spring Boot requires four components: a token service for generation and validation, a security filter for per-request validation, a Spring Security configuration for authorization rules, and a revocation mechanism (Redis) for logout and compromised token handling. Store access tokens in JavaScript memory, refresh tokens in HttpOnly cookies, and rotate secrets regularly.
JWT validation adds overhead on every request. JOptimize identifies security anti-patterns and performance issues in your Spring Security configuration.
Authenticate correctly. Perform correctly.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.