Spring Security: JWT Access & Refresh Token Pattern
The access + refresh token pattern solves the tension between security and usability. Short-lived access tokens (15 min) limit exposure if stolen; long-lived refresh tokens (7 days, stored securely) let the client obtain new access tokens without requiring the user to log in again. Spring Security handles the filter-chain plumbing; your service manages token lifecycle.
Contents
Dependency (JJWT)
Add the following to your pom.xmlbuild.gradle
<!-- 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
JwtService: Generate & Validate Tokens
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());
}
}
Refresh Token Entity & Repository
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);
}
JWT Authentication Filter
The filter runs on every request, extracts the Bearer token from the AuthorizationSecurityContextOncePerRequestFilter
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);
}
}
Login & Refresh Endpoints
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 & Revocation
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);
}
}
SecurityFilterChain Configuration
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();
}
}
HttpOnlySecureSameSite=StrictlocalStorage