Refresh Token Stratejisi
Access token kısa ömürlü (15-60 dk) olmalıdır — çalınırsa hasar penceresi küçük olur. Ama kullanıcıdan her 15 dakikada tekrar giriş yapmasını beklemek korkunç bir kullanıcı deneyimidir. Refresh token bu sorunu çözer: kullanıcı farkında olmadan, arka planda, sessizce yeni access token alır.
Access Token vs Refresh Token
| Özellik | Access Token | Refresh Token |
|---|---|---|
| Amaç | API'ye erişim | Yeni access token almak |
| Ömür | Kısa (15-60 dakika) | Uzun (7-30 gün) |
| Nerede saklanır | Memory / localStorage | HttpOnly cookie / DB |
| Her istekte gönderilir mi | Evet (Authorization header) | Hayır (sadece refresh endpoint) |
| Çalınma riski | Yüksek (sık gönderilir) | Düşük (nadir gönderilir) |
| Revoke edilebilir mi | Zor (stateless JWT) | Kolay (DB'den sil) |
| İçerik | User ID, roller, expiry | Opaque string veya JWT |
Token Lifecycle (Yaşam Döngüsü)
┌─────────────────── TOKEN LİFECYCLE ───────────────────┐
│ │
│ 1. LOGIN │
│ Client → POST /api/auth/login {email, password} │
│ Server → {accessToken, refreshToken, expiresIn} │
│ │
│ 2. API İSTEKLERİ (15 dakika boyunca) │
│ Client → GET /api/users │
│ [Authorization: Bearer <accessToken>] │
│ Server → 200 OK {data...} │
│ │
│ 3. ACCESS TOKEN EXPIRED (15 dk sonra) │
│ Client → GET /api/users [Bearer <expiredToken>] │
│ Server → 401 Unauthorized │
│ │
│ 4. REFRESH (sessizce, arka planda) │
│ Client → POST /api/auth/refresh │
│ {refreshToken: "abc123..."} │
│ Server → {accessToken: "YENİ...", │
│ refreshToken: "YENİ...", │
│ expiresIn: 900} │
│ (Eski refresh token iptal edilir — rotation) │
│ │
│ 5. API İSTEKLERİ DEVAM (yeni token ile) │
│ Client → GET /api/users [Bearer <yeniToken>] │
│ Server → 200 OK {data...} │
│ │
│ 6. REFRESH TOKEN EXPIRED (7-30 gün sonra) │
│ Client → POST /api/auth/refresh {expiredRefresh} │
│ Server → 401 — kullanıcı tekrar login olmalı │
│ │
└────────────────────────────────────────────────────────┘Refresh Token Entity
Refresh token veritabanında saklanır — bu sayede revoke edilebilir (JWT access token'dan farkı budur):
@Entity
@Table(name = "refresh_tokens", indexes = {
@Index(name = "idx_refresh_token", columnList = "token"),
@Index(name = "idx_refresh_user", columnList = "user_id")
})
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false, length = 500)
private String token;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(nullable = false)
private Instant expiryDate;
@Column(nullable = false)
private boolean revoked = false;
@Column(nullable = false)
private Instant createdAt;
private String userAgent; // Hangi cihazdan oluşturuldu
private String ipAddress; // Hangi IP'den
// Hangi token ailesine ait (rotation tracking)
@Column(nullable = false)
private String family;
public boolean isExpired() {
return Instant.now().isAfter(expiryDate);
}
public boolean isUsable() {
return !revoked && !isExpired();
}
}RefreshTokenRepository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByToken(String token);
// Kullanıcının tüm tokenlarını bul (logout everywhere)
List<RefreshToken> findByUserAndRevokedFalse(User user);
// Aynı ailedeki tüm tokenları bul (rotation chain)
List<RefreshToken> findByFamily(String family);
// Süresi dolmuş tokenları temizle
@Modifying
@Query("DELETE FROM RefreshToken rt WHERE rt.expiryDate < :now")
int deleteExpiredTokens(@Param("now") Instant now);
// Kullanıcının tüm tokenlarını iptal et
@Modifying
@Query("UPDATE RefreshToken rt SET rt.revoked = true WHERE rt.user = :user")
int revokeAllByUser(@Param("user") User user);
}RefreshTokenService — Tam Implementasyon
@Service
@RequiredArgsConstructor
@Transactional
public class RefreshTokenService {
private final RefreshTokenRepository refreshTokenRepository;
private final JwtProperties jwtProperties;
/**
* Yeni refresh token oluşturur.
* Family: rotation chain takibi için benzersiz ID.
*/
public RefreshToken createRefreshToken(User user, HttpServletRequest request) {
String family = UUID.randomUUID().toString();
return createTokenInFamily(user, family, request);
}
/**
* Refresh token kullanarak yeni token çifti oluşturur (rotation).
* Eski token revoke edilir, aynı family'de yeni token oluşturulur.
*/
public RefreshToken rotateRefreshToken(String oldTokenStr, HttpServletRequest request) {
RefreshToken oldToken = refreshTokenRepository.findByToken(oldTokenStr)
.orElseThrow(() -> new TokenNotFoundException("Refresh token bulunamadı"));
// Token zaten revoke edilmişse → muhtemelen çalınmış!
if (oldToken.isRevoked()) {
// Tüm family'yi iptal et (reuse detection)
revokeTokenFamily(oldToken.getFamily());
throw new TokenReusedException(
"Token reuse tespit edildi! Tüm oturumlar kapatıldı.");
}
// Token expired mı?
if (oldToken.isExpired()) {
throw new TokenExpiredException("Refresh token süresi dolmuş");
}
// Eski token'ı revoke et
oldToken.setRevoked(true);
refreshTokenRepository.save(oldToken);
// Aynı family'de yeni token oluştur
return createTokenInFamily(oldToken.getUser(), oldToken.getFamily(), request);
}
/**
* Belirli bir family'deki yeni token oluşturur.
*/
private RefreshToken createTokenInFamily(
User user, String family, HttpServletRequest request) {
RefreshToken token = new RefreshToken();
token.setToken(generateSecureToken());
token.setUser(user);
token.setFamily(family);
token.setExpiryDate(Instant.now().plusMillis(
jwtProperties.getRefreshTokenExpiration()));
token.setCreatedAt(Instant.now());
token.setRevoked(false);
token.setUserAgent(request.getHeader("User-Agent"));
token.setIpAddress(getClientIp(request));
return refreshTokenRepository.save(token);
}
/**
* Güvenli rastgele token üretir (opaque — JWT değil).
*/
private String generateSecureToken() {
byte[] randomBytes = new byte[64]; // 512-bit
new SecureRandom().nextBytes(randomBytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
}
/**
* Kullanıcının tüm refresh token'larını iptal et (logout everywhere).
*/
public void revokeAllUserTokens(User user) {
refreshTokenRepository.revokeAllByUser(user);
}
/**
* Bir token family'sindeki tüm tokenları iptal et.
* Reuse detection: çalınmış token kullanıldığında tüm chain iptal olur.
*/
private void revokeTokenFamily(String family) {
List<RefreshToken> familyTokens = refreshTokenRepository.findByFamily(family);
familyTokens.forEach(t -> t.setRevoked(true));
refreshTokenRepository.saveAll(familyTokens);
}
/**
* Süresi dolmuş tokenları temizle (scheduled job ile çağır).
*/
public int cleanupExpiredTokens() {
return refreshTokenRepository.deleteExpiredTokens(Instant.now());
}
private String getClientIp(HttpServletRequest request) {
String xForwardedFor = request.getHeader("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
return xForwardedFor.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}Token Rotation (Döndürme) Neden Önemli?
Token rotation olmadan: saldırgan refresh token'ı çalarsa, token expire olana kadar (7-30 gün!) sınırsız access token üretebilir.
Token rotation ile: her kullanımda eski token geçersiz olur. Saldırgan çalıntı token'ı kullanırsa → gerçek kullanıcı da kullandığında reuse detection tetiklenir → tüm token family iptal olur.
Senaryo — Token Çalınma (Rotation ile):
1. Kullanıcı login → RefreshToken-A (family: xyz)
2. Saldırgan RefreshToken-A'yı çalar
3. Kullanıcı normal refresh yapar:
RefreshToken-A revoked → RefreshToken-B oluşturulur (family: xyz)
4. Saldırgan RefreshToken-A ile refresh dener:
RefreshToken-A zaten revoked! → REUSE DETECTION
→ Family "xyz" tüm tokenları iptal → RefreshToken-B de iptal
→ Kullanıcı tekrar login olmak zorunda (güvenli)AuthController — Refresh Endpoint
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final JwtService jwtService;
private final RefreshTokenService refreshTokenService;
private final UserDetailsService userDetailsService;
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(
@Valid @RequestBody RefreshRequest request,
HttpServletRequest httpRequest) {
// 1. Refresh token'ı rotate et (eski iptal, yeni oluştur)
RefreshToken newRefreshToken = refreshTokenService
.rotateRefreshToken(request.refreshToken(), httpRequest);
// 2. Kullanıcı için yeni access token oluştur
UserDetails userDetails = userDetailsService
.loadUserByUsername(newRefreshToken.getUser().getEmail());
String accessToken = jwtService.generateAccessToken(userDetails);
return ResponseEntity.ok(new AuthResponse(
accessToken,
newRefreshToken.getToken()
));
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(@AuthenticationPrincipal UserDetails userDetails) {
// Kullanıcının tüm refresh token'larını iptal et
User user = userService.findByEmail(userDetails.getUsername());
refreshTokenService.revokeAllUserTokens(user);
return ResponseEntity.noContent().build();
}
}
public record RefreshRequest(@NotBlank String refreshToken) {}Token Storage Stratejileri (Client Tarafı)
| Strateji | Güvenlik | XSS | CSRF | Kullanım |
|---|---|---|---|---|
| localStorage | Düşük | ❌ Savunmasız | ✅ Güvenli | SPA (React, Vue) |
| sessionStorage | Orta | ❌ Savunmasız | ✅ Güvenli | Tek sekme uygulamalar |
| HttpOnly Cookie | Yüksek | ✅ Güvenli | ❌ CSRF riski | Geleneksel web |
| Memory (JS variable) | En yüksek | ✅ Güvenli | ✅ Güvenli | Modern SPA |
Önerilen strateji — Memory + HttpOnly Cookie:
Access token → JavaScript memory'de (closure/variable) — XSS'e dayanıklı
Refresh token → HttpOnly, Secure, SameSite=Strict cookie — XSS'e dayanıklı
// Server tarafı: Refresh token'ı HttpOnly cookie olarak gönder
@PostMapping("/login")
public ResponseEntity<AccessTokenResponse> login(
@RequestBody LoginRequest request,
HttpServletResponse response,
HttpServletRequest httpRequest) {
// ... authentication ...
String accessToken = jwtService.generateAccessToken(userDetails);
RefreshToken refreshToken = refreshTokenService
.createRefreshToken(user, httpRequest);
// Refresh token'ı HttpOnly cookie olarak set et
ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken.getToken())
.httpOnly(true) // JavaScript erişemez → XSS koruması
.secure(true) // Sadece HTTPS
.sameSite("Strict") // Cross-site isteklerde gönderilmez
.path("/api/auth") // Sadece auth endpoint'lerine gönderilir
.maxAge(Duration.ofDays(7))
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
// Access token response body'de döner (client memory'de tutar)
return ResponseEntity.ok(new AccessTokenResponse(accessToken, 900));
}Sliding Session vs Fixed Expiry
Fixed Expiry (Sabit Süre):
Refresh token 7 gün sonra kesinlikle expire olur
Kullanıcı her hafta tekrar login olmalı
Daha güvenli — maksimum session süresi garanti
Sliding Session (Kayan Süre):
Her refresh işleminde yeni refresh token'ın expiry'si sıfırlanır
Aktif kullanıcı hiç login olmak zorunda kalmaz
Daha iyi UX ama güvenlik riski: çalınan token sonsuza dek kullanılabilir
Hybrid Yaklaşım (Önerilen):
private RefreshToken createTokenInFamily(User user, String family, ...) {
RefreshToken token = new RefreshToken();
token.setToken(generateSecureToken());
// Sliding: her rotation'da 7 gün daha
token.setExpiryDate(Instant.now().plusMillis(refreshTokenExpiration));
// Absolute maximum: family oluşturulma tarihinden 30 gün
token.setAbsoluteExpiry(Instant.now().plusDays(30));
// Validate ederken her ikisini de kontrol et
token.setFamily(family);
// ...
}
public boolean isUsable() {
return !revoked
&& !Instant.now().isAfter(expiryDate) // Sliding expiry
&& !Instant.now().isAfter(absoluteExpiry); // Hard limit
}Database vs Redis Token Storage
Database (PostgreSQL, MySQL):
✅ Durable — sunucu restart'ında kaybolmaz
✅ ACID garantisi — tutarlılık
✅ Mevcut altyapı — ek setup gerektirmez
❌ Her refresh'te DB sorgusu — yavaş olabilir
Uygun: Çoğu uygulama, düşük-orta trafik
Redis (In-Memory Cache):
✅ Çok hızlı okuma/yazma (~1ms)
✅ TTL desteği — otomatik temizlik
✅ Yüksek trafik kaldırır
❌ Ek altyapı — Redis sunucusu gerekir
❌ Varsayılan olarak volatile — diske yazma konfigüre edilmeli
Uygun: Yüksek trafik, microservices
// Redis ile token storage örneği
@Service
@RequiredArgsConstructor
public class RedisRefreshTokenService {
private final StringRedisTemplate redisTemplate;
private static final String TOKEN_PREFIX = "refresh_token:";
private static final String USER_PREFIX = "user_tokens:";
public void saveToken(String token, Long userId, Duration ttl) {
// Token → userId mapping
redisTemplate.opsForValue()
.set(TOKEN_PREFIX + token, userId.toString(), ttl);
// userId → token set (tüm tokenları listelemek için)
redisTemplate.opsForSet()
.add(USER_PREFIX + userId, token);
}
public Optional<Long> validateToken(String token) {
String userId = redisTemplate.opsForValue()
.get(TOKEN_PREFIX + token);
return Optional.ofNullable(userId).map(Long::parseLong);
}
public void revokeToken(String token) {
redisTemplate.delete(TOKEN_PREFIX + token);
}
public void revokeAllUserTokens(Long userId) {
Set<String> tokens = redisTemplate.opsForSet()
.members(USER_PREFIX + userId);
if (tokens != null) {
tokens.forEach(t -> redisTemplate.delete(TOKEN_PREFIX + t));
}
redisTemplate.delete(USER_PREFIX + userId);
}
}Scheduled Token Cleanup
Süresi dolmuş tokenları düzenli olarak temizleyin:
@Component
@RequiredArgsConstructor
public class TokenCleanupScheduler {
private final RefreshTokenService refreshTokenService;
@Scheduled(cron = "0 0 3 * * *") // Her gece saat 3'te
public void cleanupExpiredTokens() {
int deleted = refreshTokenService.cleanupExpiredTokens();
log.info("Temizlenen expired refresh token sayısı: {}", deleted);
}
}⚠️ Token Rotation: Her refresh işleminde eski refresh token'ı revoke edin, yenisini üretin. Reuse detection ile çalınmış token tespit edildiğinde tüm token family'si iptal edilir — bu kritik bir güvenlik önlemidir.
💡 Özet: Access token kısa (15-60 dk), refresh token uzun (7-30 gün) ömürlü olmalı. Refresh token veritabanında veya Redis'te saklanır. Token rotation ile her kullanımda eski token iptal edilir. Reuse detection çalıntı token'ları tespit eder. Client'ta access token memory'de, refresh token HttpOnly cookie'de saklanmalı. Sliding session + absolute expiry hybrid yaklaşımı hem güvenlik hem UX sağlar.
AI Asistan
Sorularını yanıtlamaya hazır