Spring Boot JWT Login Authentication with Seamless Refresh Explained
This article explains how to implement a double‑token JWT authentication scheme in Spring Boot 3.x, using short‑lived AccessTokens for API security and long‑lived RefreshTokens for seamless background renewal, while addressing token revocation, refresh thresholds, blacklist handling, and multi‑device considerations.
JSON Web Token (JWT) is the dominant stateless authentication method for front‑end/back‑end separated projects because it eliminates server‑side session storage, naturally supports distributed clusters, and works across different clients. However, native JWT faces a dilemma: a long‑lived token is insecure if leaked, while a short‑lived token forces users to re‑login frequently.
1. What Double‑Token Seamless Refresh Is
1.1 Traditional Single‑Token Drawbacks
Security vs. Experience : Long expiration is unsafe; short expiration harms user experience.
Cannot Actively Revoke : After logout or password change, unexpired tokens remain usable.
Renewal Logic Chaos : Refreshing on every request makes the token effectively permanent.
1.2 Double‑Token Responsibilities
AccessToken : Short‑lived (typically 30 minutes ~ 2 hours), sent in request headers for API authentication. If leaked, the risk window is minimal.
RefreshToken : Long‑lived (typically 7 days ~ 30 days), used only on the refresh endpoint to obtain a new AccessToken. It is rarely transmitted, reducing exposure risk.
1.3 Complete Seamless Refresh Flow
User calls the login API; after credential verification, the server issues both AccessToken and RefreshToken.
Frontend calls business APIs with the AccessToken in the Authorization header; the backend interceptor validates the token.
When the AccessToken is about to expire (e.g., less than 5 minutes remaining), the backend automatically generates a new AccessToken, places it in the response header, and the frontend silently updates its stored token.
If the AccessToken has already expired, the frontend uses the RefreshToken to call the refresh endpoint, which validates the RefreshToken and issues a brand‑new token pair.
On explicit logout, the server adds the current tokens to a blacklist, immediately invalidating them.
1.4 Core Security Advantages
Only short‑lived AccessTokens are exposed to business APIs, limiting leakage impact.
RefreshTokens are used solely on the refresh endpoint, with low transmission frequency.
Blacklist and RefreshToken rotation enable proactive revocation, compensating for JWT’s stateless nature.
2. Design Principles
2.1 Expiration Guidelines
AccessToken: 30 minutes – balances security and refresh frequency.
RefreshToken: 7 days (configurable to 3 days for sensitive systems).
Refresh‑threshold: 5 minutes remaining before automatic renewal.
2.2 Refresh Trigger Modes
Passive (backend auto‑refresh) : The interceptor detects an imminent expiration and issues a new token in the response header, invisible to the user.
Active (frontend‑initiated) : The frontend detects expiration and explicitly calls the refresh API; if the refresh fails, it redirects to the login page.
Production environments typically combine both: automatic refresh for normal requests and explicit refresh when the token is fully expired.
2.3 Security Boundary Rules
RefreshToken must be validated against the same user identity; cross‑user refresh is prohibited.
Refresh endpoint must be rate‑limited to prevent brute‑force enumeration of RefreshTokens.
Sensitive operations (payment, password change, data deletion) must require secondary verification beyond token validation.
All token transmission must use HTTPS to prevent man‑in‑the‑middle attacks.
3. Code Samples
3.1 Core Dependencies (Spring Boot 3.x)
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- JWT core 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>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<!-- Redis for blacklist storage -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>3.2 Basic Entity Definitions
// Login request DTO
@Data
public class LoginDTO {
private String username;
private String password;
}
// Double‑token response
@Data
@AllArgsConstructor
public class TokenVO {
private String accessToken;
private String refreshToken;
// AccessToken expiration in seconds
private Long accessExpire;
// RefreshToken expiration in seconds
private Long refreshExpire;
}
// Logged‑in user context
@Data
public class LoginUser {
private Long userId;
private String username;
private String role;
}3.3 JWT Utility (Token Generation, Parsing, Expiration Check)
@Component
public class JwtUtil {
// Separate secrets for isolation
private static final String ACCESS_SECRET = "access_token_secret_key_2026_abcdefg123456";
private static final String REFRESH_SECRET = "refresh_token_secret_key_2026_zxcvbnm789012";
// Expiration configuration (seconds)
public static final long ACCESS_EXPIRE = 30 * 60L; // 30 minutes
public static final long REFRESH_EXPIRE = 7 * 24 * 60 * 60L; // 7 days
// Refresh threshold: auto‑refresh when less than 5 minutes remain
public static final long REFRESH_THRESHOLD = 5 * 60L;
private final ObjectMapper objectMapper = new ObjectMapper();
/** Generate AccessToken */
public String createAccessToken(LoginUser user) {
return Jwts.builder()
.subject(String.valueOf(user.getUserId()))
.claim("username", user.getUsername())
.claim("role", user.getRole())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + ACCESS_EXPIRE * 1000))
.signWith(Keys.hmacShaKeyFor(ACCESS_SECRET.getBytes(StandardCharsets.UTF_8)))
.compact();
}
/** Generate RefreshToken */
public String createRefreshToken(Long userId) {
return Jwts.builder()
.subject(String.valueOf(userId))
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + REFRESH_EXPIRE * 1000))
.signWith(Keys.hmacShaKeyFor(REFRESH_SECRET.getBytes(StandardCharsets.UTF_8)))
.compact();
}
/** Parse AccessToken */
public LoginUser parseAccessToken(String token) {
Claims claims = Jwts.parser()
.verifyWith(Keys.hmacShaKeyFor(ACCESS_SECRET.getBytes(StandardCharsets.UTF_8)))
.build()
.parseSignedClaims(token)
.getPayload();
LoginUser user = new LoginUser();
user.setUserId(Long.valueOf(claims.getSubject()));
user.setUsername(claims.get("username", String.class));
user.setRole(claims.get("role", String.class));
return user;
}
/** Parse RefreshToken (returns userId) */
public Long parseRefreshToken(String token) {
Claims claims = Jwts.parser()
.verifyWith(Keys.hmacShaKeyFor(REFRESH_SECRET.getBytes(StandardCharsets.UTF_8)))
.build()
.parseSignedClaims(token)
.getPayload();
return Long.valueOf(claims.getSubject());
}
/** Validate token signature */
public boolean validateToken(String token, boolean isAccess) {
try {
String secret = isAccess ? ACCESS_SECRET : REFRESH_SECRET;
Jwts.parser()
.verifyWith(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)))
.build()
.parseSignedClaims(token);
return true;
} catch (Exception e) {
return false;
}
}
/** Determine if AccessToken needs auto‑refresh */
public boolean isNeedRefresh(String token) {
try {
Claims claims = Jwts.parser()
.verifyWith(Keys.hmacShaKeyFor(ACCESS_SECRET.getBytes(StandardCharsets.UTF_8)))
.build()
.parseSignedClaims(token)
.getPayload();
long remain = claims.getExpiration().getTime() - System.currentTimeMillis();
return remain < REFRESH_THRESHOLD * 1000;
} catch (Exception e) {
return false;
}
}
}3.4 Custom Authentication Interceptor (Auto‑Validate + Seamless Refresh)
@Component
public class AuthInterceptor implements HandlerInterceptor {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private StringRedisTemplate redisTemplate;
private static final String BLACKLIST_PREFIX = "jwt:blacklist:";
public static final String NEW_TOKEN_HEADER = "X-New-Access-Token";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// Allow login and refresh endpoints
String uri = request.getRequestURI();
if (uri.contains("/login") || uri.contains("/refresh")) {
return true;
}
String token = request.getHeader("Authorization");
if (StrUtil.isBlank(token) || !token.startsWith("Bearer ")) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("Unauthenticated or invalid token");
return false;
}
token = token.substring(7);
// 1. Blacklist check
Boolean black = redisTemplate.hasKey(BLACKLIST_PREFIX + DigestUtils.md5Hex(token));
if (Boolean.TRUE.equals(black)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("Token has been revoked, please log in again");
return false;
}
// 2. Signature validation
if (!jwtUtil.validateToken(token, true)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("Token expired or illegal");
return false;
}
// 3. Parse user info and store in thread‑local context
LoginUser user = jwtUtil.parseAccessToken(token);
UserContext.setUser(user);
// 4. Auto‑refresh if needed
if (jwtUtil.isNeedRefresh(token)) {
String newToken = jwtUtil.createAccessToken(user);
response.setHeader(NEW_TOKEN_HEADER, newToken);
// Add old token to blacklist to prevent reuse
redisTemplate.opsForValue().set(BLACKLIST_PREFIX + DigestUtils.md5Hex(token), "1", JwtUtil.REFRESH_THRESHOLD, TimeUnit.SECONDS);
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
UserContext.clear();
}
}3.5 User Context Utility and Interceptor Registration
// Thread‑local user holder
public class UserContext {
private static final ThreadLocal<LoginUser> USER_HOLDER = new ThreadLocal<>();
public static void setUser(LoginUser user) { USER_HOLDER.set(user); }
public static LoginUser getUser() { return USER_HOLDER.get(); }
public static void clear() { USER_HOLDER.remove(); }
}
// Register interceptor and expose custom header for CORS
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private AuthInterceptor authInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/login", "/token/refresh", "/error");
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("*")
.allowedHeaders("*")
.exposedHeaders(AuthInterceptor.NEW_TOKEN_HEADER)
.allowCredentials(true);
}
}3.6 Login Endpoint (Credential Check + Issue Double Tokens)
@RestController
public class AuthController {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private StringRedisTemplate redisTemplate;
private static final String REFRESH_TOKEN_CACHE = "jwt:refresh:";
@PostMapping("/login")
public Result<TokenVO> login(@RequestBody LoginDTO dto) {
// 1. Validate credentials (demo uses hard‑coded values)
if (!"admin".equals(dto.getUsername()) || !"123456".equals(dto.getPassword())) {
return Result.fail("Invalid username or password");
}
// 2. Assemble user info
LoginUser user = new LoginUser();
user.setUserId(10001L);
user.setUsername(dto.getUsername());
user.setRole("admin");
// 3. Generate tokens
String accessToken = jwtUtil.createAccessToken(user);
String refreshToken = jwtUtil.createRefreshToken(user.getUserId());
// 4. Store RefreshToken in Redis for later verification
redisTemplate.opsForValue().set(REFRESH_TOKEN_CACHE + user.getUserId(), refreshToken, JwtUtil.REFRESH_EXPIRE, TimeUnit.SECONDS);
return Result.success(new TokenVO(accessToken, refreshToken, JwtUtil.ACCESS_EXPIRE, JwtUtil.REFRESH_EXPIRE));
}
}3.7 Refresh Endpoint and Logout (Token Rotation & Blacklist)
@RestController
@RequestMapping("/token")
public class TokenRefreshController {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private StringRedisTemplate redisTemplate;
private static final String REFRESH_TOKEN_CACHE = "jwt:refresh:";
@PostMapping("/refresh")
public Result<TokenVO> refreshToken(@RequestHeader("Refresh-Token") String refreshToken) {
// 1. Validate RefreshToken signature
if (!jwtUtil.validateToken(refreshToken, false)) {
return Result.fail("Invalid refresh token, please log in again");
}
// 2. Parse userId and compare with Redis cache to prevent forgery
Long userId = jwtUtil.parseRefreshToken(refreshToken);
String cacheToken = redisTemplate.opsForValue().get(REFRESH_TOKEN_CACHE + userId);
if (!refreshToken.equals(cacheToken)) {
return Result.fail("Refresh token has been revoked, please log in again");
}
// 3. Retrieve user info (demo uses static data)
LoginUser user = new LoginUser();
user.setUserId(userId);
user.setUsername("admin");
user.setRole("admin");
// 4. Issue new token pair (RefreshToken rotation)
String newAccessToken = jwtUtil.createAccessToken(user);
String newRefreshToken = jwtUtil.createRefreshToken(userId);
redisTemplate.opsForValue().set(REFRESH_TOKEN_CACHE + userId, newRefreshToken, JwtUtil.REFRESH_EXPIRE, TimeUnit.SECONDS);
return Result.success(new TokenVO(newAccessToken, newRefreshToken, JwtUtil.ACCESS_EXPIRE, JwtUtil.REFRESH_EXPIRE));
}
// Logout: add AccessToken to blacklist and delete RefreshToken
@PostMapping("/logout")
public Result<?> logout(@RequestHeader("Authorization") String token) {
if (StrUtil.isNotBlank(token) && token.startsWith("Bearer ")) {
token = token.substring(7);
LoginUser user = UserContext.getUser();
// Blacklist AccessToken
redisTemplate.opsForValue().set("jwt:blacklist:" + DigestUtils.md5Hex(token), "1", JwtUtil.ACCESS_EXPIRE, TimeUnit.SECONDS);
// Remove RefreshToken from Redis
redisTemplate.delete(REFRESH_TOKEN_CACHE + user.getUserId());
}
return Result.success("Logout successful");
}
}4. Addressing Native JWT Shortcomings
4.1 Blacklist Mechanism (Active Revocation)
When a user logs out, changes password, or is banned, the corresponding tokens are added to a Redis blacklist.
After an automatic refresh, the old AccessToken is also blacklisted to prevent simultaneous use.
Blacklist entries expire together with the token, freeing memory automatically.
4.2 RefreshToken Rotation
Each refresh operation generates a brand‑new RefreshToken; the previous one is immediately invalidated. This limits the exposure window of a leaked token to a single refresh cycle rather than the entire original validity period.
4.3 Refresh Endpoint Rate‑Limiting
To mitigate abuse, the refresh API should be limited (e.g., a maximum of 5 calls per minute per IP or user). Exceeding the threshold triggers temporary blocking.
4.4 Multi‑Device Login Strategies
Kick‑out mode : When the same user logs in on a new device, the previous device’s RefreshToken is revoked immediately—suitable for high‑security scenarios.
Concurrent login mode : Each device keeps its own RefreshToken, allowing simultaneous sessions on phone, PC, and tablet; logout only revokes the token of the current device.
4.5 Additional Hardening Recommendations
Prefer HttpOnly cookies for token storage on the front‑end to prevent XSS theft.
Enforce HTTPS site‑wide to avoid man‑in‑the‑middle interception.
Require secondary verification (SMS, password) for sensitive actions such as payments or password changes.
Never hard‑code secret keys; inject them via configuration services or environment variables and rotate them regularly.
5. Common Pitfalls and Solutions
CORS hides custom token header : Expose X-New-Access-Token via exposedHeaders in CORS configuration.
Refresh endpoint blocked by interceptor : Explicitly exclude /token/refresh from the interceptor’s path patterns.
Mixed secrets cause parsing failures : Keep AccessToken and RefreshToken secrets separate and encapsulate them inside the utility class.
Logout does not invalidate old token : Use the Redis blacklist to ensure that a logged‑out token cannot be used even if it has not yet expired.
Long‑lived RefreshToken risk : Implement rotation so that each refresh creates a new token and invalidates the previous one.
6. Full Summary
Implementing a double‑token JWT scheme in Spring Boot provides a production‑grade balance between security and user experience: short‑lived AccessTokens protect APIs, while long‑lived RefreshTokens enable transparent renewal. Combined with server‑side blacklist, token rotation, rate limiting, and multi‑device policies, the solution meets the stringent requirements of modern web back‑ends without the overhead of heavyweight frameworks such as Spring Security.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Java Tech Workshop
Focused on Java backend technologies, sharing fundamentals, multithreading, JVM, the Spring ecosystem, microservices, distributed systems, high concurrency, source‑code analysis, and practical experience. Continuously delivers high‑quality original content, interview guides, and learning roadmaps to help Java developers progress from beginner to advanced, enhancing technical skills and core competitiveness.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
