Mastering Double Token Authentication: Secure & Seamless Access for Web and Mobile Apps
This article explains the double token (Access Token + Refresh Token) authentication model, compares it with single‑token approaches, outlines production‑grade security measures, recommends token lifetimes, and provides a complete Java Spring Boot implementation with code examples.
Core Concepts
Access Token
Purpose: Credential for accessing protected resources.
Characteristics:
Short lifespan (10–30 minutes)
Sent with every API request
Low impact if leaked
Typical storage: In‑memory, localStorage, sessionStorage
Refresh Token
Purpose: Obtain a new Access Token.
Characteristics:
Long lifespan (7–30 days)
Never used directly to access resources
Must be stored securely and be revocable
Recommended storage: HttpOnly, Secure cookie
Authentication Workflow
1. Initial Login
Client submits credentials → server validates → returns an Access Token (short‑term) and a Refresh Token (long‑term) stored in an HttpOnly cookie.
2. API Calls
Each request includes Authorization: Bearer <access_token> header.
3. Access Token Expiration
Server responds 401 (token expired).
Client calls /refresh using the Refresh Token from the cookie.
Server issues a new Access Token (and a rotated Refresh Token).
Client retries the original request transparently.
4. Refresh Token Expiration
User must re‑authenticate.
Production‑Grade Security Measures
1. Persistent Refresh Token Store
Store each Refresh Token with metadata (user, IP, device, expiration) in a database or Redis to enable revocation and device management.
refresh_token:{token_id} = {</code><code> "userId": 1001,</code><code> "ip": "1.2.3.4",</code><code> "device": "Chrome XX",</code><code> "exp": "2025-01-01"</code><code>}2. Refresh Token Rotation
Generate a new Refresh Token on every successful refresh.
Invalidate the previous token.
If an old token is reused, treat it as a leak and force re‑authentication.
3. Multi‑Device Login Control
Limit the maximum number of concurrent devices per user.
When the limit is exceeded, evict the earliest device.
4. Risk‑Based Controls
Detect sudden IP or geographic changes.
Detect the same Refresh Token used on multiple devices.
Detect rapid, repeated refreshes (possible bots).
Automatically revoke compromised tokens and require login.
Recommended Token Lifetimes
Access Token: 10–30 minutes.
Refresh Token: 7–30 days.
Refresh Token Rotation: New token on every refresh to prevent replay attacks.
Java Spring Boot Implementation
1. JWT Utility
@Component
public class JwtUtil {
private final String ACCESS_SECRET = "your-access-secret-key";
private final String REFRESH_SECRET = "your-refresh-secret-key";
public String generateAccessToken(Long userId) {
return Jwts.builder()
.setSubject(String.valueOf(userId))
.setExpiration(Date.from(Instant.now().plus(Duration.ofMinutes(15))))
.signWith(SignatureAlgorithm.HS256, ACCESS_SECRET)
.compact();
}
public String generateRefreshToken(Long userId) {
return Jwts.builder()
.setSubject(String.valueOf(userId))
.setExpiration(Date.from(Instant.now().plus(Duration.ofDays(7))))
.signWith(SignatureAlgorithm.HS256, REFRESH_SECRET)
.compact();
}
public Claims validateAccessToken(String token) { return parse(token, ACCESS_SECRET); }
public Claims validateRefreshToken(String token) { return parse(token, REFRESH_SECRET); }
private Claims parse(String token, String secret) {
return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
}
}2. Refresh Token Storage (Redis)
@Service
public class RefreshTokenService {
@Autowired
private StringRedisTemplate redisTemplate;
private final String KEY_PREFIX = "refresh_token:";
public void storeRefreshToken(String token, Long userId) {
redisTemplate.opsForValue().set(KEY_PREFIX + token, String.valueOf(userId), Duration.ofDays(7));
}
public Long getUserId(String token) {
String value = redisTemplate.opsForValue().get(KEY_PREFIX + token);
return value == null ? null : Long.valueOf(value);
}
public void revoke(String token) {
redisTemplate.delete(KEY_PREFIX + token);
}
}3. Login Endpoint (Issue Double Tokens)
@RestController
@RequestMapping("/auth")
public class AuthController {
@Autowired private JwtUtil jwtUtil;
@Autowired private RefreshTokenService refreshTokenService;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest req, HttpServletResponse response) {
// Validate credentials (omitted)
Long userId = 1001L;
String accessToken = jwtUtil.generateAccessToken(userId);
String refreshToken = jwtUtil.generateRefreshToken(userId);
refreshTokenService.storeRefreshToken(refreshToken, userId);
ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken)
.httpOnly(true).secure(true).path("/").maxAge(7 * 24 * 3600).build();
response.addHeader("Set-Cookie", cookie.toString());
return ResponseEntity.ok(Map.of("accessToken", accessToken));
}
}4. Refresh Endpoint with Rotation
@PostMapping("/refresh")
public ResponseEntity<?> refresh(@CookieValue(value = "refreshToken", required = false) String refreshToken,
HttpServletResponse response) {
if (refreshToken == null) {
return ResponseEntity.status(401).body("Refresh Token missing");
}
Long userId = refreshTokenService.getUserId(refreshToken);
if (userId == null) {
return ResponseEntity.status(401).body("Refresh Token invalid");
}
try {
jwtUtil.validateRefreshToken(refreshToken);
} catch (Exception e) {
return ResponseEntity.status(401).body("Refresh Token expired");
}
String newAccessToken = jwtUtil.generateAccessToken(userId);
String newRefreshToken = jwtUtil.generateRefreshToken(userId);
refreshTokenService.revoke(refreshToken);
refreshTokenService.storeRefreshToken(newRefreshToken, userId);
ResponseCookie cookie = ResponseCookie.from("refreshToken", newRefreshToken)
.httpOnly(true).secure(true).path("/").maxAge(7 * 24 * 3600).build();
response.addHeader("Set-Cookie", cookie.toString());
return ResponseEntity.ok(Map.of("accessToken", newAccessToken));
}5. Access Token Filter
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
@Autowired private JwtUtil jwtUtil;
@Autowired private UserDetailsServiceImpl userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
Claims claims = jwtUtil.validateAccessToken(token);
Long userId = Long.valueOf(claims.getSubject());
UserDetails userDetails = userDetailsService.loadUserById(userId);
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (Exception e) {
// Authentication will fail downstream, triggering refresh flow.
}
}
chain.doFilter(request, response);
}
}Applicable Scenarios
Single‑Page Applications (React, Vue) requiring long‑lived sessions.
Mobile apps or mini‑programs that need persistent login.
Systems with multi‑device access (e‑commerce, social platforms).
Use cases where sudden disconnection would break user flow (online forms, instant messaging).
Conclusion
The double‑token pattern balances security and usability by using short‑lived Access Tokens for resource protection and long‑lived Refresh Tokens for seamless session renewal. Implementing token rotation, persistent storage, and risk‑based revocation—as demonstrated in the Java Spring Boot code—provides a robust, enterprise‑grade authentication solution.
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.
Ray's Galactic Tech
Practice together, never alone. We cover programming languages, development tools, learning methods, and pitfall notes. We simplify complex topics, guiding you from beginner to advanced. Weekly practical content—let's grow together!
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.
