Hardening Reset Password API bukan sekadar mengirim link reset lewat email. Titik rawan utamanya ada pada token, masa berlaku token, cara API merespons permintaan, dan bagaimana sistem membatasi penyalahgunaan. Jika desainnya lemah, attacker bisa melakukan user enumeration, brute force token, replay token lama, atau mengambil alih akun melalui log dan analytics yang bocor.

Untuk backend modern, pola yang aman biasanya terdiri dari dua endpoint: request-reset untuk meminta token, dan confirm-reset untuk menukar token menjadi password baru. Kuncinya adalah token sekali pakai, TTL pendek, hash token di database, respons generik, timing yang konsisten, rate limit per IP dan identitas, serta rotasi sesi setelah password berubah. Di bawah ini adalah panduan implementasi yang praktis dan bisa diadaptasi ke berbagai stack backend.

Tujuan desain alur reset password yang aman

Alur reset password harus memenuhi beberapa tujuan sekaligus:

  • Tidak membocorkan apakah akun ada atau tidak melalui pesan error, status code, atau waktu respons.
  • Mencegah token dicuri dari database, log, analytics, atau referer.
  • Membatasi jendela serangan dengan TTL token yang pendek dan cooldown permintaan.
  • Mencegah replay dengan invalidasi token setelah dipakai.
  • Memudahkan audit melalui event log yang aman tanpa menyimpan data sensitif.
  • Mengurangi dampak akun yang sudah dibajak dengan mencabut sesi lama saat password diganti.

Kesalahan paling umum justru terjadi pada detail implementasi: token disimpan plaintext, TTL terlalu lama, API memberi respons berbeda untuk email yang tidak terdaftar, atau token ikut masuk ke log URL.

Desain endpoint: request-reset dan confirm-reset

1. Endpoint request-reset

Endpoint ini menerima identitas pengguna, biasanya email atau username yang sudah dinormalisasi. Tugasnya bukan mengonfirmasi bahwa user ada, melainkan memproses permintaan secara aman dan mengembalikan respons generik.

Contoh request:

POST /auth/password-reset/request
Content-Type: application/json

{
  "email": "user@example.com"
}

Respons yang disarankan:

202 Accepted

{
  "message": "Jika akun terdaftar, instruksi reset password akan dikirim."
}

Kenapa 202 atau 200 dengan pesan generik lebih aman? Karena attacker tidak bisa membedakan apakah email valid, tidak terdaftar, diblokir, atau sedang cooldown. Semua kasus terlihat sama dari luar.

2. Endpoint confirm-reset

Endpoint ini menerima token reset, password baru, dan idealnya metadata yang dibutuhkan untuk kebijakan keamanan. Validasi harus ketat, tetapi respons tetap tidak boleh membocorkan detail sensitif.

Contoh request:

POST /auth/password-reset/confirm
Content-Type: application/json

{
  "token": "raw-reset-token-from-email",
  "new_password": "S3cur3!Passw0rd",
  "new_password_confirmation": "S3cur3!Passw0rd"
}

Respons sukses:

200 OK

{
  "message": "Password berhasil diperbarui."
}

Respons gagal yang aman:

400 Bad Request

{
  "message": "Token tidak valid atau sudah kedaluwarsa."
}

Jangan pisahkan pesan seperti token tidak ditemukan, token sudah dipakai, atau akun tidak ada ke publik. Detail seperti itu boleh masuk ke audit internal, bukan ke respons API.

Token reset yang aman: sekali pakai, acak kuat, dan disimpan dalam bentuk hash

Mengapa token tidak boleh disimpan plaintext

Jika database bocor dan token disimpan plaintext, attacker bisa langsung menukar token tersebut menjadi reset password. Token reset harus diperlakukan seperti secret jangka pendek. Simpan hanya hash token, bukan token mentah.

Pola yang umum:

  1. Generate token acak dengan entropi tinggi menggunakan CSPRNG.
  2. Kirim raw token ke user melalui email atau channel lain.
  3. Simpan hanya hash(token) di database.
  4. Saat confirm-reset, hash token yang diterima lalu cocokkan dengan yang ada di database.

Gunakan pembanding konstan waktu untuk mencegah kebocoran melalui timing saat membandingkan nilai yang sensitif.

Format token

Token reset sebaiknya:

  • Acak, bukan JWT yang berisi klaim sensitif.
  • Tidak mengandung informasi yang bisa ditebak seperti user ID plaintext.
  • Cukup panjang untuk mencegah brute force online.
  • Digunakan sekali saja.

Untuk banyak sistem, token acak berbasis byte dari CSPRNG yang kemudian diencode dengan URL-safe base64 atau hex sudah cukup. Tidak perlu format yang rumit jika tetap ada record server-side untuk validasi dan invalidasi.

Kenapa token sekali pakai penting

Jika token tetap valid setelah sukses dipakai, siapa pun yang masih memegang salinannya dapat melakukan replay. Karena itu, setelah password berhasil diganti, token harus ditandai used atau langsung dihapus secara atomik dalam transaksi yang sama dengan update password.

TTL pendek, cooldown, dan pembatasan percobaan

TTL token reset

TTL pendek memperkecil jendela serangan ketika token bocor dari inbox, perangkat, atau jaringan yang sudah terkompromi. Secara praktik, pilih TTL yang cukup untuk dipakai pengguna normal, tetapi tidak terlalu longgar. Hindari TTL berjam-jam atau berhari-hari kecuali ada kebutuhan operasional yang sangat jelas.

Prinsip yang aman:

  • Token reset memiliki expired_at yang eksplisit.
  • Validasi expiry dilakukan di backend, bukan hanya di UI.
  • Token yang kedaluwarsa dibersihkan secara periodik, tetapi pengecekan expiry tetap wajib saat verifikasi.

Cooldown request-reset

Tanpa cooldown, attacker dapat membanjiri inbox korban dengan email reset, atau menghabiskan resource sistem. Terapkan jeda antar permintaan untuk identitas yang sama.

Contoh kebijakan:

  • Satu identitas hanya boleh meminta token baru setiap beberapa menit.
  • Permintaan baru dapat menginvalidasi token lama, atau ditolak secara diam-diam sambil tetap mengembalikan respons generik.

Trade-off-nya:

  • Invalidasi token lama saat request baru lebih aman terhadap token lama yang mungkin bocor.
  • Membiarkan satu token aktif sampai TTL habis lebih sederhana, tetapi perlu kontrol agar tidak ada banyak token aktif untuk satu user.

Rate limit per IP dan per identitas

Rate limit hanya per IP tidak cukup karena attacker bisa memakai banyak IP. Rate limit hanya per email juga tidak cukup karena bisa dipakai untuk menyerang banyak akun dari satu sumber. Gabungkan keduanya:

  • Per IP: membatasi abuse massal dari satu sumber.
  • Per identitas: membatasi spam ke satu akun tertentu.
  • Per kombinasi IP + identitas: berguna untuk mendeteksi pola yang lebih spesifik.

Untuk endpoint confirm-reset, batasi percobaan token yang gagal. Meskipun token acak kuat sulit ditebak, rate limit tetap penting untuk menahan brute force online dan noise di log.

Mencegah user enumeration: respons generik, timing konsisten, dan perilaku yang seragam

Respons generik

User enumeration terjadi saat attacker bisa mengetahui apakah suatu email terdaftar. Sumber bocornya sering sederhana:

  • Pesan berbeda: email tidak ditemukan versus email terkirim.
  • Status code berbeda: 404 untuk user tidak ada, 202 untuk user ada.
  • Field respons tambahan hanya muncul untuk akun valid.

Solusinya: kembalikan respons yang sama untuk semua hasil yang terlihat dari luar.

Untuk endpoint request-reset, perlakukan input valid tetapi akun tidak ditemukan sebagai berhasil diproses dari sudut pandang klien.

Timing yang konsisten

Enumeration juga bisa terjadi melalui perbedaan waktu respons. Misalnya, untuk email yang ada sistem melakukan lookup, generate token, simpan ke DB, enqueue email; sedangkan untuk email yang tidak ada sistem langsung return. Selisih ini bisa diukur.

Pendekatan yang lebih aman:

  • Lakukan normalisasi dan jalur kode yang mirip untuk semua kasus.
  • Gunakan pekerjaan asynchronous untuk pengiriman email agar respons request-reset lebih seragam.
  • Tambahkan jitter kecil atau waktu minimum respons bila memang perlu, tetapi jangan menjadikannya satu-satunya perlindungan.

Timing yang benar-benar identik sulit dijamin di sistem nyata, jadi fokus pada pengurangan perbedaan yang kasar dan hindari percabangan yang sangat berbeda.

Logging dan observability yang aman

Log bisa menjadi sumber enumeration maupun kebocoran token. Hal yang perlu dihindari:

  • Mencatat email lengkap dan token mentah di log aplikasi.
  • Menyertakan token dalam URL query string yang kemudian terekam di access log, analytics, browser history, atau referer.
  • Mengirim token ke sistem pihak ketiga yang tidak perlu tahu.

Praktik yang lebih aman:

  • Masking identitas, misalnya simpan hash email ter-normalisasi untuk korelasi.
  • Log event dengan request_id, IP, user-agent, dan hasil high-level tanpa data sensitif.
  • Gunakan POST body untuk confirm-reset, bukan query string.
  • Sanitasi payload sensitif di middleware logging.

Skema tabel yang disarankan

Berikut contoh skema tabel yang cukup umum dan mudah diaudit. Sesuaikan tipe data dengan database yang digunakan.

Table: password_reset_tokens
- id                   bigint / uuid (PK)
- user_id              bigint / uuid (FK users.id)
- token_hash           varchar(...) NOT NULL
- purpose              varchar(...) NOT NULL   -- mis. "password_reset"
- created_at           timestamp NOT NULL
- expires_at           timestamp NOT NULL
- used_at              timestamp NULL
- invalidated_at       timestamp NULL
- requested_ip         varchar(...) NULL
- requested_user_agent varchar(...) NULL
- consumed_ip          varchar(...) NULL
- consumed_user_agent  varchar(...) NULL
- attempt_count        integer NOT NULL DEFAULT 0

Index yang disarankan:
- index(user_id, purpose)
- unique(token_hash)
- index(expires_at)
- index(user_id, used_at, invalidated_at)

Jika ingin membatasi satu token aktif per user untuk tujuan reset password, enforce kebijakan di level aplikasi atau database. Misalnya, sebelum membuat token baru, invalidasi token aktif lama untuk user yang sama.

Tabel audit event

Table: security_audit_events
- id              bigint / uuid (PK)
- event_type      varchar(...) NOT NULL
- actor_user_id   bigint / uuid NULL
- subject_user_id bigint / uuid NULL
- request_id      varchar(...) NULL
- ip              varchar(...) NULL
- user_agent      varchar(...) NULL
- metadata_json   json / text NULL
- created_at      timestamp NOT NULL

Contoh event_type:

  • password_reset_requested
  • password_reset_request_rate_limited
  • password_reset_token_consumed
  • password_reset_token_invalid
  • password_changed_via_reset
  • sessions_revoked_after_password_change

Pastikan metadata_json tidak menyimpan token plaintext atau password.

Alur verifikasi yang direkomendasikan

Alur request-reset

  1. Terima payload dan lakukan validasi dasar.
  2. Normalisasi identitas, misalnya trim dan lowercase untuk email jika kebijakan sistem memang case-insensitive.
  3. Terapkan rate limit per IP, per identitas, dan cooldown.
  4. Lookup user secara internal.
  5. Jika user ada dan memenuhi syarat, invalidasi token aktif lama bila diperlukan.
  6. Generate token acak, hitung hash-nya, simpan record token dengan expiry pendek.
  7. Kirim token lewat email secara asynchronous.
  8. Tulis audit event yang aman.
  9. Kembalikan respons generik yang sama untuk semua hasil eksternal.

Alur confirm-reset

  1. Terima payload dan validasi struktur.
  2. Terapkan rate limit untuk percobaan verifikasi.
  3. Hash token dari request.
  4. Lookup record berdasarkan token_hash.
  5. Pastikan token ada, belum kedaluwarsa, belum dipakai, belum diinvalidasi, dan purpose benar.
  6. Dalam transaksi atomik: tandai token sebagai used, update password user dengan password hash yang aman, tingkatkan versi sesi atau revoke sesi lama.
  7. Tulis audit event.
  8. Kembalikan hasil generik pada kegagalan token.

Langkah atomik penting untuk mencegah kondisi balapan jika token yang sama dikirim dua kali hampir bersamaan.

Pseudo-code service dan middleware

Service request-reset

function requestPasswordReset(input, context) {
  validate(input.email)

  const normalizedEmail = normalizeEmail(input.email)

  rateLimitOrThrow({
    key: `pwreset:req:ip:${context.ip}`,
    limit: IP_LIMIT
  })

  rateLimitOrThrow({
    key: `pwreset:req:id:${hash(normalizedEmail)}`,
    limit: IDENTITY_LIMIT
  })

  const user = findUserByEmail(normalizedEmail)

  if (user && !isCooldownActive(user.id, 'password_reset')) {
    invalidateActiveResetTokens(user.id)

    const rawToken = generateSecureRandomToken()
    const tokenHash = hashToken(rawToken)

    savePasswordResetToken({
      userId: user.id,
      tokenHash,
      purpose: 'password_reset',
      expiresAt: nowPlusShortTTL(),
      requestedIp: context.ip,
      requestedUserAgent: context.userAgent
    })

    enqueueResetEmail({
      to: user.email,
      token: rawToken
    })

    writeAuditEvent('password_reset_requested', {
      subjectUserId: user.id,
      requestId: context.requestId,
      ip: context.ip
    })
  } else {
    writeAuditEvent('password_reset_requested_unknown_or_suppressed', {
      requestId: context.requestId,
      ip: context.ip,
      identityHash: hash(normalizedEmail)
    })
  }

  return genericAcceptedResponse()
}

Service confirm-reset

function confirmPasswordReset(input, context) {
  validate(input.token, input.newPassword, input.newPasswordConfirmation)
  ensurePasswordsMatch(input.newPassword, input.newPasswordConfirmation)
  enforcePasswordPolicy(input.newPassword)

  rateLimitOrThrow({
    key: `pwreset:confirm:ip:${context.ip}`,
    limit: CONFIRM_IP_LIMIT
  })

  const tokenHash = hashToken(input.token)
  const record = findResetTokenByHash(tokenHash)

  if (!record || record.purpose !== 'password_reset') {
    writeAuditEvent('password_reset_token_invalid', {
      requestId: context.requestId,
      ip: context.ip
    })
    throw genericInvalidTokenError()
  }

  if (record.usedAt || record.invalidatedAt || record.expiresAt < now()) {
    writeAuditEvent('password_reset_token_rejected', {
      subjectUserId: record.userId,
      requestId: context.requestId,
      ip: context.ip
    })
    throw genericInvalidTokenError()
  }

  transaction(() => {
    markTokenUsed(record.id, {
      usedAt: now(),
      consumedIp: context.ip,
      consumedUserAgent: context.userAgent
    })

    updateUserPassword(record.userId, hashPassword(input.newPassword))
    revokeAllSessions(record.userId)
    bumpSessionVersion(record.userId)

    writeAuditEvent('password_changed_via_reset', {
      subjectUserId: record.userId,
      requestId: context.requestId,
      ip: context.ip
    })
  })

  return successResponse()
}

Middleware sanitasi log

function sanitizeRequestForLogging(req) {
  const clone = shallowCopy(req)

  if (clone.body) {
    if (clone.body.password) clone.body.password = '[REDACTED]'
    if (clone.body.new_password) clone.body.new_password = '[REDACTED]'
    if (clone.body.new_password_confirmation) clone.body.new_password_confirmation = '[REDACTED]'
    if (clone.body.token) clone.body.token = '[REDACTED]'
  }

  if (clone.query && clone.query.token) {
    clone.query.token = '[REDACTED]'
  }

  return clone
}

Walaupun pseudo-code di atas generik, idenya konsisten untuk hampir semua framework: validasi lebih dulu, batasi percobaan, jangan pernah menyimpan token mentah, dan selesaikan update password + invalidasi token + pencabutan sesi dalam satu jalur yang aman.

Validasi payload dan kebijakan input

Request-reset

Validasi yang perlu ada:

  • Field identitas wajib ada.
  • Panjang input wajar, agar tidak dipakai untuk abuse.
  • Normalisasi dilakukan secara konsisten sebelum lookup dan sebelum perhitungan key rate limit.

Jangan terlalu agresif menolak format email yang sebenarnya valid bila sistem memang menerima variasi tertentu. Fokus pada validasi yang cukup untuk keamanan dan konsistensi, bukan validasi yang terlalu sempit.

Confirm-reset

Validasi yang perlu ada:

  • Token wajib ada dan panjangnya masuk akal.
  • Password baru memenuhi kebijakan minimal sistem.
  • Konfirmasi password cocok.
  • Opsional: tolak password yang sama dengan password lama jika kebijakan sistem mewajibkan.

Jika Anda menjalankan kebijakan password yang kuat, jangan lupa bahwa reset password sering menjadi jalur alternatif yang luput dari aturan yang diterapkan di endpoint change-password biasa.

Rotasi sesi setelah password berubah

Mengubah password tanpa mencabut sesi lama adalah celah serius. Jika attacker sudah memiliki session cookie atau refresh token, ia bisa tetap aktif walau password korban sudah diganti.

Setelah reset password berhasil:

  • Cabut semua sesi aktif, refresh token, atau device token yang masih valid.
  • Jika sistem memakai session version atau token version, naikkan versinya agar token lama tidak lagi diterima.
  • Pertimbangkan memberi notifikasi keamanan ke pengguna bahwa password telah diubah.

Trade-off yang umum adalah kenyamanan pengguna versus keamanan. Untuk alur reset password, default yang aman biasanya adalah revoke semua sesi karena reset password umumnya dipicu saat akses akun diragukan atau lupa password.

Audit event, monitoring, dan debugging

Apa yang perlu dicatat

Audit yang berguna biasanya mencakup:

  • Waktu request-reset dan confirm-reset.
  • IP dan user-agent.
  • Request ID untuk korelasi.
  • Subject user ID jika sudah diketahui internal.
  • Hasil high-level: diterima, rate-limited, token invalid, token expired, password changed, sessions revoked.

Hindari menyimpan:

  • Password mentah.
  • Token reset mentah.
  • Data PII penuh jika tidak diperlukan untuk investigasi.

Debugging masalah umum

  • Token selalu dianggap invalid: pastikan normalisasi input token konsisten, hashing sama antara saat create dan verify, dan tidak ada encoding yang berubah saat token diklik dari email client.
  • Email reset tidak terkirim tetapi API sukses: ini bisa terjadi jika pengiriman asynchronous gagal. Pastikan antrean email memiliki monitoring dan retry yang aman.
  • Link reset kadang expired terlalu cepat: cek sinkronisasi waktu server dan timezone handling untuk expires_at.
  • Rate limit terlalu agresif: evaluasi key rate limiting, terutama untuk jaringan NAT besar atau kantor yang berbagi IP publik.

Kesalahan umum yang harus dihindari

  • Menyimpan token plaintext di database. Jika DB bocor, token bisa langsung dipakai.
  • TTL terlalu lama. Token yang masih valid berjam-jam atau berhari-hari memperbesar risiko replay dan penyalahgunaan.
  • Tidak menginvalidasi token setelah dipakai. Ini membuka celah replay.
  • Respons API berbeda untuk akun ada/tidak ada. Ini memicu user enumeration.
  • Token ada di query string lalu bocor ke access log, analytics, atau referer.
  • Tidak ada rate limit dan cooldown. Akibatnya mudah disalahgunakan untuk spam dan brute force.
  • Tidak mencabut sesi lama setelah password berubah. Attacker yang sudah login tetap bertahan.
  • Reset password bypass kebijakan password. Endpoint reset sering luput dari policy yang diterapkan di tempat lain.
  • Logging terlalu detail. Token, email lengkap, dan payload sensitif masuk ke log production.

Checklist implementasi hardening reset password API

  • Gunakan dua endpoint terpisah: request-reset dan confirm-reset.
  • Generate token acak dari CSPRNG dengan entropi tinggi.
  • Simpan hanya hash token, jangan token mentah.
  • Terapkan TTL pendek dan verifikasi expiry di backend.
  • Pastikan token sekali pakai dan diinvalidasi secara atomik setelah sukses.
  • Batasi token aktif per user atau invalidasi token lama saat request baru.
  • Kembalikan respons generik untuk mencegah enumeration.
  • Kurangi perbedaan timing antar jalur sukses dan gagal.
  • Terapkan rate limit per IP, per identitas, dan cooldown.
  • Sanitasi log, body, query string, dan event telemetry.
  • Jangan kirim token lewat channel atau analytics yang tidak perlu.
  • Catat audit event yang aman dan dapat dikorelasikan.
  • Setelah password berubah, revoke sesi lama dan rotasi session/token version.
  • Uji kondisi balapan, expiry, token replay, dan behavior rate limit.

Penutup

Reset password adalah jalur pemulihan akun, tetapi juga salah satu jalur serangan yang paling sering disasar. Karena itu, Hardening Reset Password API: Token, TTL, dan Anti Enumeration harus diperlakukan sebagai desain keamanan inti, bukan fitur tambahan. Implementasi yang baik biasanya sederhana secara konsep: token acak sekali pakai, hash di database, TTL pendek, invalidasi setelah dipakai, respons generik, pembatasan percobaan, audit event yang aman, dan rotasi sesi setelah password berubah.

Jika Anda hanya memperbaiki tiga hal terlebih dulu, mulailah dari ini: jangan simpan token plaintext, jangan bocorkan keberadaan user lewat respons API, dan cabut semua sesi setelah reset password berhasil. Tiga langkah itu saja sudah menutup banyak celah yang paling sering muncul di sistem produksi.