State machine untuk kontrak API auth yang tahan retry adalah cara praktis untuk mengubah alur autentikasi yang biasanya implisit menjadi eksplisit, terukur, dan lebih aman saat menghadapi timeout, retry dari client, atau callback yang datang terlambat. Intinya bukan menambah teori, tetapi memastikan setiap aksi auth hanya boleh terjadi dari state tertentu, menghasilkan transisi yang jelas, dan punya respons yang stabil saat request yang sama dikirim ulang.

Masalah umum pada API auth biasanya bukan karena algoritma login atau OTP-nya salah, melainkan karena state tersembunyi di banyak tempat: database, cache, token store, worker queue, dan webhook handler. Akibatnya, login bisa diproses dua kali saat client timeout, OTP bisa tervalidasi lebih dari sekali, refresh token bisa balapan antar perangkat, dan revoke bisa datang terlambat setelah token baru sudah diterbitkan. Dengan lensa control structures, terutama ide bahwa alur sistem harus memiliki jalur eksekusi dan kelanjutan yang eksplisit, kita bisa memodelkan auth sebagai serangkaian state dan transisi yang dapat diuji.

Artikel ini memakai ide praktis dari pembahasan control structures: jangan biarkan kelanjutan proses auth tersebar sebagai efek samping implisit. Nyatakan state, transisi, dan hasil kontrak API secara eksplisit agar retry dan integrasi asynchronous tetap aman.

Mengapa alur auth sering rusak ketika state implisit

Pada implementasi yang terburu-buru, endpoint auth sering diperlakukan seperti fungsi stateless sederhana:

  • POST /login memeriksa password lalu mengirim OTP jika perlu.
  • POST /verify-otp memvalidasi kode lalu menerbitkan token.
  • POST /refresh menukar refresh token dengan access token baru.
  • POST /revoke menghapus sesi.

Secara kasat mata itu cukup. Masalah muncul ketika realitas jaringan ikut bermain:

  • Client mengirim request, server selesai memproses, tetapi respons hilang di jaringan.
  • Client melakukan retry otomatis.
  • Webhook dari provider OTP datang terlambat atau duplikat.
  • Dua perangkat memakai refresh token yang sama hampir bersamaan.
  • Job async untuk revoke tertunda, sementara token baru terlanjur dibuat.

Jika state tidak eksplisit, sistem akan menjawab pertanyaan penting dengan cara yang tidak konsisten:

  • Apakah request ini sudah pernah diproses?
  • Apakah OTP ini masih menunggu verifikasi atau sudah selesai?
  • Apakah refresh token ini masih aktif atau sudah diputar?
  • Apakah revoke ini final atau masih dalam proses?
  • Jika callback datang dua kali, apakah hasilnya sama?

Di titik ini, masalahnya bukan sekadar error handling, melainkan kontrak sistem. Endpoint auth perlu diperlakukan sebagai mesin status, bukan rangkaian if-else yang berdiri sendiri.

Model state machine untuk kontrak API auth

State machine membantu kita mendefinisikan:

  • State: kondisi sah dari sebuah proses auth.
  • Transition: perpindahan state yang diizinkan.
  • Event: aksi atau input yang memicu transisi.
  • Invariant: aturan yang harus selalu benar.

Untuk auth, jangan pakai satu state global yang terlalu besar. Lebih aman jika memisahkan beberapa mesin yang saling terkait:

  1. Authentication Attempt untuk login dan challenge OTP.
  2. Token Session untuk access/refresh token.
  3. Revocation Request untuk pencabutan sesi.
  4. Delivery/Callback State untuk status webhook atau callback provider.

Contoh state untuk login berbasis OTP

States (Authentication Attempt):
- INITIATED
- CREDENTIALS_VERIFIED
- CHALLENGE_DISPATCHING
- CHALLENGE_SENT
- VERIFIED
- TOKEN_ISSUED
- FAILED
- EXPIRED
- CANCELLED

Events:
- submit_credentials
- credentials_valid
- credentials_invalid
- dispatch_otp
- otp_sent
- otp_delivery_failed
- verify_otp
- otp_valid
- otp_invalid
- issue_token
- token_issued
- timeout
- cancel

Transisi yang masuk akal:

  • INITIATED -> CREDENTIALS_VERIFIED saat password benar.
  • CREDENTIALS_VERIFIED -> CHALLENGE_DISPATCHING saat OTP akan dikirim.
  • CHALLENGE_DISPATCHING -> CHALLENGE_SENT saat dispatch diterima sistem internal atau provider.
  • CHALLENGE_SENT -> VERIFIED saat OTP valid.
  • VERIFIED -> TOKEN_ISSUED saat token berhasil dibuat.
  • State mana pun yang belum final dapat berpindah ke FAILED, EXPIRED, atau CANCELLED sesuai aturan.

Yang penting: setiap endpoint harus tahu state apa yang boleh diproses. Misalnya, endpoint verifikasi OTP tidak boleh diam-diam membuat attempt baru jika ID attempt tidak ditemukan. Ia hanya boleh memproses attempt dengan state CHALLENGE_SENT atau state retry yang eksplisit.

Contoh state untuk refresh token

States (Refresh Token Session):
- ACTIVE
- ROTATING
- ROTATED
- REVOKING
- REVOKED
- EXPIRED
- COMPROMISED

Refresh token sering gagal karena implementasi tidak memodelkan rotasi sebagai transisi atomik. Jika dua request refresh masuk hampir bersamaan, keduanya bisa lolos validasi. Dengan state machine:

  • ACTIVE -> ROTATING saat refresh mulai diproses.
  • ROTATING -> ROTATED saat token baru sudah diterbitkan dan token lama dinonaktifkan.
  • ACTIVE/ROTATING -> REVOKING -> REVOKED saat pencabutan terjadi.

Jika request kedua datang saat state sudah ROTATING atau ROTATED, server bisa memberi respons stabil: entah mengembalikan hasil yang sama untuk idempotency yang sama, atau menolak dengan kode error yang konsisten.

Desain contract API: endpoint, state, dan respons yang stabil

Kontrak API auth yang tahan retry harus menyatakan tiga hal dengan jelas:

  1. Status bisnis, bukan hanya HTTP status.
  2. Identity operasi melalui idempotency key atau operation id.
  3. State yang terobservasi client, agar integrator tahu apa yang harus dilakukan berikutnya.

Contoh: memulai login

POST /auth/login
Idempotency-Key: 9c5e9f77-6b22-4f65-a0c4-2e0e8c28f911
Content-Type: application/json

{
  "username": "[email protected]",
  "password": "secret-password",
  "client": {
    "device_id": "device-123",
    "ip": "203.0.113.10"
  }
}

Contoh respons bila password valid dan OTP dibutuhkan:

{
  "attempt_id": "authatt_01HXYZ...",
  "state": "CHALLENGE_SENT",
  "next_action": "VERIFY_OTP",
  "challenge": {
    "type": "otp_sms",
    "masked_destination": "+62******1234",
    "expires_at": "2026-08-19T10:15:00Z"
  },
  "idempotency": {
    "key": "9c5e9f77-6b22-4f65-a0c4-2e0e8c28f911",
    "replayed": false
  }
}

Jika request yang sama diulang karena timeout, server sebaiknya mengembalikan hasil yang sama untuk operasi yang sama:

{
  "attempt_id": "authatt_01HXYZ...",
  "state": "CHALLENGE_SENT",
  "next_action": "VERIFY_OTP",
  "challenge": {
    "type": "otp_sms",
    "masked_destination": "+62******1234",
    "expires_at": "2026-08-19T10:15:00Z"
  },
  "idempotency": {
    "key": "9c5e9f77-6b22-4f65-a0c4-2e0e8c28f911",
    "replayed": true
  }
}

Perhatikan bahwa respons replay tidak perlu identik byte-per-byte, tetapi makna bisnisnya harus identik. Jangan kirim OTP baru hanya karena request diulang dengan idempotency key yang sama.

Contoh: verifikasi OTP

POST /auth/attempts/authatt_01HXYZ.../verify-otp
Idempotency-Key: 51d2d1ee-7130-4ac1-93cc-d70a9d616af4
Content-Type: application/json

{
  "otp_code": "481209"
}

Contoh respons sukses:

{
  "attempt_id": "authatt_01HXYZ...",
  "state": "TOKEN_ISSUED",
  "next_action": "NONE",
  "tokens": {
    "access_token": "access-token-value",
    "refresh_token": "refresh-token-value",
    "token_type": "Bearer",
    "expires_in": 3600
  },
  "session": {
    "session_id": "sess_01ABC..."
  }
}

Jika OTP yang sama diverifikasi ulang dengan key yang sama karena client tidak menerima respons, server sebaiknya mengembalikan hasil yang sama. Jika request baru datang dengan key berbeda setelah attempt sudah final, server sebaiknya menolak secara eksplisit:

{
  "error": {
    "code": "AUTH_ATTEMPT_FINALIZED",
    "message": "Authentication attempt is already finalized.",
    "retryable": false,
    "current_state": "TOKEN_ISSUED"
  }
}

Contoh: refresh token yang aman terhadap balapan

POST /auth/refresh
Idempotency-Key: 1de8ab9d-1cf8-4d2c-b8d6-c853ad0ce9b2
Content-Type: application/json

{
  "refresh_token": "refresh-token-value"
}

Contoh respons:

{
  "state": "ROTATED",
  "tokens": {
    "access_token": "new-access-token",
    "refresh_token": "new-refresh-token",
    "token_type": "Bearer",
    "expires_in": 3600
  },
  "rotation": {
    "previous_session_id": "sess_old",
    "new_session_id": "sess_new"
  }
}

Kalau request refresh yang sama diproses dua kali, sistem perlu aturan yang jelas:

  • Untuk idempotency key yang sama, hasil harus sama.
  • Untuk request bersamaan dengan key berbeda tetapi memakai refresh token yang sama, hanya satu yang boleh menang.
  • Request yang kalah harus menerima kode stabil seperti REFRESH_TOKEN_ALREADY_USED atau SESSION_ROTATION_CONFLICT.

Invariant yang wajib dijaga

State machine tanpa invariant akan tetap rapuh. Tabel berikut merangkum aturan minimum yang perlu dipertahankan.

AreaInvariantAlasan
Authentication AttemptSatu attempt hanya bisa final satu kaliMencegah token diterbitkan ganda
OTP VerificationOTP valid hanya bisa mengubah state dari CHALLENGE_SENT ke VERIFIED sekaliMencegah verifikasi berulang
Token IssuanceSetiap attempt final menghasilkan paling banyak satu sesi aktifMencegah duplikasi sesi saat retry
Refresh RotationSatu refresh token hanya boleh dipakai untuk satu rotasi suksesMencegah replay dan race condition
RevokeRevoke bersifat monotonic: REVOKED tidak boleh kembali ACTIVEMencegah sesi hidup kembali
IdempotencyKey yang sama dengan payload semantik yang sama harus mengembalikan hasil bisnis yang samaMembuat retry aman
Webhook CallbackEvent provider diproses paling banyak sekali secara semantikMencegah update status ganda

Secara implementasi, invariant ini biasanya dijaga dengan kombinasi:

  • unique constraint di database,
  • conditional update berdasarkan state saat ini,
  • transaction,
  • row locking bila perlu,
  • outbox/inbox table untuk event asynchronous.

Idempotency key: desain yang benar untuk auth

Idempotency pada auth tidak cukup dengan menyimpan key lalu mengembalikan respons lama secara buta. Anda perlu mendefinisikan ruang lingkup dan identitas operasi.

Apa yang harus menjadi ruang lingkup key

Idempotency key sebaiknya terkait dengan:

  • endpoint atau jenis operasi,
  • identitas principal atau attempt,
  • payload yang relevan secara semantik.

Contoh:

  • POST /auth/login: key unik per upaya login dari client.
  • POST /auth/attempts/{id}/verify-otp: key unik per attempt verifikasi.
  • POST /auth/refresh: key unik per permintaan rotasi refresh token.
  • POST /auth/revoke: key unik per operasi revoke.

Jangan izinkan satu key dipakai lintas endpoint. Jangan juga menganggap key yang sama valid jika payload semantiknya berbeda, misalnya OTP yang berbeda.

Data yang perlu disimpan

idempotency_records
- key
- operation_type
- scope_id
- request_hash
- status            -- IN_PROGRESS | COMPLETED | FAILED_REPLAYABLE
- response_code
- response_body
- resource_id
- created_at
- expires_at

request_hash berguna untuk mendeteksi jika client memakai key yang sama tetapi isi request berbeda. Respons yang aman:

{
  "error": {
    "code": "IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD",
    "message": "The same idempotency key cannot be reused with a different payload.",
    "retryable": false
  }
}

Status IN_PROGRESS saat timeout

Kasus sulit adalah ketika request pertama sedang diproses tetapi client sudah timeout lalu retry. Jika record idempotency masih IN_PROGRESS, ada dua pilihan:

  1. Kembalikan 409/202 dengan status operasi, lalu client melakukan polling.
  2. Tahan sebentar sampai hasil final tersedia, jika waktu proses singkat dan aman.

Untuk auth yang melibatkan OTP atau provider eksternal, pendekatan polling biasanya lebih stabil. Misalnya:

{
  "operation_id": "op_01JKL...",
  "state": "IN_PROGRESS",
  "next_action": "POLL_OPERATION_STATUS"
}

Ini lebih baik daripada memproses ulang request dari awal.

Webhook/callback auth: sumber edge case yang paling sering diabaikan

Banyak sistem auth modern bergantung pada pihak ketiga: provider OTP, push approval, magic link, atau identity provider enterprise. Begitu callback/webhook masuk, Anda memasuki dunia at-least-once delivery: event bisa terlambat, duplikat, atau tidak berurutan.

Masalah umum pada callback auth

  • Provider mengirim delivered setelah user sudah berhasil login.
  • Provider mengirim failed lalu delivered untuk message yang sama.
  • Webhook dikirim dua kali karena retry provider.
  • Callback datang setelah attempt kadaluarsa.

Karena itu, jangan biarkan webhook mengubah state sembarangan. Webhook harus diproses seperti event pada state machine.

Contoh aturan transisi callback

Delivery States:
- PENDING
- SENT
- DELIVERED
- FAILED
- EXPIRED

Allowed transitions:
PENDING   -> SENT
SENT      -> DELIVERED
SENT      -> FAILED
PENDING   -> FAILED
PENDING   -> EXPIRED
SENT      -> EXPIRED

Disallowed:
DELIVERED -> FAILED
FAILED    -> DELIVERED
EXPIRED   -> SENT

Jika provider mengirim event yang bertentangan dengan transisi yang diizinkan, jangan paksa state mundur. Simpan event-nya untuk audit, tandai sebagai ignored due to invalid transition, lalu biarkan state akhir tetap konsisten.

Inbox pattern untuk webhook

Untuk mencegah proses ganda:

  1. Verifikasi signature webhook.
  2. Simpan event mentah ke inbox table dengan provider_event_id unik.
  3. Jika event sudah pernah ada, akui sebagai duplikat dan hentikan.
  4. Proses event dalam transaction terpisah.
  5. Gunakan conditional update berbasis state saat ini.

Ini jauh lebih aman daripada langsung memperbarui tabel auth dari handler HTTP webhook.

Pseudocode transisi yang aman

Verifikasi OTP dengan idempotency dan state check

function verifyOtp(attemptId, otpCode, idemKey):
    existing = findIdempotency("VERIFY_OTP", attemptId, idemKey)
    if existing exists:
        return existing.response

    begin transaction

    createIdempotencyInProgress("VERIFY_OTP", attemptId, idemKey, hash(otpCode))

    attempt = select attempt for update where id = attemptId
    if attempt not found:
        failIdempotency("AUTH_ATTEMPT_NOT_FOUND")
        rollback
        return error

    if attempt.state == "TOKEN_ISSUED":
        failIdempotency("AUTH_ATTEMPT_FINALIZED")
        rollback
        return error

    if attempt.state != "CHALLENGE_SENT":
        failIdempotency("INVALID_AUTH_STATE")
        rollback
        return error

    if attempt.expires_at < now():
        update attempt set state = "EXPIRED"
        failIdempotency("OTP_EXPIRED")
        commit
        return error

    if !isValidOtp(attempt, otpCode):
        recordFailedOtpAttempt(attempt)
        failIdempotency("OTP_INVALID")
        commit
        return error

    update attempt set state = "VERIFIED"

    session = issueSessionAndTokens(attempt.user_id)
    update attempt set state = "TOKEN_ISSUED", session_id = session.id

    response = successResponse(session)
    completeIdempotency(response)
    commit
    return response

Poin pentingnya:

  • Selalu cek idempotency sebelum memproses.
  • Kunci resource utama saat transisi penting.
  • Gunakan update berbasis state saat ini, bukan overwrite buta.
  • Simpan hasil final agar retry bisa mengembalikan jawaban yang sama.

Refresh token dengan rotasi atomik

function refreshToken(refreshToken, idemKey):
    existing = findIdempotency("REFRESH", tokenFingerprint(refreshToken), idemKey)
    if existing exists:
        return existing.response

    begin transaction

    createIdempotencyInProgress("REFRESH", tokenFingerprint(refreshToken), idemKey)

    token = select refresh_session for update by token fingerprint
    if token not found:
        failIdempotency("REFRESH_TOKEN_INVALID")
        rollback
        return error

    if token.state != "ACTIVE":
        failIdempotency("REFRESH_TOKEN_ALREADY_USED")
        commit
        return error

    update token set state = "ROTATING"

    newSession = createNewSession(token.user_id)
    update token set state = "ROTATED", rotated_to = newSession.id

    response = issueNewTokens(newSession)
    completeIdempotency(response)
    commit
    return response

Jika revoke bisa berjalan paralel, tambahkan pengecekan state REVOKING atau REVOKED sebelum rotasi final diselesaikan.

Error code yang stabil dan ramah integrasi

Salah satu kesalahan umum adalah bergantung pada pesan teks yang berubah-ubah. Untuk kontrak API auth, gunakan error code yang stabil dan pisahkan dari message yang bisa lebih manusiawi.

Prinsip error code

  • Stabil lintas perubahan wording.
  • Spesifik cukup untuk automation.
  • Tidak membocorkan informasi sensitif.
  • Memiliki petunjuk apakah aman untuk retry.

Contoh error code yang berguna

{
  "error": {
    "code": "OTP_INVALID",
    "message": "The provided OTP code is invalid.",
    "retryable": true,
    "current_state": "CHALLENGE_SENT"
  }
}

Daftar kode yang umum dipakai:

  • AUTH_INVALID_CREDENTIALS
  • AUTH_ATTEMPT_NOT_FOUND
  • INVALID_AUTH_STATE
  • OTP_INVALID
  • OTP_EXPIRED
  • AUTH_ATTEMPT_FINALIZED
  • IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD
  • REFRESH_TOKEN_INVALID
  • REFRESH_TOKEN_ALREADY_USED
  • SESSION_REVOKED
  • REVOCATION_IN_PROGRESS
  • WEBHOOK_EVENT_DUPLICATE
  • WEBHOOK_EVENT_OUT_OF_ORDER

Untuk HTTP status, pilih yang konservatif dan mudah dipahami integrator. Jangan memindahkan makna bisnis penting hanya ke HTTP status. Sebagian besar integrator akan lebih bergantung pada body JSON yang stabil.

Mencegah aksi ganda saat client timeout atau webhook terlambat

Saat client timeout

Client timeout tidak berarti operasi gagal. Ini prinsip dasar yang sering dilanggar. Agar aman:

  • Setiap operasi mutasi auth harus punya idempotency key.
  • Server menyimpan hasil final operasi.
  • Jika operasi masih berjalan, sediakan endpoint status.
  • Client harus retry dengan key yang sama, bukan membuat request baru tanpa jejak.

Contoh endpoint status:

GET /auth/operations/op_01JKL...

{
  "operation_id": "op_01JKL...",
  "state": "COMPLETED",
  "result": {
    "attempt_id": "authatt_01HXYZ...",
    "state": "CHALLENGE_SENT",
    "next_action": "VERIFY_OTP"
  }
}

Saat webhook terlambat

Webhook terlambat tidak boleh membangkitkan kembali proses yang sudah final. Strateginya:

  • Beri setiap auth attempt dan delivery event waktu kadaluarsa yang jelas.
  • Webhook hanya boleh memengaruhi state yang masih relevan.
  • Event terlambat tetap disimpan untuk audit, tetapi diabaikan sebagai transisi bisnis.
  • Jika callback penting datang setelah timeout, client harus memulai attempt baru, bukan memaksa state lama hidup kembali.

Saat revoke dan refresh balapan

Ini edge case yang sering memicu bug keamanan. Misalnya satu request ingin refresh, request lain ingin revoke. Solusinya:

  • Gunakan lock pada record sesi atau refresh token.
  • Pastikan revoke bersifat monotonic dan final.
  • Definisikan prioritas: biasanya revoke menang atas refresh yang belum final.
  • Jangan terbitkan token baru jika state sudah masuk REVOKING atau REVOKED.

Trade-off dan keterbatasan pendekatan state machine

Pendekatan ini tidak gratis. Ada biaya desain dan operasional:

  • Lebih banyak tabel dan metadata: attempt, session, idempotency, inbox/outbox.
  • Lebih banyak transisi yang harus diuji: terutama edge case balapan.
  • Perlu disiplin kontrak: tim tidak boleh diam-diam menambah shortcut di endpoint.
  • Perlu observabilitas: log transisi, correlation id, dan audit event.

Namun trade-off ini layak untuk auth, karena area ini sangat sensitif terhadap keamanan, integritas sesi, dan pengalaman integrator.

Kesalahan implementasi yang paling sering terjadi

  • Menganggap retry sebagai request baru, bukan replay operasi lama.
  • Menerbitkan token sebelum state attempt ditandai final.
  • Menyimpan idempotency key tanpa request hash.
  • Membiarkan webhook langsung meng-overwrite status.
  • Menggunakan pesan error teks sebagai kontrak integrasi.
  • Menghapus record sementara terlalu cepat sehingga replay tidak bisa dijawab konsisten.
  • Tidak memisahkan state attempt auth dari state sesi token.

Debugging dan observabilitas

Jika ingin sistem auth berbasis state machine bisa dioperasikan dengan baik, siapkan data observabilitas sejak awal:

  • correlation_id per request,
  • idempotency_key di log,
  • attempt_id, session_id, dan operation_id,
  • log transisi: state lama, event, state baru,
  • alasan penolakan transisi,
  • jejak webhook: provider event id, waktu diterima, waktu diproses.

Untuk debugging race condition, event log transisi sering lebih berguna daripada log aplikasi umum. Anda ingin bisa menjawab: request mana yang lebih dulu, state apa yang dibaca, dan transisi mana yang ditolak.

Checklist implementasi

  1. Definisikan state eksplisit untuk login attempt, token session, revoke, dan callback delivery.
  2. Tentukan transisi yang diizinkan dan yang dilarang.
  3. Dokumentasikan invariant yang wajib dijaga.
  4. Tambahkan idempotency key pada semua endpoint mutasi auth.
  5. Simpan request hash untuk mendeteksi reuse key dengan payload berbeda.
  6. Simpan hasil final operasi agar replay bisa dijawab konsisten.
  7. Gunakan transaction dan conditional update pada transisi penting.
  8. Lindungi refresh token rotation dari balapan dengan locking atau CAS-style update.
  9. Pastikan revoke bersifat final dan monotonic.
  10. Proses webhook dengan inbox pattern dan deduplikasi event.
  11. Sediakan error code stabil dengan atribut retryable dan current_state.
  12. Sediakan endpoint status operasi jika ada proses async atau timeout panjang.
  13. Uji edge case: retry setelah timeout, OTP diverifikasi dua kali, refresh paralel, revoke vs refresh, webhook duplikat, webhook terlambat.
  14. Tambahkan audit log transisi dan correlation id.

Penutup

State machine untuk kontrak API auth yang tahan retry bukan sekadar pola desain yang rapi. Ini adalah cara untuk memastikan login, refresh token, verifikasi OTP, dan revoke tetap benar ketika jaringan gagal, client mengulang request, atau webhook datang tidak ideal. Dengan state yang eksplisit, transisi yang terbatas, invariant yang jelas, idempotency key yang dirancang baik, dan error code yang stabil, kontrak API auth menjadi jauh lebih mudah diintegrasikan dan lebih sulit rusak oleh edge case dunia nyata.

Jika Anda sedang memperbaiki API auth yang sering menghasilkan sesi ganda, OTP ganda, atau perilaku aneh setelah timeout, biasanya solusi utamanya bukan menambah retry logic di client. Solusinya adalah membuat alur auth menjadi eksplisit sebagai state machine, lalu menjadikan retry sebagai bagian resmi dari kontrak sistem.