JWT & Refresh Token in Spring Boot
Introduction
In modern backend systems, authentication and authorization are commonly implemented using JWT (JSON Web Token). However, using JWT incorrectly can introduce serious security risks.
This guide explains:
- What JWT really is
- Why Access Token alone is not enough
- Why Refresh Token is necessary
- How to implement both in Spring Boot (without OAuth2)
- Enterprise best practices
1. What is JWT?
A JWT is a compact, URL-safe token used for authentication. It consists of 3 parts:
- header.payload.signature
Example:
- eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMSIsInJvbGUiOiJBRE1JTiJ9.signature
Structure
1️⃣ Header
{
"alg": "HS256",
"typ": "JWT"
}
2️⃣ Payload (Claims)
{
"sub": "user1",
"role": "ADMIN",
"exp": 1700000000
}
3️⃣ Signature
HMACSHA256(base64Url(header) + "." + base64Url(payload), secret)
2. Why NOT Use Only One JWT Token?
There are two common mistakes:
Long-lived Access Token (30 days)
If token is leaked: - Attacker can use system for 30 days - No way to revoke (JWT is stateless)
Short-lived Access Token (15 minutes)
User must log in again after expiration → terrible UX.
3. The Correct Solution: Access + Refresh Token
| Token Type | Purpose | Lifetime |
|---|---|---|
| Access Token | Call API | 10--15 minutes |
| Refresh Token | Get new access token | 7 days |
Benefits: - Access token is short-lived → limited damage if leaked - Refresh token stored securely → can revoke sessions - Good UX + Good Security
4. Implement JWT in Spring Boot
Step 1: Add Dependency (Gradle)
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
Step 2: JwtService
@Service
public class JwtService {
private final String SECRET = "super-secret-key-super-secret-key";
private Key getSigningKey() {
return Keys.hmacShaKeyFor(SECRET.getBytes());
}
public String generateAccessToken(User user) {
return Jwts.builder()
.setSubject(user.getUsername())
.claim("role", user.getRole())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 15 * 60 * 1000))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}
}
Step 3: JWT Authentication Filter
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtService jwtService;
@Autowired
private UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
Claims claims = jwtService.parseToken(token);
String username = claims.getSubject();
UserDetails user = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
user, null, user.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
}
5. Implement Refresh Token
Refresh Token MUST
- Be random string (NOT JWT)
- Be stored in database
- Support revocation
- Support rotation
Step 1: Database Entity
@Entity
@Table(name = "refresh_tokens")
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(nullable = false, unique = true)
private String token;
@Column(nullable = false)
private LocalDateTime expiryDate;
@Column(nullable = false)
private boolean revoked = false;
@Column(nullable = false)
private LocalDateTime createdAt;
// Getters and setters
}
Step 2: Repository
@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByToken(String token);
@Modifying
@Query("UPDATE RefreshToken rt SET rt.revoked = true WHERE rt.user.id = :userId")
void revokeAllByUserId(@Param("userId") Long userId);
@Modifying
@Query("DELETE FROM RefreshToken rt WHERE rt.expiryDate < :now")
void deleteExpiredTokens(@Param("now") LocalDateTime now);
}
Step 3: RefreshToken Service
@Service
public class RefreshTokenService {
@Autowired
private RefreshTokenRepository refreshTokenRepository;
@Autowired
private UserRepository userRepository;
private static final long REFRESH_TOKEN_VALIDITY = 7 * 24 * 60 * 60 * 1000; // 7 days
public String generateRefreshToken() {
return UUID.randomUUID().toString() + UUID.randomUUID().toString().replace("-", "");
}
public RefreshToken createRefreshToken(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("User not found"));
RefreshToken refreshToken = new RefreshToken();
refreshToken.setUser(user);
refreshToken.setToken(generateRefreshToken());
refreshToken.setExpiryDate(LocalDateTime.now().plusSeconds(REFRESH_TOKEN_VALIDITY / 1000));
refreshToken.setCreatedAt(LocalDateTime.now());
refreshToken.setRevoked(false);
return refreshTokenRepository.save(refreshToken);
}
public RefreshToken verifyRefreshToken(String token) {
RefreshToken refreshToken = refreshTokenRepository.findByToken(token)
.orElseThrow(() -> new RuntimeException("Refresh token not found"));
if (refreshToken.isRevoked()) {
throw new RuntimeException("Refresh token has been revoked");
}
if (refreshToken.getExpiryDate().isBefore(LocalDateTime.now())) {
throw new RuntimeException("Refresh token is expired");
}
return refreshToken;
}
@Transactional
public void revokeToken(String token) {
RefreshToken refreshToken = refreshTokenRepository.findByToken(token)
.orElseThrow(() -> new RuntimeException("Token not found"));
refreshToken.setRevoked(true);
refreshTokenRepository.save(refreshToken);
}
@Transactional
public void revokeAllUserTokens(Long userId) {
refreshTokenRepository.revokeAllByUserId(userId);
}
}
Step 4: Authentication Response DTO
public class AuthResponse {
private String accessToken;
private String refreshToken;
private String tokenType = "Bearer";
private long expiresIn; // seconds
public AuthResponse(String accessToken, String refreshToken, long expiresIn) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
this.expiresIn = expiresIn;
}
// Getters and setters
}
Step 5: Auth Controller
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtService jwtService;
@Autowired
private RefreshTokenService refreshTokenService;
@Autowired
private UserRepository userRepository;
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@RequestBody LoginRequest request) {
// 1. Authenticate user
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
SecurityContextHolder.getContext().setAuthentication(authentication);
// 2. Get user details
User user = userRepository.findByUsername(request.getUsername())
.orElseThrow(() -> new RuntimeException("User not found"));
// 3. Generate access token
String accessToken = jwtService.generateAccessToken(user);
// 4. Generate and save refresh token
RefreshToken refreshToken = refreshTokenService.createRefreshToken(user.getId());
// 5. Return both tokens
return ResponseEntity.ok(new AuthResponse(
accessToken,
refreshToken.getToken(),
15 * 60 // 15 minutes in seconds
));
}
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refreshToken(@RequestBody RefreshRequest request) {
try {
// 1. Verify refresh token
RefreshToken refreshToken = refreshTokenService.verifyRefreshToken(request.getRefreshToken());
User user = refreshToken.getUser();
// 2. Generate new access token
String newAccessToken = jwtService.generateAccessToken(user);
// 3. Generate new refresh token (rotation)
RefreshToken newRefreshToken = refreshTokenService.createRefreshToken(user.getId());
// 4. Revoke old refresh token
refreshTokenService.revokeToken(request.getRefreshToken());
// 5. Return new tokens
return ResponseEntity.ok(new AuthResponse(
newAccessToken,
newRefreshToken.getToken(),
15 * 60
));
} catch (RuntimeException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(null);
}
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(@RequestBody LogoutRequest request) {
try {
// Revoke the refresh token
refreshTokenService.revokeToken(request.getRefreshToken());
// Clear security context
SecurityContextHolder.clearContext();
return ResponseEntity.ok().build();
} catch (RuntimeException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
}
}
Step 6: Request DTOs
public class LoginRequest {
private String username;
private String password;
// Getters and setters
}
public class RefreshRequest {
private String refreshToken;
// Getters and setters
}
public class LogoutRequest {
private String refreshToken;
// Getters and setters
}
Login Flow Summary
- User sends username + password
- Server authenticates
- Generate access token (15 minutes)
- Generate refresh token (7 days) and save to database
- Return both tokens to client
Refresh Flow Summary
Client sends refresh token.
Server:
- Check token exists in database
- Check not revoked
- Check expiry date
- Generate new access token
- Generate new refresh token
- Revoke old refresh token (rotation)
- Return new tokens
6. Access Token Blacklist Implementation
Why Blacklist Access Tokens?
Even though access tokens are short-lived (15 minutes), you may need to revoke them immediately for:
- User logout - Invalidate token before expiration
- Security incident - Compromised token detection
- Permission changes - Role/permission updates
- Account suspension - Admin actions
Approach 1: Redis-Based Blacklist (Recommended)
Redis is ideal for token blacklisting because:
- Fast lookup (O(1))
- Built-in TTL (auto-cleanup)
- No database bloat
Step 1: Add Redis Dependency
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
Step 2: Redis Configuration
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
return template;
}
}
Step 3: Token Blacklist Service
@Service
public class TokenBlacklistService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final String BLACKLIST_PREFIX = "blacklist:token:";
/**
* Add token to blacklist with TTL equal to remaining token lifetime
*/
public void blacklistToken(String token, long expirationTimeMillis) {
String key = BLACKLIST_PREFIX + token;
// Calculate remaining time until token expires
long currentTime = System.currentTimeMillis();
long ttlSeconds = (expirationTimeMillis - currentTime) / 1000;
if (ttlSeconds > 0) {
// Store token in Redis with TTL
redisTemplate.opsForValue().set(key, "revoked", ttlSeconds, TimeUnit.SECONDS);
}
}
/**
* Check if token is blacklisted
*/
public boolean isBlacklisted(String token) {
String key = BLACKLIST_PREFIX + token;
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
/**
* Blacklist all tokens for a specific user
*/
public void blacklistUserTokens(String userId) {
String userKey = BLACKLIST_PREFIX + "user:" + userId;
// Store user ID with long TTL (e.g., 1 hour)
redisTemplate.opsForValue().set(userKey, "revoked", 1, TimeUnit.HOURS);
}
/**
* Check if all user tokens are blacklisted
*/
public boolean isUserBlacklisted(String userId) {
String userKey = BLACKLIST_PREFIX + "user:" + userId;
return Boolean.TRUE.equals(redisTemplate.hasKey(userKey));
}
}
Step 4: Update JwtService to Extract Claims
@Service
public class JwtService {
private final String SECRET = "super-secret-key-super-secret-key";
private Key getSigningKey() {
return Keys.hmacShaKeyFor(SECRET.getBytes());
}
public String generateAccessToken(User user) {
return Jwts.builder()
.setSubject(user.getUsername())
.claim("userId", user.getId())
.claim("role", user.getRole())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 15 * 60 * 1000))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}
public long getExpirationTime(String token) {
Claims claims = parseToken(token);
return claims.getExpiration().getTime();
}
public String getUserId(String token) {
Claims claims = parseToken(token);
return claims.get("userId", String.class);
}
}
Step 5: Update JWT Filter with Blacklist Check
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtService jwtService;
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private TokenBlacklistService blacklistService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
try {
// 1. Check if token is blacklisted
if (blacklistService.isBlacklisted(token)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("Token has been revoked");
return;
}
// 2. Parse token
Claims claims = jwtService.parseToken(token);
String username = claims.getSubject();
String userId = claims.get("userId", String.class);
// 3. Check if all user tokens are blacklisted
if (blacklistService.isUserBlacklisted(userId)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("User session has been revoked");
return;
}
// 4. Authenticate user
UserDetails user = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
user, null, user.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (Exception e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("Invalid token");
return;
}
}
filterChain.doFilter(request, response);
}
}
Step 6: Update Logout Endpoint
@PostMapping("/logout")
public ResponseEntity<Void> logout(
@RequestHeader("Authorization") String authHeader,
@RequestBody LogoutRequest request) {
try {
// 1. Extract access token from header
String accessToken = authHeader.substring(7);
// 2. Blacklist access token
long expirationTime = jwtService.getExpirationTime(accessToken);
blacklistService.blacklistToken(accessToken, expirationTime);
// 3. Revoke refresh token
refreshTokenService.revokeToken(request.getRefreshToken());
// 4. Clear security context
SecurityContextHolder.clearContext();
return ResponseEntity.ok().build();
} catch (RuntimeException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
}
Approach 2: Database-Based Blacklist (Alternative)
If you don't have Redis, you can use a database table. But note that this may cause performance issue, as we look up blacklist token every request with JWT token.
You can implement caching or database indexing to resolve performance issue, by choosing an evict cache stratergy.
Step 1: Entity
@Entity
@Table(name = "token_blacklist")
public class BlacklistedToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 512)
private String token;
@Column(nullable = false)
private LocalDateTime expiryDate;
@Column(nullable = false)
private LocalDateTime blacklistedAt;
private String reason; // "LOGOUT", "SECURITY", "ADMIN_ACTION"
// Getters and setters
}
Step 2: Repository
@Repository
public interface BlacklistedTokenRepository extends JpaRepository<BlacklistedToken, Long> {
boolean existsByToken(String token);
@Modifying
@Query("DELETE FROM BlacklistedToken bt WHERE bt.expiryDate < :now")
void deleteExpiredTokens(@Param("now") LocalDateTime now);
}
Step 3: Blacklist Service
@Service
public class TokenBlacklistService {
@Autowired
private BlacklistedTokenRepository blacklistRepository;
public void blacklistToken(String token, long expirationTimeMillis, String reason) {
BlacklistedToken blacklistedToken = new BlacklistedToken();
blacklistedToken.setToken(token);
blacklistedToken.setExpiryDate(
LocalDateTime.ofInstant(
Instant.ofEpochMilli(expirationTimeMillis),
ZoneId.systemDefault()
)
);
blacklistedToken.setBlacklistedAt(LocalDateTime.now());
blacklistedToken.setReason(reason);
blacklistRepository.save(blacklistedToken);
}
public boolean isBlacklisted(String token) {
return blacklistRepository.existsByToken(token);
}
@Scheduled(cron = "0 0 * * * *") // Every hour
@Transactional
public void cleanupExpiredTokens() {
blacklistRepository.deleteExpiredTokens(LocalDateTime.now());
}
}
Performance Considerations
| Approach | Lookup Speed | Storage | Auto-Cleanup | Best For |
|---|---|---|---|---|
| Redis | O(1) - Very Fast | In-Memory | Built-in TTL | Production (Recommended) |
| Database | O(log n) - Slower | On-Disk | Manual Cron | Small scale or no Redis |
7. Best Practices (Production)
✅ Access Token
- 10--15 minutes lifetime
- Signed with RS256 (preferred for microservices)
- Support blacklist token for urgent case:
- Use Redis for production - faster and more efficient
- Set proper TTL - only store until token expires
- Blacklist on logout - prevent token reuse
- Monitor blacklist size - ensure cleanup is working
- Log blacklisting events - for security auditing
✅ Refresh Token
- Stored in DB or Redis
- HttpOnly cookie recommended
- Rotation enabled
- Reuse detection enabled
Conclusion
Using only a single JWT token is simple but unsafe for production.
The correct enterprise approach:
- Short-lived Access Token
- Long-lived Refresh Token
- Rotation
- Revocation support
- RS256 signing (recommended)
