Contents

Add the following to your pom.xml (Maven) or build.gradle (Gradle). Spring Boot manages compatible versions automatically when you use the Spring Boot BOM.

<!-- JJWT — Java JWT library --> <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> # application.properties app.jwt.secret=your-256-bit-base64-secret-key-here app.jwt.access-token-expiry-ms=900000 # 15 minutes app.jwt.refresh-token-expiry-ms=604800000 # 7 days

The class below shows the implementation. Key points are highlighted in the inline comments.

import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import javax.crypto.SecretKey; import java.util.Base64; import java.util.Date; import java.util.Map; @Service public class JwtService { private final SecretKey signingKey; private final long accessTokenExpiryMs; public JwtService( @Value("${app.jwt.secret}") String secret, @Value("${app.jwt.access-token-expiry-ms}") long accessTokenExpiryMs) { this.signingKey = Keys.hmacShaKeyFor(Base64.getDecoder().decode(secret)); this.accessTokenExpiryMs = accessTokenExpiryMs; } public String generateAccessToken(AppUserDetails user) { return Jwts.builder() .subject(user.getUsername()) .claim("userId", user.getId()) .claim("roles", user.getAuthorities().stream() .map(a -> a.getAuthority()).toList()) .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() + accessTokenExpiryMs)) .signWith(signingKey) .compact(); } public Claims validateAndParse(String token) { return Jwts.parser() .verifyWith(signingKey) .build() .parseSignedClaims(token) .getPayload(); } public String extractUsername(String token) { return validateAndParse(token).getSubject(); } public boolean isTokenExpired(String token) { return validateAndParse(token).getExpiration().before(new Date()); } }

Store refresh tokens in the database so they can be revoked. Never store them only in a cookie or memory — that prevents logout and token invalidation.

import jakarta.persistence.*; import java.time.Instant; @Entity @Table(name = "refresh_tokens") public class RefreshToken { @Id @GeneratedValue(strategy = GenerationType.UUID) private String id; // This IS the token value (UUID) @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private AppUser user; @Column(nullable = false) private Instant expiresAt; @Column(nullable = false) private boolean revoked = false; // getters / setters } import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import java.util.Optional; public interface RefreshTokenRepository extends JpaRepository<RefreshToken, String> { Optional<RefreshToken> findByIdAndRevokedFalse(String id); @Modifying @Query("UPDATE RefreshToken r SET r.revoked = true WHERE r.user.id = :userId") void revokeAllForUser(Long userId); }

The filter runs on every request, extracts the Bearer token from the Authorization header, validates it, and sets the SecurityContext. It extends OncePerRequestFilter to guarantee single execution per request.

import jakarta.servlet.FilterChain; import jakarta.servlet.http.*; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtService jwtService; private final AppUserDetailsService userDetailsService; public JwtAuthenticationFilter(JwtService jwtService, AppUserDetailsService userDetailsService) { this.jwtService = jwtService; this.userDetailsService = userDetailsService; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws java.io.IOException, jakarta.servlet.ServletException { String authHeader = request.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { chain.doFilter(request, response); return; } String token = authHeader.substring(7); String username; try { username = jwtService.extractUsername(token); } catch (JwtException e) { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token"); return; } if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails user = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authToken); } chain.doFilter(request, response); } }

The class below shows the implementation. Key points are highlighted in the inline comments.

@RestController @RequestMapping("/api/auth") @Transactional public class AuthController { private final AuthenticationManager authManager; private final JwtService jwtService; private final RefreshTokenService refreshTokenService; @PostMapping("/login") public ResponseEntity<AuthResponse> login(@RequestBody LoginRequest req) { authManager.authenticate( new UsernamePasswordAuthenticationToken(req.username(), req.password())); AppUserDetails user = (AppUserDetails) userDetailsService.loadUserByUsername(req.username()); String accessToken = jwtService.generateAccessToken(user); String refreshToken = refreshTokenService.createRefreshToken(user.getId()).getId(); return ResponseEntity.ok(new AuthResponse(accessToken, refreshToken)); } @PostMapping("/refresh") public ResponseEntity<AuthResponse> refresh(@RequestBody RefreshRequest req) { RefreshToken rt = refreshTokenService.validateAndRotate(req.refreshToken()); AppUserDetails user = (AppUserDetails) userDetailsService.loadUserByUsername(rt.getUser().getUsername()); String newAccessToken = jwtService.generateAccessToken(user); String newRefreshToken = rt.getId(); // rotated token id returned by validateAndRotate return ResponseEntity.ok(new AuthResponse(newAccessToken, newRefreshToken)); } @PostMapping("/logout") public ResponseEntity<Void> logout(@AuthenticationPrincipal AppUserDetails user) { refreshTokenService.revokeAll(user.getId()); SecurityContextHolder.clearContext(); return ResponseEntity.noContent().build(); } }

Token rotation replaces the old refresh token with a new one on every use, limiting the damage if a refresh token is stolen — the attacker's copy is invalidated after first use.

@Service @Transactional public class RefreshTokenService { private final RefreshTokenRepository repo; private final UserRepository userRepo; @Value("${app.jwt.refresh-token-expiry-ms}") private long expiryMs; public RefreshToken createRefreshToken(Long userId) { RefreshToken rt = new RefreshToken(); rt.setUser(userRepo.getReferenceById(userId)); rt.setExpiresAt(Instant.now().plusMillis(expiryMs)); return repo.save(rt); } public RefreshToken validateAndRotate(String tokenId) { RefreshToken rt = repo.findByIdAndRevokedFalse(tokenId) .orElseThrow(() -> new InvalidTokenException("Refresh token not found or revoked")); if (rt.getExpiresAt().isBefore(Instant.now())) { rt.setRevoked(true); throw new InvalidTokenException("Refresh token expired"); } // Revoke old token and issue a new one (rotation) rt.setRevoked(true); repo.save(rt); return createRefreshToken(rt.getUser().getId()); } public void revokeAll(Long userId) { repo.revokeAllForUser(userId); } }

The class below shows the implementation. Key points are highlighted in the inline comments.

@Configuration @EnableMethodSecurity public class SecurityConfig { private final JwtAuthenticationFilter jwtFilter; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .cors(cors -> cors.configurationSource(corsSource())) .sessionManagement(sm -> sm .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**").permitAll() .anyRequest().authenticated()) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration cfg) throws Exception { return cfg.getAuthenticationManager(); } } Store refresh tokens in an HttpOnly, Secure, SameSite=Strict cookie (not in localStorage) to prevent XSS theft. Access tokens can be kept in memory (JavaScript variable) since they are short-lived.