Working with Refresh Tokens
1. Understanding Refresh Token Flow
Login → {access_token (15m), refresh_token (30d)}
│
Access expires → POST /token (grant_type=refresh_token)
↓
{new access_token, new refresh_token} (rotation)
│
Old refresh_token marked revoked, family tracked
2. Generating Refresh Tokens
| Property | Value |
|---|---|
| Format | Opaque random string (NOT JWT) |
| Entropy | ≥256 bits (CSPRNG, 32 bytes) |
| Encoding | Base64URL |
| Storage at issue | SHA-256 hash in DB (never plaintext) |
3. Storing Refresh Tokens Securely
| Side | Approach |
|---|---|
| Server (DB) | Store sha256(token) + userId + familyId + expiresAt + revokedAt |
| Browser | HttpOnly, Secure, SameSite=Strict cookie scoped to /oauth/token |
| Mobile | iOS Keychain / Android EncryptedSharedPreferences |
| Desktop | OS keyring (libsecret, Credential Manager) |
4. Implementing Token Refresh Endpoint
Example: OAuth-compliant refresh
@PostMapping(value = "/oauth/token", consumes = APPLICATION_FORM_URLENCODED_VALUE)
public TokenResponse refresh(@RequestParam String grant_type,
@RequestParam String refresh_token) {
if (!"refresh_token".equals(grant_type)) throw new InvalidGrant();
var stored = refreshRepo.findByTokenHash(sha256(refresh_token))
.orElseThrow(InvalidGrant::new);
if (stored.isRevoked()) { // reuse detection
refreshRepo.revokeFamily(stored.getFamilyId());
alertingService.notifyReuse(stored.getUserId());
throw new InvalidGrant();
}
if (stored.getExpiresAt().isBefore(Instant.now())) throw new InvalidGrant();
refreshRepo.revoke(stored.getId());
var newRefresh = issueRefreshToken(stored.getUserId(), stored.getFamilyId());
var access = jwtService.issueAccess(stored.getUserId(), stored.getScope());
return new TokenResponse(access, newRefresh, "Bearer", 900);
}
5. Validating Refresh Tokens
| Check | Failure |
|---|---|
| Hash exists | invalid_grant |
| Not revoked | invalid_grant + revoke family |
| Not expired | invalid_grant |
| Client ID matches | invalid_client |
| User still active | invalid_grant |
6. Rotating Refresh Tokens
| Aspect | Detail |
|---|---|
| When | Every successful refresh |
| Old token | Mark revoked, keep row for reuse detection (until family expiry) |
| Family ID | Same across rotations, regenerated on full re-auth |
| Grace window | 10–30 sec to handle network races |
7. Revoking Refresh Tokens
| Trigger | Scope |
|---|---|
| Logout | Single token |
| Reuse detected | Entire family |
| Password change | All user tokens |
| Admin force-logout | All user tokens |
| Suspected compromise | All sessions + force re-auth |
8. Setting Refresh Token Expiration
| App Type | Lifetime |
|---|---|
| Web (BFF cookie) | 1–7 days |
| Mobile | 30–90 days, sliding |
| Sensitive (finance) | 1 day max |
| Sliding vs absolute | Both: sliding inactivity (7d) + absolute cap (90d) |
9. Implementing Refresh Token Reuse Detection
Reuse Detection Flow
- Issue token T1 (family F)
- Client refreshes: T1 → T2 (T1 marked revoked, retained)
- Attacker replays T1 → server sees revoked token in family F
- Revoke entire family F (T1, T2, ...)
- Alert user + force re-authentication
10. Handling Refresh Token Errors
| OAuth Error | Meaning | Client Action |
|---|---|---|
| invalid_grant | Expired, revoked, or unknown | Redirect to login |
| invalid_client | Bad client credentials | Check config |
| invalid_request | Malformed body | Fix request |
| unauthorized_client | Client not allowed grant type | Register correct grant |
| 429 | Rate limit | Backoff per Retry-After |