Rotasi refresh token tanpa race condition berarti satu refresh token hanya boleh dipakai sekali, tetapi sistem tetap konsisten saat dua request refresh datang hampir bersamaan. Jika desain endpoint tidak hati-hati, Anda bisa mendapat token ganda, logout paksa pada user yang sah, atau status sesi yang sulit diprediksi.

Di Laravel API, pola yang aman umumnya menggabungkan beberapa hal: token family, penyimpanan hash refresh token, transaksi database atau lock saat rotasi, deteksi reuse untuk replay attack, dan aturan idempotency yang jelas. Artikel ini membahas desain praktisnya, termasuk kontrak API, TTL, revoke policy, multi-device, logging, serta strategi retry di sisi client.

Masalah inti: kenapa race condition terjadi saat refresh token

Pada skema rotasi, client mengirim refresh token lama untuk meminta pasangan token baru. Setelah berhasil, token lama harus dianggap tidak valid. Masalah muncul saat dua request memakai refresh token yang sama dalam waktu hampir bersamaan.

Skenario yang sering terjadi

  1. Kedua request lolos validasi sebelum status token berubah. Hasilnya, server menerbitkan dua refresh token baru dari satu token lama.
  2. Request pertama sukses, request kedua dianggap reuse. Jika kebijakan Anda terlalu agresif, seluruh keluarga token direvoke, padahal itu hanya akibat retry jaringan atau request ganda dari client.
  3. State tidak konsisten antar node. Ini bisa terjadi jika beberapa instance API membaca state lama karena locking tidak tepat atau penyimpanan token tidak atomik.

Dampaknya bukan hanya masalah keamanan. Dari sisi produk, user bisa tiba-tiba logout di satu device, tetap login di device lain, atau menerima error yang sulit dipulihkan karena client tidak tahu token mana yang valid.

Tujuan desain yang benar

Desain endpoint refresh yang sehat sebaiknya memenuhi target berikut:

  • Single use: satu refresh token hanya bisa dipakai sekali.
  • Aman dari replay: jika token lama dicuri lalu dipakai ulang, sistem bisa mendeteksi reuse.
  • Konsisten saat konkurensi: dua request paralel tidak menghasilkan dua token aktif dari parent yang sama.
  • Dapat dipulihkan oleh client: retry karena timeout atau jaringan buruk tidak langsung berujung logout paksa.
  • Mendukung multi-device: sesi tiap device bisa dipisahkan dengan jelas.
  • Auditable: semua rotasi, revoke, dan reuse tercatat.

Model data: token family, hash token, dan status rotasi

Jangan simpan refresh token mentah di database. Simpan hash-nya, seperti saat menyimpan password. Refresh token yang keluar ke client sebaiknya token acak berentropi tinggi, bukan JWT yang memuat terlalu banyak state, agar revocation dan rotasi lebih mudah dikontrol di server.

Konsep token family

Token family adalah rantai rotasi untuk satu sesi login pada satu device. Saat user login dari laptop dan ponsel, buat dua family berbeda. Ini penting agar revoke pada satu device tidak otomatis memutus semua device, kecuali memang itu kebijakan Anda.

Struktur tabel yang umum:

refresh_token_families
- id (uuid / bigint)
- user_id
- device_id atau client_instance_id
- status (active, revoked)
- revoked_at
- revoke_reason
- created_at
- updated_at

refresh_tokens
- id
- family_id
- token_hash
- parent_id nullable
- issued_at
- expires_at
- consumed_at nullable
- revoked_at nullable
- reuse_detected_at nullable
- replaced_by_id nullable
- request_fingerprint nullable
- created_ip nullable
- created_user_agent nullable
- created_at
- updated_at

Makna kolom penting:

  • token_hash: hasil hash refresh token mentah.
  • consumed_at: menandakan token sudah dipakai untuk rotasi.
  • replaced_by_id: referensi ke token baru hasil rotasi.
  • family_id: semua token dalam satu sesi/device yang sama.
  • reuse_detected_at: jejak audit ketika token yang sudah dikonsumsi dipakai lagi.

Kenapa hash token lebih aman

Jika database bocor, attacker tidak langsung mendapatkan refresh token aktif. Anda tetap perlu memastikan refresh token asli punya entropi tinggi dan dibandingkan dengan timing-safe comparison saat perlu.

Kontrak API refresh yang jelas

Kontrak API harus tegas soal apa yang dikirim, apa yang dikembalikan, kapan gagal, dan apakah client boleh retry.

Request

POST /api/auth/refresh
Content-Type: application/json

{
  "refresh_token": "rt_...",
  "device_id": "web-3f8c...",
  "idempotency_key": "8b0f0d0c-..."
}

Catatan:

  • refresh_token wajib.
  • device_id membantu korelasi sesi, audit, dan multi-device. Jangan jadikan ini satu-satunya kontrol keamanan.
  • idempotency_key sangat berguna agar retry dari client untuk request yang sama tidak menyebabkan perilaku ambigu.

Response sukses

200 OK

{
  "access_token": "at_...",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "rt_new_...",
  "refresh_expires_in": 2592000,
  "family_id": "fam_..."
}

access token sebaiknya pendek TTL-nya, misalnya hitungan menit. refresh token lebih panjang, misalnya hitungan hari atau minggu, tergantung risiko. Nilai persisnya tergantung kebutuhan produk dan profil ancaman.

Status code dan payload error yang masuk akal

Gunakan error yang bisa dibedakan oleh client tanpa membocorkan detail berlebihan.

401 Unauthorized
{
  "error": {
    "code": "INVALID_REFRESH_TOKEN",
    "message": "Refresh token tidak valid atau sudah kedaluwarsa."
  }
}

401 Unauthorized
{
  "error": {
    "code": "REFRESH_TOKEN_REUSED",
    "message": "Refresh token terdeteksi dipakai ulang.",
    "action": "REAUTH_REQUIRED"
  }
}

409 Conflict
{
  "error": {
    "code": "REFRESH_IN_PROGRESS",
    "message": "Rotasi token sedang diproses.",
    "retryable": true
  }
}

422 Unprocessable Entity
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Payload tidak valid."
  }
}

401 cocok untuk token tidak valid, expired, revoked, atau reuse yang mewajibkan login ulang. 409 berguna jika Anda memilih memberi sinyal bahwa ada refresh paralel yang sedang berlangsung dan client boleh retry singkat. Jangan menyamakan semua kondisi menjadi 500.

Aturan idempotency yang realistis

Untuk endpoint refresh, idempotency tidak berarti request yang sama boleh menghasilkan token baru berkali-kali. Aturan yang lebih aman adalah:

  • Jika request identik dengan idempotency_key yang sama masuk ulang dalam jendela pendek, server boleh mengembalikan hasil sukses yang sama jika rotasi sebelumnya memang berhasil.
  • Jika request kedua memakai refresh token lama tanpa idempotency yang cocok, perlakukan sebagai late duplicate atau reuse sesuai state token.
  • Simpan catatan idempotency per kombinasi family/session + key + endpoint dengan TTL pendek.

Ini membantu saat client timeout setelah server sebenarnya sudah sukses memutar token, sehingga retry tidak memicu logout yang tidak perlu.

Strategi race-free: transaksi database dan row locking

Solusi paling sederhana yang tetap kuat adalah menyimpan state refresh token di database relasional dan melakukan rotasi dalam satu transaksi dengan row-level lock. Intinya, hanya satu request yang boleh memutuskan nasib token tertentu pada satu waktu.

Alur aman rotasi

  1. Hash refresh token dari request.
  2. Cari baris token berdasarkan hash.
  3. Lock baris token itu di dalam transaksi.
  4. Periksa apakah token expired, revoked, atau sudah consumed.
  5. Jika masih valid, buat refresh token baru, tandai token lama consumed_at, set replaced_by_id.
  6. Commit transaksi.
  7. Terbitkan access token baru.

Dengan pola ini, request kedua yang masuk hampir bersamaan akan menunggu lock atau membaca state setelah request pertama selesai. Ia tidak bisa lagi memutar token lama menjadi token baru kedua.

Contoh implementasi service di Laravel

<?php

namespace App\Services\Auth;

use App\Models\RefreshToken;
use App\Models\RefreshTokenFamily;
use App\Models\AuthAuditLog;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Support\Carbon;

class RefreshTokenService
{
    public function rotate(string $plainRefreshToken, ?string $deviceId = null, ?string $idempotencyKey = null): array
    {
        $tokenHash = hash('sha256', $plainRefreshToken);
        $now = Carbon::now();

        return DB::transaction(function () use ($tokenHash, $deviceId, $idempotencyKey, $now) {
            $token = RefreshToken::query()
                ->where('token_hash', $tokenHash)
                ->lockForUpdate()
                ->first();

            if (! $token) {
                throw new InvalidRefreshTokenException('INVALID_REFRESH_TOKEN');
            }

            $family = RefreshTokenFamily::query()
                ->whereKey($token->family_id)
                ->lockForUpdate()
                ->first();

            if (! $family || $family->status !== 'active') {
                throw new InvalidRefreshTokenException('INVALID_REFRESH_TOKEN');
            }

            if ($token->revoked_at || $token->expires_at->isPast()) {
                throw new InvalidRefreshTokenException('INVALID_REFRESH_TOKEN');
            }

            if ($token->consumed_at) {
                $this->handleReuseOrDuplicate($token, $family, $idempotencyKey);
            }

            $newPlainRefreshToken = 'rt_' . Str::random(96);
            $newToken = RefreshToken::create([
                'family_id' => $family->id,
                'token_hash' => hash('sha256', $newPlainRefreshToken),
                'parent_id' => $token->id,
                'issued_at' => $now,
                'expires_at' => $now->copy()->addDays(30),
                'created_ip' => request()->ip(),
                'created_user_agent' => substr((string) request()->userAgent(), 0, 500),
            ]);

            $token->forceFill([
                'consumed_at' => $now,
                'replaced_by_id' => $newToken->id,
            ])->save();

            AuthAuditLog::create([
                'user_id' => $family->user_id,
                'family_id' => $family->id,
                'event' => 'refresh_rotated',
                'ip' => request()->ip(),
                'user_agent' => substr((string) request()->userAgent(), 0, 500),
                'meta' => [
                    'old_token_id' => $token->id,
                    'new_token_id' => $newToken->id,
                    'device_id' => $deviceId,
                    'idempotency_key' => $idempotencyKey,
                ],
            ]);

            return [
                'user_id' => $family->user_id,
                'family_id' => $family->id,
                'refresh_token' => $newPlainRefreshToken,
                'refresh_expires_in' => 30 * 24 * 60 * 60,
            ];
        });
    }

    protected function handleReuseOrDuplicate(RefreshToken $token, RefreshTokenFamily $family, ?string $idempotencyKey): void
    {
        $token->forceFill([
            'reuse_detected_at' => now(),
        ])->save();

        $family->forceFill([
            'status' => 'revoked',
            'revoked_at' => now(),
            'revoke_reason' => 'refresh_token_reuse',
        ])->save();

        RefreshToken::query()
            ->where('family_id', $family->id)
            ->whereNull('revoked_at')
            ->update(['revoked_at' => now()]);

        AuthAuditLog::create([
            'user_id' => $family->user_id,
            'family_id' => $family->id,
            'event' => 'refresh_reuse_detected',
            'ip' => request()->ip(),
            'user_agent' => substr((string) request()->userAgent(), 0, 500),
            'meta' => [
                'token_id' => $token->id,
                'idempotency_key' => $idempotencyKey,
            ],
        ]);

        throw new RefreshTokenReusedException('REFRESH_TOKEN_REUSED');
    }
}

Contoh di atas menunjukkan prinsip utama, bukan implementasi final siap produksi. Dalam praktiknya, Anda kemungkinan ingin memisahkan logika duplicate retry dari reuse berbahaya, bukan langsung merevoke family untuk semua kasus consumed_at.

Membedakan duplicate retry dan reuse berbahaya

Ini bagian yang sering salah desain. Jika token sudah consumed, belum tentu itu serangan. Bisa jadi:

  • client mengirim request yang sama dua kali,
  • koneksi timeout setelah server sukses,
  • dua tab browser memakai state lama secara bersamaan.

Agar tidak terlalu agresif, Anda bisa menerapkan kebijakan bertingkat:

  1. Jika request kedua datang dalam jendela sangat pendek setelah rotasi pertama dan punya idempotency_key yang sama, kembalikan hasil yang sama.
  2. Jika datang dalam jendela pendek tetapi tanpa bukti idempotency, balas 409 REFRESH_IN_PROGRESS atau 401 INVALID_REFRESH_TOKEN sesuai kontrak Anda.
  3. Jika token lama dipakai lagi setelah jendela pendek berlalu, atau dari fingerprint yang sangat berbeda, tandai sebagai reuse dan revoke family.

Dengan pendekatan ini, Anda tetap ketat terhadap replay, tetapi tidak menghukum retry yang sah secara berlebihan.

Menggunakan lock terdistribusi bila perlu

Jika Anda menjalankan banyak instance aplikasi dan ingin melindungi area kritis lebih awal sebelum transaksi database, Anda bisa menambahkan distributed lock menggunakan Redis. Namun, lock database pada baris token tetap lebih penting karena state final tetap berada di database.

Gunakan lock Redis sebagai optimisasi untuk mengurangi tabrakan, bukan sebagai satu-satunya sumber kebenaran. Jika lock Redis hilang atau TTL lock terlalu pendek, transaksi database masih harus menjaga konsistensi.

$lock = Cache::lock('refresh:' . $tokenHash, 5);

try {
    $lock->block(2);
    $result = $service->rotate($refreshToken, $deviceId, $idempotencyKey);
} finally {
    optional($lock)->release();
}

Trade-off:

  • Pro: mengurangi kontensi di database.
  • Kontra: kompleksitas lebih tinggi, perlu disiplin TTL dan fallback saat lock gagal.

Controller dan kontrak respons di Laravel

<?php

namespace App\Http\Controllers\Api\Auth;

use App\Http\Controllers\Controller;
use App\Services\Auth\RefreshTokenService;
use Illuminate\Http\Request;

class RefreshTokenController extends Controller
{
    public function __invoke(Request $request, RefreshTokenService $service)
    {
        $data = $request->validate([
            'refresh_token' => ['required', 'string'],
            'device_id' => ['nullable', 'string', 'max:255'],
            'idempotency_key' => ['nullable', 'string', 'max:255'],
        ]);

        try {
            $result = $service->rotate(
                $data['refresh_token'],
                $data['device_id'] ?? null,
                $data['idempotency_key'] ?? null,
            );

            $accessToken = app(AccessTokenIssuer::class)->issue($result['user_id']);

            return response()->json([
                'access_token' => $accessToken['token'],
                'token_type' => 'Bearer',
                'expires_in' => $accessToken['expires_in'],
                'refresh_token' => $result['refresh_token'],
                'refresh_expires_in' => $result['refresh_expires_in'],
                'family_id' => $result['family_id'],
            ]);
        } catch (RefreshTokenReusedException $e) {
            return response()->json([
                'error' => [
                    'code' => 'REFRESH_TOKEN_REUSED',
                    'message' => 'Refresh token terdeteksi dipakai ulang.',
                    'action' => 'REAUTH_REQUIRED',
                ]
            ], 401);
        } catch (InvalidRefreshTokenException $e) {
            return response()->json([
                'error' => [
                    'code' => 'INVALID_REFRESH_TOKEN',
                    'message' => 'Refresh token tidak valid atau sudah kedaluwarsa.',
                ]
            ], 401);
        }
    }
}

Anda bebas memakai sistem access token apa pun di Laravel, selama rotasi refresh token dikontrol oleh state server yang konsisten. Jangan mencampur logika akses dan refresh secara kabur di satu token yang sama.

TTL, revoke policy, dan keputusan desain yang perlu disepakati

TTL yang umum dipakai

  • Access token: pendek, misalnya 5-30 menit.
  • Refresh token: lebih panjang, misalnya beberapa hari hingga minggu.
  • Absolute session lifetime: opsional, untuk membatasi umur family walaupun refresh terus berhasil.

Pilih TTL berdasarkan risiko. Aplikasi internal dengan device terkelola bisa sedikit lebih longgar. Aplikasi publik dengan data sensitif sebaiknya lebih ketat.

Revoke policy yang disarankan

  • Logout satu device: revoke satu family saja.
  • Logout semua device: revoke semua family milik user.
  • Reuse terdeteksi: minimal revoke family terkait; untuk kasus sensitif, pertimbangkan revoke semua family user dan paksa re-auth.
  • Password berubah atau MFA reset: umumnya revoke semua family.

Kapan revoke family terlalu agresif

Jika setiap duplicate retry langsung dianggap reuse dan family direvoke, user sah akan sering logout. Karena itu, tentukan grace policy yang eksplisit untuk request duplikat yang sangat dekat waktunya dan masih dapat dijelaskan oleh retry jaringan.

Strategi retry client yang aman

Client perlu aturan jelas agar tidak memperparah race condition.

Aturan yang disarankan

  • Jangan biarkan banyak request refresh berjalan paralel di client yang sama.
  • Gunakan single-flight: jika satu refresh sedang berjalan, request lain menunggu hasilnya.
  • Kirim idempotency_key yang sama untuk retry dari operasi refresh yang sama.
  • Jika menerima 409 REFRESH_IN_PROGRESS, retry dengan backoff singkat.
  • Jika menerima 401 REFRESH_TOKEN_REUSED, hentikan retry dan arahkan user ke login ulang.

Contoh alur client

  1. API request gagal karena access token expired.
  2. Interceptor mengecek apakah refresh sedang berlangsung.
  3. Jika ya, tunggu promise yang sama.
  4. Jika tidak, buat satu operasi refresh dan simpan promise-nya.
  5. Jika refresh sukses, ulangi request semula dengan access token baru.
  6. Jika refresh gagal dengan 401 reuse/invalid, hapus sesi lokal dan tampilkan login.

Kesalahan umum di sisi client adalah setiap request yang mendapat 401 langsung memanggil refresh sendiri-sendiri. Itu menciptakan badai request yang justru memicu race condition.

Audit log dan observabilitas

Refresh token adalah area keamanan. Anda perlu jejak audit yang cukup untuk investigasi tanpa menyimpan data rahasia berlebihan.

Event yang sebaiknya dicatat

  • login_issued
  • refresh_rotated
  • refresh_duplicate_retry
  • refresh_reuse_detected
  • family_revoked
  • logout dan logout_all

Data yang relevan:

  • user_id
  • family_id
  • token_id lama dan baru
  • timestamp
  • IP dan user agent
  • device_id
  • alasan revoke
  • idempotency_key
  • request correlation id

Jangan log refresh token mentah. Jika perlu korelasi, simpan hash atau token identifier internal.

Pengujian konkurensi: jangan hanya test kasus normal

Endpoint refresh terlihat sederhana pada pengujian tunggal. Justru bug-nya muncul saat ada konkurensi, timeout, dan retry.

Skenario uji minimum

  1. Single refresh normal: token lama jadi consumed, token baru aktif.
  2. Dua request paralel dengan token sama: hanya satu yang menghasilkan token baru.
  3. Retry dengan idempotency_key sama: hasil sama, tidak membuat token baru kedua.
  4. Reuse setelah rotasi selesai: family direvoke sesuai kebijakan.
  5. Token expired: 401 invalid.
  6. Family revoked: 401 invalid.
  7. Multi-device: revoke pada device A tidak memutus device B.
  8. Logout saat refresh berlangsung: pastikan hasil akhir deterministik.

Contoh ide test konkurensi

Di level integrasi, Anda bisa menjalankan dua proses atau dua request HTTP hampir bersamaan terhadap endpoint yang sama. Tujuannya bukan mengejar microsecond, tetapi memverifikasi invariants:

  • maksimal satu child token dari satu parent token,
  • tidak ada dua token aktif yang berasal dari parent yang sama,
  • status family konsisten setelah reuse.

Jika sulit mensimulasikan race di test HTTP biasa, turunkan sebagian logika ke service dan uji transaksi pada database test yang mendukung locking. Untuk verifikasi akhir, tetap lakukan test end-to-end di lingkungan yang mendekati produksi.

Edge case yang sering terlupakan

Multi-device vs multi-tab

Multi-device sebaiknya berarti family terpisah. Multi-tab pada browser yang sama umumnya berbagi family yang sama. Jika state token disimpan di banyak tab tanpa koordinasi, request refresh paralel mudah terjadi. Gunakan sinkronisasi lokal di client bila memungkinkan.

Clock skew

Jangan terlalu bergantung pada waktu client. Validasi expiry harus memakai waktu server. Jika access token hampir habis, client boleh refresh lebih awal, tetapi keputusan validitas tetap di server.

Partial failure setelah commit

Kasus berbahaya: transaksi berhasil commit, tetapi response ke client hilang karena jaringan. Tanpa idempotency, client akan mengulang refresh dengan token lama dan bisa dianggap reuse. Karena itu, idempotency_key dan cache hasil singkat sangat membantu.

Pembersihan data lama

Tabel refresh token akan tumbuh. Siapkan job periodik untuk mengarsipkan atau menghapus token revoked/expired yang sudah melewati retensi audit. Pastikan kebijakan retensi sesuai kebutuhan forensik dan regulasi.

Checklist hardening produksi

  • Simpan refresh token sebagai hash, bukan plaintext.
  • Gunakan refresh token acak dengan entropi tinggi.
  • Lakukan rotasi dalam transaksi database dan lockForUpdate().
  • Pisahkan sesi per device dengan token family.
  • Terapkan reuse detection dan kebijakan revoke yang eksplisit.
  • Dukung idempotency_key untuk retry aman.
  • Pastikan client menerapkan single-flight refresh.
  • Log event audit tanpa menyimpan token mentah.
  • Batasi rate endpoint refresh untuk mencegah abuse.
  • Gunakan HTTPS saja; jangan pernah kirim token lewat channel tidak aman.
  • Jika memakai cookie, set atribut yang tepat seperti HttpOnly, Secure, dan kebijakan SameSite yang sesuai.
  • Siapkan endpoint logout device tunggal dan logout semua device.
  • Monitor metrik: jumlah refresh sukses, invalid, reuse, conflict, dan revoke.
  • Uji skenario paralel, timeout, retry, dan failover antar instance.

Kesimpulan

Laravel API: rotasi refresh token tanpa race condition bukan sekadar menukar token lama dengan token baru. Anda perlu desain stateful yang eksplisit: token family, hash refresh token, transaksi dengan lock, deteksi reuse, dan aturan idempotency yang masuk akal.

Jika harus memilih prioritas implementasi, mulai dari ini: simpan token sebagai hash, lakukan rotasi dalam transaksi dengan row lock, pisahkan family per device, dan pastikan client tidak melakukan refresh paralel. Setelah itu, tambahkan idempotency, audit log, dan kebijakan reuse yang lebih cermat agar aman dari replay tanpa menyebabkan logout paksa pada user yang sah.