Verifikasi formulir OTP SMS

Mengirimkan kode sandi sekali pakai (OTP) melalui SMS adalah alur umum untuk verifikasi nomor telepon, pemulihan akun, autentikasi langkah, dan konfirmasi pembayaran. Beralih antara browser dan aplikasi pesan SMS untuk menghafal atau menyalin-menempel kode multi-digit akan menimbulkan hambatan bagi pengguna, meningkatkan rasio berhenti, dan membuat pengguna rentan terhadap phishing di domain palsu.

Menggabungkan markup <form> semantik, <input autocomplete="one-time-code" inputmode="numeric">, pemformatan SMS terikat origin (@domain #code), dan WebOTP API (OTPCredential) akan menciptakan alur verifikasi berlapis yang ditingkatkan secara progresif:

  1. Ekstraksi dan pengiriman otomatis: Browser yang mendukung WebOTP API memverifikasi origin terhadap baris @domain #code dalam SMS, mengisi <input>, dan memicu verifikasi dengan sekali ketuk.
  2. Isi otomatis keyboard yang terikat asal: Browser dan sistem operasi yang mendukung autocomplete="one-time-code" (seperti Safari di iOS, iPadOS, dan macOS) mengurai pesan @domain #code dan menyarankan kode di panel isi otomatis keyboard hanya jika domainnya cocok.
  3. Entri manual yang lancar: Pengguna yang memasukkan kode dari perangkat lain akan menerima keypad virtual numerik melalui inputmode="numeric" tanpa risiko kegunaan <input type="number">.

Pertimbangan keamanan

OTP SMS memverifikasi kepemilikan nomor telepon dan memberikan verifikasi langkah tambahan yang praktis, tetapi pengiriman SMS dan nomor telepon dapat menjadi sasaran pertukaran SIM, daur ulang nomor, dan penyadapan. Selalu pasangkan isi otomatis sisi klien dan WebOTP API dengan format SMS @domain #code yang terikat origin agar browser dan OS memverifikasi bahwa domain target cocok sebelum menyarankan atau mengirimkan kode. Untuk autentikasi utama yang tahan phishing, pilih kunci sandi daripada kode berbasis SMS (lihat Orientasi Kunci Sandi).

Markup formulir semantik

Gabungkan kontrol verifikasi dalam elemen <form> semantik dengan method="POST" dan <button type="submit"> eksplisit. Selalu gunakan satu elemen <input>, bukan memisahkan digit di beberapa kotak.

  • Gunakan type="text" dengan inputmode="numeric": Jangan pernah gunakan type="number" untuk kode OTP. Kode sandi sekali pakai adalah string ID yang dapat berisi angka nol di awal (seperti 012345), bukan jumlah yang dapat dihitung. Penggunaan type="number" akan menambahkan tombol spinner penambahan/pengurangan, dapat menghapus angka nol di awal, dan memungkinkan perubahan nilai roda scroll yang tidak disengaja. Hindari juga type="tel", yang merupakan solusi lama yang ditujukan untuk nomor telepon lengkap. Pasangkan type="text" dengan inputmode="numeric" untuk menampilkan keyboard virtual khusus angka di perangkat seluler.
  • Tambahkan autocomplete="one-time-code": Menginstruksikan browser dan sistem operasi untuk menyarankan kode verifikasi SMS yang masuk di panel isi otomatis saat <input> difokuskan.
  • Tempatkan petunjuk instruksional di atas <input>: Kaitkan <label> yang terlihat menggunakan for dan id, dan tempatkan petunjuk format apa pun (ditautkan melalui aria-describedby) secara visual di atas <input> sehingga popover isi otomatis dan keyboard virtual seluler tidak pernah menutupi petunjuk.
  • Jangan pisahkan digit di beberapa elemen <input>: Pola OTP multi-kotak (enam input maxlength="1" terpisah) merusak isi otomatis autocomplete="one-time-code" native di banyak browser, menghambat salin-tempel, dan mempersulit navigasi pembaca layar. Jika diperlukan desain visual tersegmentasi, beri gaya pada satu <input> dengan letter-spacing dan font monospace.
<form id="otp-form" action="/verify-otp" method="POST">
  <div class="form-group">
    <label for="otp-input">Verification code</label>
    <!-- Place the hint above the input so autofill popovers and mobile keyboards do not obscure it -->
    <p id="otp-format-hint" class="field-hint">
      Enter the 6-digit code sent to your phone.
    </p>
    <!-- MANDATORY: Use a single input with type="text", inputmode="numeric", and autocomplete="one-time-code" -->
    <input
      type="text"
      id="otp-input"
      name="otp"
      inputmode="numeric"
      autocomplete="one-time-code"
      pattern="\d{6}"
      maxlength="6"
      aria-describedby="otp-format-hint"
      required>
  </div>
  <button type="submit">Verify code</button>
</form>
/* Optional: Style a single input to visually space digits without splitting the DOM element */
#otp-input {
  font-family: ui-monospace, monospace;
  font-size: 1.25rem; /* Example value: must be at least 1rem (16px) to prevent mobile browser auto-zoom */
  letter-spacing: 0.35em;
  min-height: 48px;
  padding: 0.5rem 0.75rem;
  box-sizing: border-box;
}

Format pesan SMS terikat origin

Agar WebOTP API dan isi otomatis autocomplete="one-time-code" yang terikat domain Safari dapat memverifikasi bahwa kode tersebut milik situs Anda, layanan SMS backend Anda harus memformat baris terakhir pesan teks dengan domain asal yang diawali dengan @ dan OTP yang diawali dengan #:

Your verification code is 123456.

@example.com #123456

Terapkan aturan pemformatan berikut pada pesan SMS keluar:

  • Sertakan teks yang dapat dibaca manusia terlebih dahulu: Selalu mulai SMS dengan kalimat yang jelas dan dapat dibaca manusia yang berisi kode, sehingga pengguna yang mengetiknya secara manual di perangkat lain dapat membacanya dengan mudah.
  • Tempatkan @<domain> #<otp> di baris terakhir: Karakter @ harus menjadi karakter pertama dari baris terakhir, tanpa baris tambahan atau teks tanda tangan setelahnya.
  • Hanya gunakan domain host: Jangan sertakan skema URL (https:// atau http://), port (:8080), atau jalur (/verify) dalam token @domain.
  • Pisahkan token dengan satu spasi: Tempatkan tepat satu spasi di antara @<domain> dan #<otp>, dan jangan pernah menyertakan spasi kosong di dalam domain atau di antara # dan kode.

Mengintegrasikan WebOTP API ke dalam alur verifikasi Anda

WebOTP API (OTPCredential) memerlukan Konteks Aman (https:// dalam produksi, atau http://localhost selama pengembangan lokal). Daripada menempelkan skrip DOMContentLoaded yang tidak terhubung yang mengkueri DOM dan memanggil form.submit(), integrasikan navigator.credentials.get() langsung ke dalam siklus proses dan pengendali pengiriman UI verifikasi Anda:

  • Kaitkan siklus proses permintaan ke langkah OTP aktif: Mulai proses pemantauan OTP saat input verifikasi dirender dan aktif (misalnya, saat langkah OTP dipasang setelah pengguna meminta kode SMS, dan lagi setiap kali pengguna mengklik tombol "Kirim ulang kode").
  • Mengelola AbortController di semua jalur keluar: Teruskan AbortSignal (signal: ac.signal) ke navigator.credentials.get(). Hubungi ac.abort() kapan saja:
    • Pengguna mengirimkan formulir secara manual (atau mengirimkan kode yang diisi melalui autocomplete="one-time-code").
    • Kode SMS baru diminta ("Kirim ulang kode"), yang membatalkan pengontrol sebelumnya sebelum membuat pengontrol baru.
    • Komponen, dialog, atau rute verifikasi dilepas.
  • Memicu pengendali pengiriman aplikasi (form.requestSubmit()): Saat navigator.credentials.get() di-resolve dengan otp, tetapkan otpInput.value = otp.code dan panggil form.requestSubmit() (atau panggil fungsi verifikasi Anda secara langsung). JANGAN panggil form.submit(), karena form.submit() mengabaikan pemroses peristiwa submit (pengendali event.preventDefault() / fetch()) dan melewati validasi batasan native.
const otpForm = document.getElementById('otp-form');
const otpInput = document.getElementById('otp-input');

let otpAbortController = null;

function stopSmsOtpListener() {
  if (otpAbortController) {
    otpAbortController.abort();
    otpAbortController = null;
  }
}

async function startSmsOtpListener() {
  // Feature-detect WebOTP support
  if (!otpInput || !('OTPCredential' in window)) {
    return;
  }

  // Cancel any previous pending WebOTP request (e.g., when re-sending an SMS code)
  stopSmsOtpListener();
  const ac = new AbortController();
  otpAbortController = ac;

  try {
    const otp = await navigator.credentials.get({
      otp: { transport: ['sms'] },
      signal: ac.signal,
    });

    if (!otp) return;
    otpInput.value = otp.code;

    // Trigger standard form submission so submit event listeners and validation run
    if (otpForm) {
      otpForm.requestSubmit();
    }
  } catch (err) {
    // Expected when aborted by manual submission, resend, unmount, or user dismissal
    if (err && err.name === 'AbortError') {
      return;
    }
    console.error('WebOTP verification error:', err);
  }
}

otpForm.addEventListener('submit', async (event) => {
  // MANDATORY: Abort any in-flight WebOTP request when the form is submitted
  stopSmsOtpListener();

  // Application-specific submission logic (e.g., AJAX fetch or native POST)
  event.preventDefault();
  const formData = new FormData(otpForm);
  // await fetch(otpForm.action, { method: 'POST', body: formData });
});

// Start listening when the OTP verification step becomes active
startSmsOtpListener();

Verifikasi iframe lintas origin

Menerima OTP SMS di dalam <iframe> lintas origin dibatasi secara default dan terutama digunakan untuk alur konfirmasi pembayaran tersemat (seperti verifikasi 3D Secure tempat shop.example menyematkan tantangan penerbit dari bank.example). Mendukung WebOTP di dalam <iframe> lintas origin memerlukan tiga konfigurasi terkoordinasi:

  1. Ikat kedua origin di baris terakhir SMS: Cantumkan domain penyematan tingkat teratas terlebih dahulu (@shop.example), diikuti dengan #<otp>, diikuti dengan spasi dan domain iframe yang disematkan (@bank.example):

    Your verification code is 123456.
    
    @shop.example #123456 @bank.example
    
  2. Kirim header respons HTTP Permissions-Policy: Delegasikan otp-credentials ke origin iframe lintas origin di header HTTP dokumen induk:

    Permissions-Policy: otp-credentials=(self "https://bank.example")
    
  3. Tambahkan allow="otp-credentials" ke elemen <iframe>: Berikan kebijakan izin secara eksplisit pada tag <iframe> di halaman penyematan dan sertakan atribut title deskriptif untuk pembaca layar:

<iframe
  src="https://bank.example/verify"
  allow="otp-credentials"
  title="Payment verification">
</iframe>

Di dalam dokumen <iframe> yang ditayangkan oleh https://bank.example, jalankan markup <form> dan siklus proses verifikasi navigator.credentials.get({ otp: { transport: ['sms'] }, signal: ac.signal }) yang sama seperti yang ditunjukkan di atas.

Strategi penggantian

Penggantian WebOTP API

Jika browser target Anda tidak mendukung WebOTP API (OTPCredential), jangan muat polyfill JavaScript pihak ketiga, karena membaca pesan SMS yang masuk memerlukan integrasi tingkat sistem operasi. Sebagai gantinya, andalkan progressive enhancement tiga tingkat yang disertakan dalam markup <input>:

  • Pengalaman progressive enhancement: Lindungi panggilan navigator.credentials.get({ otp: { transport: ['sms'] } }) di balik if ('OTPCredential' in window). Jika OTPCredential tidak tersedia:
    1. Browser yang mendukung autocomplete="one-time-code" (seperti Safari di iOS 14+, iPadOS, dan macOS dengan relai SMS iCloud) mengurai SMS @domain #code dan menampilkan kode di panel isi otomatis keyboard sistem saat <input> difokuskan.
    2. Semua browser lainnya akan kembali ke entri manual di satu <input type="text" inputmode="numeric" pattern="\d{6}" maxlength="6" required>, menampilkan keypad numerik di perangkat seluler, dan mengirimkan melalui <button type="submit">.

Penggantian Kebijakan Izin

Jika browser tidak mendukung pendelegasian otp-credentials melalui Permissions-Policy dan <iframe allow="otp-credentials">, <form> verifikasi yang disematkan di dalam <iframe> lintas origin akan menurun secara halus ke saran keyboard autocomplete="one-time-code" dan input numerik manual tanpa menampilkan pengecualian yang tidak tertangani.

Dukungan Inputmode dan AbortController

Atribut inputmode HTML dan AbortController (AbortSignal) didukung di seluruh browser modern dan tidak memerlukan kode polyfill atau penggantian.