Race condition pada refresh token paralel sering muncul sebagai bug yang tampak acak: pengguna tiba-tiba logout, refresh token baru langsung dianggap tidak valid, atau error hanya muncul sesekali saat jaringan lambat atau aplikasi mobile melakukan retry. Pada sistem Spring Boot yang memakai JWT access token dan refresh token yang disimpan di database, masalah ini biasanya bukan di JWT-nya, melainkan di state transition refresh token yang tidak aman terhadap konkurensi.
Jika dua request refresh masuk hampir bersamaan untuk token yang sama, keduanya bisa sama-sama lolos validasi awal, lalu sama-sama mencoba merotasi token. Hasilnya bisa berupa token lama tertandai revoked dua kali, token baru saling menimpa, atau response pertama mengembalikan token yang beberapa milidetik kemudian sudah dianggap tidak valid oleh request kedua. Artikel ini membahas kronologi investigasi, pola reproduksi, root cause, dan perbaikan yang realistis di Spring Boot/JPA.
Konteks Implementasi: JWT Access Token + Refresh Token di Database
Pola yang umum dipakai:
- Access token berumur pendek, diverifikasi secara stateless.
- Refresh token berumur lebih panjang dan disimpan di database agar bisa dirotasi, dicabut, atau divalidasi statusnya.
- Saat endpoint
/auth/refreshdipanggil, sistem akan:
- Mencari refresh token di database.
- Memastikan token belum expired dan belum revoked/used.
- Menandai token lama sebagai tidak aktif.
- Menerbitkan access token baru dan refresh token baru.
- Menyimpan refresh token baru ke database.
Di atas kertas alurnya benar. Masalah muncul ketika langkah 1-4 dijalankan oleh dua thread hampir bersamaan terhadap token yang sama.
Contoh Entitas Refresh Token
@Entity
@Table(name = "refresh_tokens")
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 255)
private String token;
@Column(nullable = false)
private Long userId;
@Column(nullable = false)
private Instant expiresAt;
@Column(nullable = false)
private boolean revoked;
@Column(nullable = false)
private boolean used;
@Version
private Long version;
public boolean isActive(Instant now) {
return !revoked && !used && expiresAt.isAfter(now);
}
public void markUsedAndRevoked() {
this.used = true;
this.revoked = true;
}
// getter/setter
}Kolom used, revoked, dan version penting untuk menangani rotasi dan konkurensi. Tanpa model state yang jelas, bug jadi sulit dianalisis.
Gejala Nyata di Produksi
Pada studi kasus ini, gejalanya tidak muncul sebagai error besar yang konsisten. Justru yang terlihat adalah kegagalan acak:
- User mobile tiba-tiba logout setelah aplikasi kembali dari background.
- Frontend menerima refresh token baru, tetapi request refresh berikutnya gagal dengan
invalid refresh token. - Sebagian request refresh sukses, sebagian lain gagal dengan status 401 atau 409.
- Log menunjukkan dua request refresh dengan user dan token yang sama dalam rentang waktu sangat dekat.
Biasanya ini dipicu oleh:
- Aplikasi mobile mengirim dua refresh request karena interceptor dan retry berjalan bersamaan.
- Tab browser ganda melakukan refresh hampir serempak.
- Gateway atau client melakukan retry otomatis saat timeout, padahal request pertama sebenarnya sedang diproses.
Kronologi Investigasi
1. Mulai dari Gejala, Bukan Asumsi
Bug semacam ini sering disangka masalah JWT signature, timezone expiry, atau cache. Padahal petunjuk kuatnya ada pada pola intermiten. Jika satu request bekerja normal dan request lain gagal acak untuk token yang sama, fokus utama harus ke konkurensi dan transaksi.
2. Tambahkan Logging yang Bisa Dikorelasikan
Log umum seperti refresh failed tidak cukup. Tambahkan informasi berikut:
requestIdatau correlation IDuserId- hash/token fingerprint, bukan token mentah
- thread name
- timestamp dengan resolusi milidetik
- state token sebelum dan sesudah update
Contoh log yang relevan:
INFO [req=3f2a] Refresh requested userId=42 tokenHash=8ab1c2 thread=http-nio-8080-exec-7
INFO [req=91dd] Refresh requested userId=42 tokenHash=8ab1c2 thread=http-nio-8080-exec-9
INFO [req=3f2a] Token loaded id=1001 revoked=false used=false version=5
INFO [req=91dd] Token loaded id=1001 revoked=false used=false version=5
INFO [req=3f2a] Old token marked used id=1001 version=6
INFO [req=91dd] Old token marked used id=1001 version=6
INFO [req=3f2a] New refresh token saved id=1002 parent=1001
INFO [req=91dd] New refresh token saved id=1003 parent=1001
WARN [req=3f2a] Client received refresh token that is no longer latest for sessionJika kedua request sama-sama membaca state awal revoked=false, used=false, itu sinyal kuat adanya race condition.
3. Periksa Alur Service yang Rentan
Implementasi yang rentan biasanya mirip seperti ini:
@Transactional
public TokenResponse refresh(String rawRefreshToken) {
RefreshToken token = refreshTokenRepository.findByToken(rawRefreshToken)
.orElseThrow(() -> new UnauthorizedException("Invalid refresh token"));
if (!token.isActive(Instant.now())) {
throw new UnauthorizedException("Refresh token is not active");
}
token.markUsedAndRevoked();
refreshTokenRepository.save(token);
RefreshToken newToken = new RefreshToken();
newToken.setToken(tokenGenerator.generateRefreshToken());
newToken.setUserId(token.getUserId());
newToken.setExpiresAt(Instant.now().plus(refreshTtl));
newToken.setRevoked(false);
newToken.setUsed(false);
refreshTokenRepository.save(newToken);
String newAccessToken = jwtService.generateAccessToken(token.getUserId());
return new TokenResponse(newAccessToken, newToken.getToken());
}Secara logika tampak benar, tetapi masih rentan. Dua transaksi terpisah bisa melakukan read-check-update pada data yang sama sebelum salah satunya commit.
Reproduksi Bug dengan Concurrent Test
Bug konkurensi sulit dibuktikan jika hanya diuji manual. Cara paling efektif adalah membuat test yang memaksa dua thread memanggil service refresh pada token yang sama secara hampir bersamaan.
Contoh Test Integrasi Sederhana
@SpringBootTest
class RefreshTokenRaceConditionTest {
@Autowired
private AuthService authService;
@Autowired
private RefreshTokenRepository refreshTokenRepository;
@Test
void shouldRevealRaceConditionWhenTwoRefreshRequestsRunInParallel() throws Exception {
String refreshToken = seedActiveRefreshTokenForUser(42L);
ExecutorService pool = Executors.newFixedThreadPool(2);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
Callable<Object> task = () -> {
ready.countDown();
start.await();
try {
return authService.refresh(refreshToken);
} catch (Exception e) {
return e;
}
};
Future<Object> f1 = pool.submit(task);
Future<Object> f2 = pool.submit(task);
ready.await();
start.countDown();
Object r1 = f1.get();
Object r2 = f2.get();
System.out.println(r1);
System.out.println(r2);
pool.shutdown();
}
private String seedActiveRefreshTokenForUser(Long userId) {
RefreshToken token = new RefreshToken();
token.setToken("rtok-initial");
token.setUserId(userId);
token.setExpiresAt(Instant.now().plusSeconds(3600));
token.setRevoked(false);
token.setUsed(false);
refreshTokenRepository.save(token);
return token.getToken();
}
}Test ini tidak selalu gagal pada setiap run, tetapi justru itu mencerminkan bug aslinya: intermiten. Agar reproduksi lebih konsisten, developer sering menambahkan jeda kecil sementara di service, misalnya sesudah baca token tetapi sebelum update, untuk memperlebar jendela race.
Untuk debugging lokal, jeda buatan seperti
Thread.sleep(100)di titik kritis kadang berguna. Jangan biarkan kode ini masuk ke production branch.
Root Cause: Read-Check-Update Tidak Aman terhadap Konkurensi
Masalah intinya biasanya ada pada pola berikut:
- Thread A membaca token lama: masih aktif.
- Thread B membaca token lama: masih aktif.
- Thread A menandai token lama sebagai used/revoked, lalu membuat token baru A.
- Thread B, yang sudah telanjur lolos validasi awal, juga menandai token lama dan membuat token baru B.
Ada beberapa variasi kerusakan yang mungkin terjadi:
- Double rotation: dua token baru sah tercipta dari satu token lama.
- Last write wins: state sesi atau pointer ke token aktif ditimpa request kedua.
- Stale response: client menerima refresh token dari request pertama, tetapi server hanya menganggap token hasil request kedua sebagai token aktif terakhir.
- Optimistic lock exception yang tidak ditangani dengan benar lalu diterjemahkan menjadi logout.
Jika ada tabel sesi atau kolom seperti current_refresh_token_id pada user/session, masalah bisa lebih jelas: dua transaksi memodifikasi referensi token aktif yang sama, dan hasil akhirnya bergantung pada urutan commit.
Perbaikan yang Bisa Diterapkan
1. Optimistic Locking untuk Mendeteksi Update Balapan
Jika throughput tinggi dan konflik tidak terlalu sering, optimistic locking adalah pilihan yang masuk akal. Tambahkan @Version pada entitas, lalu tangani konflik update sebagai kondisi bisnis, bukan error generik.
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByToken(String token);
}@Transactional
public TokenResponse refresh(String rawRefreshToken) {
RefreshToken token = refreshTokenRepository.findByToken(rawRefreshToken)
.orElseThrow(() -> new UnauthorizedException("Invalid refresh token"));
if (!token.isActive(Instant.now())) {
throw new UnauthorizedException("Refresh token is not active");
}
token.markUsedAndRevoked();
refreshTokenRepository.saveAndFlush(token);
RefreshToken newToken = new RefreshToken();
newToken.setToken(tokenGenerator.generateRefreshToken());
newToken.setUserId(token.getUserId());
newToken.setExpiresAt(Instant.now().plus(refreshTtl));
newToken.setRevoked(false);
newToken.setUsed(false);
refreshTokenRepository.save(newToken);
return new TokenResponse(
jwtService.generateAccessToken(token.getUserId()),
newToken.getToken()
);
}Jika dua transaksi mengubah row yang sama, salah satunya akan gagal saat flush/commit dengan konflik versi. Keuntungannya:
- Tidak mengunci row sejak awal.
- Cocok jika bentrokan jarang.
Kekurangannya:
- Developer wajib menangani exception dengan benar.
- Tanpa desain retry/idempotency yang baik, client tetap bisa menerima pengalaman logout acak.
Menangani Konflik Secara Aman
Jangan langsung memetakan konflik versi menjadi 500. Untuk endpoint refresh token, lebih aman mengubahnya menjadi respons yang jelas, misalnya 409 Conflict atau hasil idempoten jika memang request ganda dari client yang sama.
@Transactional
public TokenResponse refresh(String rawRefreshToken) {
try {
return doRefresh(rawRefreshToken);
} catch (ObjectOptimisticLockingFailureException ex) {
throw new ConflictException("Refresh token already rotated by another request");
}
}2. Pessimistic Locking untuk Menjamin Satu Pemroses per Token
Jika refresh token benar-benar harus diproses satu per satu dan konflik cukup sering, pessimistic locking lebih tegas. Ambil row token dengan PESSIMISTIC_WRITE agar request kedua menunggu atau gagal, tergantung konfigurasi database.
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select t from RefreshToken t where t.token = :token")
Optional<RefreshToken> findByTokenForUpdate(@Param("token") String token);
}@Transactional
public TokenResponse refresh(String rawRefreshToken) {
RefreshToken token = refreshTokenRepository.findByTokenForUpdate(rawRefreshToken)
.orElseThrow(() -> new UnauthorizedException("Invalid refresh token"));
if (!token.isActive(Instant.now())) {
throw new UnauthorizedException("Refresh token is not active");
}
token.markUsedAndRevoked();
RefreshToken newToken = new RefreshToken();
newToken.setToken(tokenGenerator.generateRefreshToken());
newToken.setUserId(token.getUserId());
newToken.setExpiresAt(Instant.now().plus(refreshTtl));
newToken.setRevoked(false);
newToken.setUsed(false);
refreshTokenRepository.save(newToken);
return new TokenResponse(
jwtService.generateAccessToken(token.getUserId()),
newToken.getToken()
);
}Kelebihan:
- Lebih mudah dipahami: satu token lama hanya bisa diproses satu transaksi pada satu waktu.
- Mengurangi kemungkinan double rotation.
Kekurangan:
- Bisa menambah latency.
- Perlu hati-hati terhadap deadlock jika transaksi menyentuh resource lain dalam urutan berbeda.
- Perilaku detail bergantung pada database dan isolation/lock timeout.
Kapan Memilih Optimistic vs Pessimistic?
- Pilih optimistic locking jika konflik jarang, throughput penting, dan Anda siap menangani konflik secara eksplisit.
- Pilih pessimistic locking jika konsistensi satu-token-satu-rotasi lebih penting daripada paralelisme, atau bug sudah terbukti sering dipicu oleh retry simultan.
3. Idempotency untuk Retry yang Aman
Locking saja belum tentu cukup. Dalam dunia nyata, request refresh sering diulang oleh client karena timeout, reconnect, atau retry interceptor. Jika request kedua sebenarnya adalah duplikasi request pertama, pendekatan terbaik adalah idempotent refresh.
Salah satu caranya:
- Setiap request refresh membawa
idempotencyKeyunik dari client. - Server menyimpan hasil refresh berdasarkan kombinasi
oldRefreshToken + idempotencyKey. - Jika request identik datang lagi, server mengembalikan hasil yang sama, bukan merotasi ulang.
Ini sangat membantu untuk mencegah stale response saat client mengirim ulang request yang sama.
Trade-off-nya:
- Perlu penyimpanan tambahan untuk hasil request idempoten.
- Perlu kebijakan TTL agar data tidak menumpuk.
- Client harus konsisten mengirim key yang sama saat retry.
4. Rotasi Token yang Aman
Rotasi aman bukan hanya “buat token baru lalu revoke token lama”. Anda perlu memastikan relasi antar token jelas dan state transisinya atomik.
Praktik yang umum dipakai:
- Token lama punya state final:
used=truedanrevoked=true. - Token baru menyimpan referensi
replacedByTokenIdatauparentTokenIdjika ingin audit chain rotasi. - Jika token lama yang sudah dipakai dipresentasikan lagi, itu diperlakukan sebagai reuse dan bisa memicu pencabutan sesi.
Namun hati-hati: kebijakan “deteksi reuse lalu revoke semua sesi” bisa terlalu agresif jika sistem Anda sendiri masih rentan terhadap request paralel yang sah. Perbaiki konkurensinya dulu sebelum mengaktifkan penalti keamanan yang keras.
5. Validasi Retry-Safe
Validasi refresh token harus tahan terhadap retry normal, bukan hanya benar secara teoritis.
Contohnya:
- Jika request kedua datang setelah request pertama sukses, jangan selalu balas 401 generik. Pertimbangkan 409 Conflict atau respons khusus yang memberi sinyal bahwa token sudah diputar.
- Jika Anda menyimpan hasil rotasi terbaru untuk request idempoten, request duplikat bisa menerima token yang sama dengan request pertama.
- Pastikan client tahu kapan harus retry dan kapan harus memaksa login ulang.
Contoh Desain Service yang Lebih Aman
Berikut contoh sederhana dengan pessimistic locking dan state check yang eksplisit. Tujuannya bukan menjadi template final untuk semua sistem, tetapi menunjukkan titik kritis yang harus dijaga atomik.
@Service
public class AuthService {
private final RefreshTokenRepository refreshTokenRepository;
private final JwtService jwtService;
private final TokenGenerator tokenGenerator;
private final Duration refreshTtl;
public AuthService(
RefreshTokenRepository refreshTokenRepository,
JwtService jwtService,
TokenGenerator tokenGenerator,
Duration refreshTtl) {
this.refreshTokenRepository = refreshTokenRepository;
this.jwtService = jwtService;
this.tokenGenerator = tokenGenerator;
this.refreshTtl = refreshTtl;
}
@Transactional
public TokenResponse refresh(String rawRefreshToken) {
Instant now = Instant.now();
RefreshToken current = refreshTokenRepository.findByTokenForUpdate(rawRefreshToken)
.orElseThrow(() -> new UnauthorizedException("Invalid refresh token"));
if (current.isExpired(now)) {
throw new UnauthorizedException("Refresh token expired");
}
if (current.isRevoked() || current.isUsed()) {
throw new ConflictException("Refresh token already used or rotated");
}
current.markUsedAndRevoked();
RefreshToken next = new RefreshToken();
next.setToken(tokenGenerator.generateRefreshToken());
next.setUserId(current.getUserId());
next.setExpiresAt(now.plus(refreshTtl));
next.setRevoked(false);
next.setUsed(false);
refreshTokenRepository.save(next);
String accessToken = jwtService.generateAccessToken(current.getUserId());
return new TokenResponse(accessToken, next.getToken());
}
}Poin penting dari contoh di atas:
- Pengambilan token dilakukan dengan lock.
- Validasi status dilakukan setelah lock diperoleh, bukan sebelumnya di luar transaksi.
- Perubahan state token lama dan pembuatan token baru terjadi dalam satu transaksi.
Langkah Verifikasi Setelah Fix
Setelah perbaikan diterapkan, jangan berhenti di “test sudah hijau”. Verifikasi harus mencakup kasus paralel dan perilaku client.
1. Ulangi Concurrent Test
Jalankan test paralel berkali-kali. Hasil yang diharapkan biasanya salah satu dari dua pola berikut:
- Satu request sukses, satu gagal terkontrol dengan 409/401 yang konsisten.
- Keduanya menerima hasil yang sama jika Anda menerapkan idempotency.
Yang tidak boleh terjadi:
- Dua refresh token baru aktif dari satu token lama.
- Request sukses mengembalikan token yang tidak bisa dipakai beberapa saat kemudian karena tertimpa rotasi lain.
2. Periksa State Database
Sesudah dua request paralel, cek database:
- Hanya ada satu token baru aktif.
- Token lama berstatus used/revoked satu kali secara konsisten.
- Tidak ada chain token bercabang untuk parent yang sama, kecuali memang didesain demikian.
3. Uji Retry dari Client
Simulasikan timeout atau duplicate submit dari frontend/mobile. Pastikan perilaku API terdokumentasi jelas:
- Kapan client boleh retry.
- Kapan client harus memakai token hasil pertama.
- Kapan harus menampilkan login ulang.
4. Tambahkan Metrics dan Alert
Beberapa metrik yang berguna:
- Jumlah refresh per user/session dalam interval pendek.
- Jumlah konflik refresh token.
- Jumlah token reuse detection.
- Rasio refresh gagal setelah access token expired.
Lonjakan metrik ini sering menjadi sinyal bahwa concurrency bug belum sepenuhnya selesai atau client masih mengirim request ganda.
Kesalahan Umum Saat Memperbaiki Bug Ini
- Hanya menambah synchronized di service. Ini tidak membantu di deployment multi-instance.
- Mengandalkan cache lokal untuk menahan request ganda. Ini rapuh jika ada lebih dari satu node aplikasi.
- Menangani semua konflik sebagai 401. Client jadi sulit membedakan token benar-benar invalid vs bentrok request paralel.
- Tidak memikirkan retry. Sistem backend sudah “benar”, tetapi UX tetap buruk karena client mendapat logout acak.
- Logging token mentah. Gunakan fingerprint/hash agar aman untuk debugging.
Checklist Pencegahan agar Bug Serupa Tidak Terulang
- Definisikan state refresh token dengan jelas: aktif, used, revoked, expired.
- Pastikan operasi validasi dan rotasi berjalan atomik dalam satu transaksi.
- Pilih strategi konkurensi yang sesuai: optimistic atau pessimistic locking.
- Tambahkan
@Versionjika memakai optimistic locking. - Gunakan query lock jika membutuhkan serialisasi akses pada token yang sama.
- Desain endpoint refresh agar aman terhadap retry dan duplicate request.
- Pertimbangkan idempotency key untuk client mobile/web yang rawan retry.
- Tulis concurrent test, bukan hanya unit test biasa.
- Tambahkan log korelatif: requestId, userId, token fingerprint, dan state token.
- Monitoring konflik refresh di produksi, bukan hanya error 500.
- Jangan pakai token mentah di log atau dashboard observability.
- Dokumentasikan kontrak API refresh untuk frontend dan mobile team.
Penutup
Bug Spring Boot: debug race condition pada refresh token paralel biasanya tidak berasal dari JWT itu sendiri, melainkan dari update state refresh token yang tidak aman ketika dua request berjalan bersamaan. Gejala seperti user logout mendadak, token baru dianggap tidak valid, dan error intermiten adalah pola khas dari read-check-update race.
Solusi praktisnya adalah memperlakukan refresh token sebagai resource stateful yang harus dilindungi: gunakan transaksi yang tepat, locking yang sesuai, rotasi token yang atomik, dan jika perlu idempotency agar retry client tidak berubah menjadi logout acak. Setelah diperbaiki, verifikasi dengan concurrent test dan observability yang memadai. Tanpa itu, bug ini mudah hilang sementara lalu kembali muncul di traffic nyata.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!