1. Introduction

In JWT (JSON Web Token) based authentication systems, AccessToken is typically set with a short lifetime (15 minutes to 1 hour) to reduce security risks from token leakage. However, this introduces a practical issue: after the AccessToken expires, the client requires the user to log in again each time to obtain a new token, resulting in a poor user experience. This article explains how to achieve seamless token renewal using a dual-token model β€” the division and collaboration of AccessToken and RefreshToken.

After reading this article, you will understand the core purpose and usage boundaries of RefreshToken, master the method of configuring a dual-token refresh mechanism in a front-end/back-end separation scenario, learn about secure storage solutions for RefreshToken, and grasp engineering practices for handling common issues such as expiration, concurrent refreshes, and rotation strategies.

2. Why Do We Need RefreshToken β€” The Division and Design Intent of the Dual-Token Model

2.1 Limitations of the Single-Token Model

If only one AccessToken is used for authentication, there are usually two options:

  • Short lifetime: The token expires in 15 minutes, requiring the user to log in again every 15 minutes, resulting in a very poor experience.
  • Long lifetime: The token is set to 7 days or longer. Once leaked, an attacker can gain authentication permissions for a long time, posing a significant security risk.

The single-token model struggles to balance security and user experience. Frequent re-login affects user retention, while an excessively long lifetime makes risk control difficult in operations.

2.2 Division of the Dual-Token Model

The dual-token model introduces two roles:

  • AccessToken (access token): Short-lived, typically 15 minutes to 1 hour. Carried in every API request for server-side authentication. Its impact is limited if leaked because of the short validity period.
  • RefreshToken (refresh token): Long-lived, typically 7 to 30 days. Only used in a specific refresh endpoint to obtain a new AccessToken. If leaked, an attacker can refresh tokens for a long period, so higher security is required for storage and transmission.

The core idea of this division is to separate the short-lived credential verified on every request from the long-lived credential used only during refresh, achieving seamless renewal while maintaining security.

2.3 Usage Boundaries of RefreshToken

It is important to clarify that RefreshToken cannot be used as a business authentication credential. It should only be used in the following scenarios:

  • The client sends the RefreshToken to the server via the /auth/refresh endpoint.
  • The server validates the RefreshToken, then issues a new AccessToken (and optionally a new RefreshToken).
  • The client uses the new AccessToken for subsequent requests.

No business endpoint should accept a RefreshToken in place of an AccessToken.

3. RefreshToken Generation and Data Structure Design

3.1 Generation Methods: Random String vs. JWT Format

There are two mainstream ways to generate RefreshTokens:

Method 1: Encrypted random string (recommended)
Generate a random string of at least 32 bytes using a strong random source like crypto.randomBytes(32). This method is unpredictable, non-reversible, and highly secure.

1
2
3
# Pseudo code example
import secrets
refresh_token = secrets.token_hex(32) # 64-character hex string

Method 2: JWT format
Issue the RefreshToken as a JWT as well, containing information such as userId, version number, and expiration time. The server can validate it statelessly, but once the JWT is leaked, an attacker can parse the payload to obtain information. In practice, random strings are preferred, with the server maintaining state for easier revocation and rotation control.

3.2 Server-side Storage Structure

Regardless of the method, the server typically needs to store associated information about the RefreshToken. A typical database table or Redis hash structure is as follows:

Field Type Description
user_id varchar User identifier
token_hash varchar SHA-256 hash of the RefreshToken (or plaintext)
expires_at datetime Expiration time
is_used boolean Whether it has been used (marked after rotation)
created_at datetime Creation time

If using Redis, TTL can be set to automatically expire tokens. The key point is that each refresh requires a lookup, hash verification, marking the old token as used, and inserting a new record.

3.3 Design Points

  • Length β‰₯ 32 bytes: It is recommended to use 64 bytes or more to reduce the probability of brute-force collisions.
  • Use a cryptographic random source: Do not use predictable sources like Math.random() or time().
  • Avoid predictability: Prohibit simple combinations like timestamp + auto-increment sequence.

4. Automatic AccessToken Refresh Mechanism β€” Complete Interaction Flow

4.1 Frontend Interception and Refresh Trigger

In a front-end/back-end separation architecture, the frontend typically uses an HTTP interceptor (e.g., Axios’s response.interceptors) to uniformly handle 401 status codes:

  1. The client makes an API request with the AccessToken.

  2. The server validates the AccessToken and returns 401 Unauthorized if it has expired.

  3. The frontend interceptor detects the 401 and determines if it is caused by AccessToken expiration (rather than insufficient permissions).

  4. If it is expiration, the frontend pauses all pending requests and calls the /auth/refresh endpoint with the RefreshToken.

  5. After the server validates the RefreshToken, it returns a new AccessToken (and a new RefreshToken).

  6. The frontend retries the original request with the new token and updates the local storage.

  7. If the RefreshToken is also expired or invalid, the server returns a 401, the frontend clears the local tokens, and redirects to the login page.

4.2 Server-side Validation and Issuance

Key steps in the server refresh logic:

  1. Receive the RefreshToken (in the request body or as an httpOnly Cookie).

  2. Look up the hash of the RefreshToken from storage, retrieve the associated userId, expiration time, and whether it has been used.

  3. Check if it has expired (current time > expires_at).

  4. Check if it has been used (is_used = true). If so, it may indicate a replay attack; it is recommended to invalidate all associated RefreshTokens for that user (since an attacker may have stolen the token).

  5. After validation, issue a new AccessToken (short-lived).

  6. Rotation: Discard the old RefreshToken (mark is_used = true or delete) and generate and return a new RefreshToken.

  7. If necessary, the server can bind fingerprint information (e.g., User-Agent, IP range) to the RefreshToken record for comparison during the next validation.

4.3 Flow Diagram

1
2
3
4
5
6
7
8
9
10
11
12
13
Client                              Server
| |
|--- Request (with AccessToken) -> | Validate AccessToken
|<---- 401 (expired) ------------ |
| |
|--- POST /auth/refresh ---------> | Validate RefreshToken
| (body: refreshToken) | Check for expiration/usage
| | Mark old RefreshToken as used
| | Generate new AccessToken + new RefreshToken
|<---- {accessToken, refreshToken} |
| |
|--- Retry original request (with new AccessToken) -> |
|<---- 200 OK ------------------- |

5. Practical Code: Spring Security JWT Refresh Token Configuration

The following uses Spring Boot + Spring Security as an example to illustrate the core implementation. Assume that JWT issuance and validation are already integrated in the project.

5.1 Storage Layer (Redis)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component
public class RefreshTokenRepository {
@Autowired
private RedisTemplate<String, Object> redisTemplate;

private static final String PREFIX = "refresh_token:";

public void save(String tokenHash, String userId, long ttlSeconds) {
String key = PREFIX + tokenHash;
redisTemplate.opsForValue().set(key, userId, ttlSeconds, TimeUnit.SECONDS);
}

public String getUserIdByTokenHash(String tokenHash) {
String key = PREFIX + tokenHash;
Object value = redisTemplate.opsForValue().get(key);
return value != null ? value.toString() : null;
}

public void delete(String tokenHash) {
String key = PREFIX + tokenHash;
redisTemplate.delete(key);
}
}

Redis TTL naturally handles expiration, and read/write speed is fast.

5.2 Refresh Service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Service
public class RefreshTokenService {
@Autowired
private RefreshTokenRepository repository;

@Value("${jwt.refresh-token-expiration:604800}") // 7 days
private long refreshTokenExpiration;

public String generateRefreshToken() {
byte[] bytes = new byte[64];
SecureRandom.getInstanceStrong().nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}

public Map<String, String> refresh(String oldRefreshToken) {
// 1. Compute hash, look up in Redis
String hash = SHA256.hash(oldRefreshToken);
String userId = repository.getUserIdByTokenHash(hash);
if (userId == null) {
throw new InvalidTokenException("RefreshToken invalid or expired");
}

// 2. Delete old Token
repository.delete(hash);

// 3. Generate new AccessToken and new RefreshToken
String newAccessToken = JwtUtils.createAccessToken(userId);
String newRefreshToken = generateRefreshToken();
String newHash = SHA256.hash(newRefreshToken);
repository.save(newHash, userId, refreshTokenExpiration);

return Map.of(
"accessToken", newAccessToken,
"refreshToken", newRefreshToken
);
}
}

Note: The RefreshToken is stored as a SHA-256 hash to avoid plaintext leakage. The delete followed by save implements rotation.

5.3 Security Configuration

In SecurityConfig, add the /auth/refresh path to the whitelist so that it does not require AccessToken validation:

1
2
3
4
http
.authorizeRequests()
.antMatchers("/auth/refresh").permitAll()
.anyRequest().authenticated()

Also configure CSRF protection; if the RefreshToken is sent via POST body, ensure that cross-site request forgery is restricted.

6. Practical Code: Node.js (Express + jsonwebtoken) Refresh Endpoint Implementation

6.1 Issuance and Storage

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const redis = require('redis');
const client = redis.createClient();

// Generate RefreshToken (random string)
function generateRefreshToken() {
return crypto.randomBytes(64).toString('hex'); // 128-character hex string
}

// Refresh route
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({ error: 'Missing refreshToken' });
}

// Look up in Redis
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
const userId = await client.get(`refresh:${hash}`);

if (!userId) {
return res.status(401).json({ error: 'RefreshToken invalid or expired' });
}

// Delete old Token (rotation)
await client.del(`refresh:${hash}`);

// Issue new tokens
const newAccessToken = jwt.sign({ userId }, ACCESS_SECRET, { expiresIn: '15m' });
const newRefreshToken = generateRefreshToken();
const newHash = crypto.createHash('sha256').update(newRefreshToken).digest('hex');
await client.setex(`refresh:${newHash}`, 7 * 24 * 60 * 60, userId); // 7 days

res.json({
accessToken: newAccessToken,
refreshToken: newRefreshToken
});
});

6.2 Middleware

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing AccessToken' });
}

const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, ACCESS_SECRET);
req.userId = decoded.userId;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'AccessToken expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'AccessToken invalid' });
}
}

The frontend interceptor triggers the refresh flow based on code: 'TOKEN_EXPIRED'.

7. RefreshToken Storage Security Solutions (Frontend + Backend)

7.1 Frontend Storage Comparison

Storage Method Risk Applicable Scenario
localStorage Vulnerable to XSS attacks; attackers can read Not recommended for storing RefreshToken
sessionStorage Also affected by XSS, but cleared when browser closes Can be used with timed refreshes, but still risky
httpOnly + Secure + SameSite=Strict Cookie Cannot be read by JavaScript; good XSS protection Recommended, especially when API and frontend are same-origin
In-memory variable (closure) + timed refresh No persistent storage; lost after session ends High-security scenarios, but requires frequent re-login

Suggestion: If frontend and backend share the same domain, use httpOnly Cookie to store the RefreshToken; the frontend does not need to handle it. For cross-origin scenarios, use a SameSite=None; Secure Cookie (requires HTTPS) or a framework-provided encapsulated solution.

7.2 Backend Storage Security

  • Store hash in Redis: Even if Redis is compromised, the plaintext RefreshToken cannot be directly used.
  • Regularly clean expired records: Redis TTL handles this automatically; for databases, scheduled tasks can delete expired rows.
  • Hash with salt: When computing the hash of the RefreshToken, append a server-side fixed salt (e.g., application secret) to mitigate rainbow table attacks.

7.3 Plaintext vs. Hash

Plaintext storage: Direct string comparison during validation, good performance. However, Redis/database access must be strictly ACL-controlled, and plaintext tokens should not appear in logs.

Hash storage: Adds an extra layer of security; even if storage is leaked, tokens cannot be directly used. The performance difference from computing the hash each time is negligible. Hash storage is recommended.

7.4 Rotation Strategy

After each refresh, the old RefreshToken must be immediately invalidated (deleted or marked as used). This is a key measure to prevent replay attacks. If an attacker simultaneously steals the old RefreshToken and the new AccessToken, but the new RefreshToken has already been rotated and the old one is invalidated, the attacker cannot perform a second refresh.

Important: If the server discovers that a RefreshToken has been used twice (i.e., during the second refresh, the token is already marked as used), it should be considered stolen. All RefreshTokens for that user should be invalidated and the user required to log in again.

8. Pitfall Log: Handling RefreshToken Expiration, Concurrent Refreshes, and Rotation Strategy

8.1 RefreshToken Expiration Handling

The RefreshToken itself has a validity period. If the user closes the browser and then reopens it within the validity period, the AccessToken may have expired but the RefreshToken is still valid. In this case, the frontend should perform a silent refresh once when starting up; the server will return a new AccessToken and RefreshToken.

A more elegant approach: before the RefreshToken expires (e.g., when 1 day remains), the frontend can proactively call an /auth/refresh/status endpoint to check whether a refresh is needed. However, in practice, most applications simply wait for a 401 to trigger the refresh, as the refresh endpoint is idempotent.

8.2 Concurrent Refresh Issue

When multiple API requests all return 401 simultaneously, the frontend may initiate multiple concurrent /auth/refresh requests. This can lead to:

  • The server receives duplicate refresh requests. The first request succeeds and rotates the RefreshToken; subsequent requests carry the old RefreshToken, which is now marked as used, so they fail.
  • Multiple failures cause the frontend to mistakenly conclude that the RefreshToken has also expired and navigate to the login page.

Solution: The frontend uses a pending promise to deduplicate β€” when the first 401 triggers a refresh, all subsequent 401s wait for the same refresh Promise to resolve, then retry.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
let refreshPromise = null;

api.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
if (!refreshPromise) {
refreshPromise = refreshToken().then(newToken => {
refreshPromise = null;
return newToken;
}).catch(() => {
refreshPromise = null;
localStorage.clear();
window.location.href = '/login';
});
}
const newToken = await refreshPromise;
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
return api(originalRequest);
}
return Promise.reject(error);
}
);

8.3 Rotation Strategy Pitfalls

If rotation only updates but does not delete the old RefreshToken, an attacker can hold both the old and new RefreshTokens, gaining double the validity window. Always delete or mark the old record immediately after generating a new RefreshToken.

Additionally, if the server uses a stateless JWT as the RefreshToken (i.e., the JWT itself carries expiration information without server-side storage), true rotation cannot be achieved. In that case, an attacker who holds both the old and new tokens can use either. It is recommended to always use server-side state records to control rotation.

8.4 Long-term Inactivity

If the user closes the browser for 7 days, the RefreshToken expires, requiring re-login. To allow users to maintain a long-term session, use a sliding session strategy: each refresh updates the RefreshToken expiration to current time + 7 days. However, note that this means the token never expires for as long as the user is active. For scenarios like large-screen displays, additional security measures (e.g., IP binding, device fingerprinting) should be combined.

9. Combining OAuth2 RefreshToken with JWT β€” Enterprise-Grade Extension

9.1 RefreshToken in OAuth2

In the OAuth2 authorization code flow, the Authorization Server returns:

  • access_token: Can be a JWT or opaque string.
  • refresh_token: Usually an opaque random string that requires server-side database lookup for validation. The OAuth2 refresh flow is similar to self-developed dual-token, but adds validation of client_id and client_secret.

9.2 Comparison: Self-developed Dual-Token vs. OAuth2

Aspect Self-developed Dual-Token OAuth2 (Authorization Code)
Use Case Single application, internal systems Multi-system SSO, third-party authorization
Refresh Validation Only validates RefreshToken Requires validation of client_id + client_secret + refresh_token
Rotation Requirement Recommended to enforce rotation Public clients require rotation; confidential clients optional
Stateless Can support (JWT), but rotation requires DB Does not support stateless

9.3 Integration Suggestions

  • For internal enterprise systems, a self-developed dual-token is sufficient and simple to implement.
  • To integrate with multiple external applications (e.g., third-party login, SSO gateway), use OAuth2 frameworks (e.g., Spring Authorization Server, Keycloak).

Note: If OAuth2 RefreshToken does not implement rotation, the old token remains valid as long as a refresh request is not sent. Public clients (e.g., pure frontend applications) are required to enforce rotation to mitigate leakage risk.

10. Summary and Further Exploration

10.1 Key Points Recap

  1. Division of AccessToken and RefreshToken: Short-lived AccessToken for business authentication; long-lived RefreshToken used only in the refresh endpoint.

  2. Rotation and Invalidation: After each refresh, the old RefreshToken must be discarded (marked as used or deleted) to prevent replay attacks.

  3. Secure Storage: Frontend stores RefreshToken in an httpOnly Cookie; backend stores its hash, with strict ACL on database/Redis access.

  4. Concurrency Handling: Frontend uses a pending promise to deduplicate refresh requests; backend can use optimistic locking or version numbers to prevent concurrent usage of the same token.

  5. Expiration Strategy: Set a reasonable validity period for RefreshToken (7–30 days), combined with sliding windows or proactive pre-refresh to enhance user experience.

10.2 Further Considerations

  • Bind device fingerprint: Store User-Agent, IP range, or device ID in the RefreshToken record. Compare during refresh; if they don’t match, require re-login. This reduces the risk of token theft across devices.

  • Device Grant flow: For non-browser clients (e.g., mobile apps, IoT devices), use the OAuth2 device authorization code flow instead of the dual-token model.

  • Performance Impact: Each refresh involves only one Redis query (or database lookup), which is less overhead than a full login (which may involve LDAP, password hashing, etc.). The dual-token model has almost no impact on backend performance.

  • Recommended Tools and Frameworks:

    • Spring Security: Built-in refresh_token extension, can be quickly implemented with OAuth2AuthorizationService.
  • Node.js: Lightweight implementation with express-jwt + redis, paired with helmet for enhanced security headers.

    • General: Keycloak, Auth0, and other services can directly configure RefreshToken behavior.

By properly using the dual-token model, you can achieve seamless session renewal without sacrificing security, improving the overall user experience of the system. The solutions provided in this article have been practiced in multiple production environments and can be adapted based on business needs.

Summary

Through this article, you should now have a deeper understanding of JWT. We recommend practicing with actual projects. If you have any questions, feel free to discuss!