OAuth Refresh Token Race Condition adalah masalah nyata pada integrasi backend ke API pihak ketiga: beberapa request paralel mendeteksi access token kedaluwarsa, lalu sama-sama menjalankan refresh. Akibatnya, token baru bisa saling menimpa di database atau cache, bahkan ada kasus refresh token lama langsung tidak valid setelah refresh pertama berhasil.

Jika tidak ditangani, gejalanya terlihat seperti bug acak: sebagian request berhasil, sebagian gagal 401 Unauthorized, ada invalid_grant saat refresh, atau token yang tersimpan justru token lama. Solusinya bukan sekadar “retry refresh”, tetapi mengendalikan konkurensi, memastikan hanya satu proses yang berwenang memperbarui token, dan menyimpan hasil refresh dengan mekanisme yang aman terhadap balapan data.

Gejala yang Biasanya Muncul di Produksi

Pada integrasi backend-to-backend, pola ini sering muncul saat satu akun atau satu koneksi OAuth dipakai oleh banyak request sekaligus. Misalnya, beberapa worker queue, beberapa instance aplikasi, atau beberapa request HTTP masuk dalam waktu hampir bersamaan.

  • Request A dan B sama-sama melihat access token sudah kedaluwarsa.
  • Keduanya memanggil endpoint refresh ke provider OAuth.
  • Provider mengembalikan token baru ke A dan B, atau hanya A yang berhasil sementara B gagal invalid_grant.
  • Hasil yang terakhir ditulis ke token store menimpa hasil lain.
  • Request berikutnya memakai token yang salah, kedaluwarsa, atau refresh token yang sudah tidak berlaku.

Gejala yang sering dilaporkan:

  • Lonjakan error 401 atau 403 sesaat setelah token expire.
  • Error invalid_grant pada endpoint token refresh.
  • Token di database berubah beberapa kali dalam hitungan detik.
  • Insiden hanya muncul saat trafik tinggi atau saat banyak job berjalan paralel.
  • Perilaku sulit direproduksi di lokal karena kompetisi timing jarang terjadi.

Root Cause: Mengapa Token Bisa Tertimpa

1. Check-then-act tanpa sinkronisasi

Pola paling umum:

if token.isExpired() {
  newToken = oauth.refresh(token.refreshToken)
  save(newToken)
}

Kode ini terlihat benar dalam satu alur tunggal, tetapi bermasalah saat dieksekusi paralel. Dua proses bisa sama-sama membaca keadaan token lama, lalu sama-sama bertindak seolah mereka adalah pihak pertama yang melakukan refresh.

2. Refresh token berotasi

Banyak provider OAuth menerapkan refresh token rotation: setiap refresh menghasilkan refresh token baru, dan refresh token lama bisa langsung tidak valid. Jika dua proses memakai refresh token yang sama:

  • Proses pertama berhasil dan menyimpan refresh token baru.
  • Proses kedua memakai refresh token lama dan menerima invalid_grant.
  • Jika proses kedua masih menulis state lama atau menandai koneksi sebagai putus tanpa verifikasi ulang, data menjadi inkonsisten.

3. Token store tidak terpusat atau tidak atomik

Masalah makin parah bila token disimpan di memori per-instance. Pada deployment multi-instance, instance A bisa memegang token lama, instance B sudah menyimpan token baru, dan keduanya tidak sinkron. Bahkan jika memakai database bersama, operasi read-modify-write tanpa pengaman tetap rentan terhadap lost update.

4. Retry yang tidak aman

Retry otomatis sering memperburuk situasi. Saat refresh gagal, worker lain ikut mencoba refresh lagi, menciptakan gelombang request ke provider. Hasilnya bukan hanya race condition, tetapi juga risiko kena rate limit.

Contoh Alur Race Condition

Misalkan satu integrasi menyimpan pasangan berikut:

access_token = AT-1
refresh_token = RT-1
expires_at = 10:00:00

Pada pukul 10:00:01, dua request masuk bersamaan.

  1. Request A membaca token: expired, refresh_token = RT-1.
  2. Request B membaca token: expired, refresh_token = RT-1.
  3. A memanggil refresh dengan RT-1, berhasil mendapat AT-2 dan RT-2.
  4. B memanggil refresh dengan RT-1.
  5. Jika provider mengizinkan satu kali pakai, B mendapat invalid_grant.
  6. Jika B menangani error dengan buruk, B mungkin menandai token invalid permanen, padahal A sudah berhasil.
  7. Jika keduanya sama-sama dapat respons sukses dan urutan tulis terbalik, token yang lebih lama bisa menimpa token yang lebih baru.

Masalah inti bukan pada OAuth secara umum, tetapi pada concurrency control di sisi aplikasi Anda.

Pola Mitigasi yang Praktis

Single-flight refresh di dalam satu proses

Single-flight berarti untuk satu identitas token tertentu, hanya satu refresh yang boleh berjalan pada saat yang sama. Request lain tidak ikut refresh; mereka menunggu hasil refresh yang sedang berlangsung, lalu memakai hasil yang sama.

Pola ini sangat efektif bila masalah utama terjadi di satu instance aplikasi dengan banyak request paralel.

function getValidAccessToken(connectionId): Token {
  token = tokenStore.get(connectionId)

  if !needsRefresh(token) {
    return token
  }

  return singleFlight.do("refresh:" + connectionId, () => {
    latest = tokenStore.get(connectionId)
    if !needsRefresh(latest) {
      return latest
    }

    refreshed = oauth.refresh(latest.refreshToken)
    tokenStore.save(connectionId, refreshed)
    return refreshed
  })
}

Mengapa perlu membaca ulang token di dalam blok single-flight? Karena saat request menunggu giliran, request lain mungkin sudah menyelesaikan refresh. Tanpa re-check, Anda masih bisa melakukan refresh yang tidak perlu.

Kelebihan:

  • Sederhana dan murah untuk kasus single-instance.
  • Mengurangi duplikasi refresh call.
  • Menurunkan kemungkinan rate limit.

Kekurangan:

  • Tidak cukup untuk deployment multi-instance.
  • Request yang menunggu refresh akan menambah latency.

Optimistic locking atau version check saat menyimpan token

Walaupun Anda sudah membatasi refresh, penyimpanan token tetap harus aman dari lost update. Solusi umum adalah menambahkan kolom versi atau memakai nilai token lama sebagai syarat update.

Contoh tabel:

oauth_connections
- id
- access_token
- refresh_token
- expires_at
- version
- updated_at

Alur:

  1. Baca row token beserta version.
  2. Lakukan refresh.
  3. Simpan hasil dengan syarat WHERE id = ? AND version = ?.
  4. Jika jumlah row ter-update = 0, berarti ada proses lain yang lebih dulu menulis. Ambil ulang token terbaru dan gunakan itu.
token = repo.get(connectionId) // version = 7
refreshed = oauth.refresh(token.refreshToken)

updated = repo.updateWhereVersion(
  id = connectionId,
  expectedVersion = token.version,
  newAccessToken = refreshed.accessToken,
  newRefreshToken = refreshed.refreshToken,
  newExpiresAt = refreshed.expiresAt,
  newVersion = token.version + 1
)

if updated == 0 {
  return repo.get(connectionId)
}

return refreshed

Mengapa ini bekerja? Karena Anda tidak mengasumsikan data yang dibaca di awal masih menjadi versi terbaru saat menulis. Jika ada penulis lain yang lebih dulu menyimpan token baru, penulisan Anda gagal secara aman, bukan menimpa data.

Kapan cocok dipakai? Hampir selalu. Bahkan jika Anda juga memakai lock, version check tetap berguna sebagai pengaman lapis kedua.

Distributed lock untuk multi-instance

Jika aplikasi berjalan di beberapa instance, single-flight lokal tidak cukup. Instance A dan B tetap bisa refresh bersamaan. Di sinilah distributed lock berguna, misalnya dengan Redis atau database locking.

Prinsipnya:

  • Buat lock per koneksi OAuth, misalnya oauth:refresh:{connectionId}.
  • Hanya pemegang lock yang boleh menjalankan refresh.
  • Setelah lock didapat, baca ulang token dari store terpusat.
  • Jika ternyata token sudah diperbarui oleh instance lain, jangan refresh lagi.
function refreshWithDistributedLock(connectionId): Token {
  lockKey = "oauth:refresh:" + connectionId
  lock = lockProvider.acquire(lockKey, ttl=30s)

  if !lock.acquired {
    waitShort()
    return tokenStore.get(connectionId)
  }

  try {
    current = tokenStore.get(connectionId)
    if !needsRefresh(current) {
      return current
    }

    refreshed = oauth.refresh(current.refreshToken)
    saved = tokenStore.updateWithVersionCheck(connectionId, current.version, refreshed)

    if !saved {
      return tokenStore.get(connectionId)
    }

    return refreshed
  } finally {
    lock.release()
  }
}

Trade-off:

  • Lebih aman untuk horizontal scaling.
  • Menambah kompleksitas operasional.
  • Perlu memperhatikan TTL lock, crash recovery, dan kemungkinan lock orphan.

Failure mode yang harus dipahami:

  • Lock TTL terlalu pendek: refresh belum selesai, lock habis, instance lain masuk.
  • Lock TTL terlalu panjang: kegagalan holder lock membuat request lain tertahan lama.
  • Network partition atau gangguan Redis dapat membuat lock tidak tersedia.

Karena itu, distributed lock sebaiknya tidak berdiri sendiri. Kombinasikan dengan version check agar kegagalan lock tidak langsung berujung token tertimpa.

Token store terpusat

Jangan simpan token aktif hanya di memori proses jika ada lebih dari satu instance. Simpan di store terpusat seperti database atau key-value store yang menjadi sumber kebenaran tunggal.

Praktik yang lebih aman:

  • Satu sumber data untuk semua instance.
  • Update token secara atomik atau dengan version check.
  • Cache lokal boleh ada, tetapi harus dianggap turunan, bukan sumber kebenaran utama.

Kesalahan umum adalah menyimpan token di database tetapi tetap mempertahankan cache memori yang tidak pernah di-invalidasi. Hasilnya, refresh sudah berhasil disimpan, tetapi beberapa worker masih memakai token lama beberapa detik atau menit.

Retry yang aman

Retry tidak boleh membabi buta. Tujuannya adalah memulihkan kondisi sementara, bukan memperbanyak refresh paralel.

Pola retry yang lebih aman:

  1. Jika API bisnis gagal 401, cek ulang token terbaru dari store terpusat.
  2. Jika token lokal ternyata stale, ulangi request dengan token terbaru tanpa refresh lagi.
  3. Hanya lakukan refresh jika state terbaru memang perlu refresh.
  4. Batasi retry count dan beri backoff kecil.
function callThirdParty(connectionId, request) {
  token = getValidAccessToken(connectionId)
  resp = api.call(request, token.accessToken)

  if resp.status != 401 {
    return resp
  }

  latest = tokenStore.get(connectionId)
  if latest.accessToken != token.accessToken {
    return api.call(request, latest.accessToken)
  }

  refreshed = refreshWithCoordination(connectionId)
  return api.call(request, refreshed.accessToken)
}

Pendekatan ini penting karena tidak semua 401 berarti token harus direfresh. Bisa jadi request memakai token stale dari cache, atau token baru sudah disimpan oleh proses lain.

Penanganan invalid_grant yang benar

invalid_grant adalah titik yang sering disalahartikan. Error ini bisa berarti:

  • Refresh token benar-benar dicabut atau tidak valid lagi.
  • Refresh token sudah dipakai oleh proses lain karena rotation.
  • Jam sistem terlalu melenceng untuk skenario tertentu.
  • State integrasi memang sudah tidak bisa dipulihkan tanpa re-auth.

Jangan langsung menandai koneksi rusak permanen hanya karena satu request mendapat invalid_grant. Lakukan verifikasi ulang:

  1. Ambil token terbaru dari store terpusat.
  2. Jika refresh token di store sudah berubah sejak request ini mulai, anggap error ini sebagai efek race, bukan kegagalan final.
  3. Jika refresh token belum berubah dan provider konsisten mengembalikan invalid_grant, barulah tandai integrasi perlu otorisasi ulang.
try {
  refreshed = oauth.refresh(oldRefreshToken)
} catch (e) {
  if e.code == "invalid_grant" {
    latest = tokenStore.get(connectionId)

    if latest.refreshToken != oldRefreshToken {
      return latest // proses lain sudah refresh lebih dulu
    }

    markReauthRequired(connectionId)
    throw new ReauthRequiredError()
  }

  throw e
}

Ini mencegah false positive yang membuat pengguna dipaksa login ulang padahal token sebenarnya sudah sehat.

Skenario Multi-Instance yang Sering Menjebak

Kasus 1: 4 worker queue memproses akun yang sama

Empat job berbeda memproses sinkronisasi data untuk koneksi OAuth yang sama. Semuanya mulai dalam rentang beberapa milidetik dan mendapati access token expired.

Jika tanpa koordinasi:

  • Semua worker refresh bersamaan.
  • Provider menerima empat refresh request.
  • Satu berhasil, sisanya bisa gagal atau menghasilkan state tidak konsisten.

Jika memakai distributed lock + version check:

  • Satu worker menang lock.
  • Tiga worker lain menunggu singkat atau membaca ulang token setelah jeda.
  • Worker pemenang refresh dan menyimpan hasil.
  • Worker lain membaca token terbaru lalu lanjut memanggil API.

Kasus 2: Web request dan scheduler bertabrakan

Request user memicu panggilan API pihak ketiga tepat saat scheduler latar belakang juga melakukan sinkronisasi. Tanpa token store terpusat, request web bisa memakai token stale dari memori, sementara scheduler sudah menyimpan token baru ke database.

Solusi minimal:

  • Setiap sebelum memanggil API pihak ketiga, ambil token dari store terpusat atau cache yang punya invalidation jelas.
  • Jangan hanya mengandalkan token yang dimuat saat aplikasi start.

Urutan Implementasi yang Disarankan

Jika Anda ingin memperbaiki sistem yang sudah berjalan, urutan berikut biasanya paling masuk akal:

  1. Pusatkan token store agar semua instance membaca sumber yang sama.
  2. Tambahkan expires_at yang konservatif, misalnya refresh sedikit sebelum benar-benar kedaluwarsa untuk mengurangi balapan di tepi expiry.
  3. Implementasikan version check saat update token.
  4. Tambahkan single-flight lokal untuk mengurangi duplikasi refresh per instance.
  5. Tambahkan distributed lock jika ada multi-instance atau queue paralel.
  6. Perbaiki retry dan penanganan invalid_grant agar tidak memperparah race.
  7. Tambahkan observability: log korelasi, metrik refresh, dan jumlah konflik update.

Checklist Implementasi

  • Token disimpan di store terpusat, bukan hanya memori proses.
  • Ada kolom expires_at dan evaluasi expiry dilakukan dengan buffer kecil.
  • Refresh memakai koordinasi per connectionId, bukan global lock.
  • Setelah mendapat lock, aplikasi selalu membaca ulang token terbaru.
  • Penyimpanan token memakai optimistic locking atau version check.
  • Jika update gagal karena versi berubah, aplikasi mengambil ulang token terbaru, bukan memaksa overwrite.
  • 401 dari API tidak selalu langsung memicu refresh; cek dulu apakah token lokal stale.
  • invalid_grant diverifikasi terhadap state terbaru sebelum menandai re-auth wajib.
  • Retry dibatasi dan memakai backoff kecil.
  • Log memuat connectionId, versi token, hasil refresh, dan penyebab kegagalan.
  • Ada metrik: jumlah refresh, konflik version check, lock timeout, invalid_grant, dan retry.
  • Test paralel dijalankan, bukan hanya test serial.

Debugging Tips di Produksi

Log yang perlu ada

  • ID koneksi OAuth atau tenant.
  • Timestamp mulai dan selesai refresh.
  • Versi token sebelum dan sesudah update.
  • Apakah refresh dieksekusi, ditunggu, atau dilewati karena token sudah diperbarui proses lain.
  • Hasil lock: berhasil, timeout, atau gagal.
  • Kode error dari provider, terutama invalid_grant.

Tanda bahwa race condition memang terjadi

  • Dua log refresh untuk koneksi yang sama muncul hampir bersamaan.
  • Ada update token berurutan dengan versi yang saling bertabrakan.
  • invalid_grant muncul tepat setelah refresh sukses dari request lain.
  • 401 hilang sendiri saat request diulang beberapa ratus milidetik kemudian.

Strategi testing

Jangan hanya menguji satu request. Buat test yang menjalankan banyak goroutine/thread/promise secara bersamaan untuk koneksi yang sama, lalu pastikan:

  • Hanya satu refresh call benar-benar dikirim ke provider mock.
  • Semua request menerima access token final yang sama.
  • Tidak ada overwrite token lama ke token baru.
  • invalid_grant palsu tidak menyebabkan re-auth yang salah.

Trade-off yang Perlu Dipahami

Latency vs konsistensi

Menambahkan lock atau menunggu hasil single-flight meningkatkan latency sebagian request. Namun ini biasanya lebih murah daripada membiarkan banyak refresh paralel yang menyebabkan error berantai.

Kompleksitas vs keandalan

Single-flight lokal mudah diterapkan, tetapi tidak cukup untuk cluster. Distributed lock lebih andal untuk multi-instance, tetapi menambah komponen dan mode gagal baru. Karena itu, kombinasi yang umum adalah:

  • single-flight lokal untuk efisiensi,
  • distributed lock untuk koordinasi antar-instance,
  • version check sebagai jaring pengaman akhir.

Availability vs strict coordination

Jika lock service bermasalah, Anda harus menentukan kebijakan: gagal cepat, baca token terbaru lalu coba sekali, atau tunda request. Pilihan ini bergantung pada seberapa kritis integrasi tersebut dan apakah request bisa diulang dengan aman.

Kesimpulan

OAuth Refresh Token Race Condition bukan masalah teori, tetapi pitfall umum pada integrasi backend ke API pihak ketiga. Akar masalahnya adalah beberapa request paralel melakukan refresh terhadap state token yang sama, lalu hasilnya saling menimpa atau memicu invalid_grant akibat refresh token rotation.

Pendekatan yang paling aman biasanya bukan satu teknik tunggal, melainkan kombinasi: token store terpusat, single-flight refresh, optimistic locking/version check, distributed lock untuk multi-instance, serta retry dan penanganan invalid_grant yang sadar terhadap race. Dengan pola ini, Anda tidak hanya mengurangi error, tetapi juga menjaga state token tetap konsisten saat trafik naik dan worker berjalan paralel.