การยืนยันแบบฟอร์ม OTP ทาง SMS

การส่งรหัสผ่านแบบใช้งานครั้งเดียว (OTP) ผ่าน SMS เป็นขั้นตอนที่พบบ่อยสำหรับการยืนยันหมายเลขโทรศัพท์ การกู้คืนบัญชี การตรวจสอบสิทธิ์แบบ Step-up และการยืนยันการชำระเงิน การสลับไปมาระหว่างเบราว์เซอร์กับแอปรับส่งข้อความ SMS เพื่อจดจำหรือคัดลอกและวางรหัสหลายหลักทำให้ผู้ใช้เกิดความยุ่งยาก เพิ่มอัตราการออกจากเว็บไซต์ และทำให้ผู้ใช้เสี่ยงต่อฟิชชิงในโดเมนที่มีการปลอมแปลง

การรวมมาร์กอัป <form> เชิงความหมาย, <input autocomplete="one-time-code" inputmode="numeric">, การจัดรูปแบบ SMS ที่เชื่อมโยงกับต้นทาง (@domain #code) และ WebOTP API (OTPCredential) จะสร้างขั้นตอนการยืนยันที่ได้รับการเพิ่มประสิทธิภาพแบบต่อเนื่องแบบเป็นชั้นๆ ดังนี้

  1. การดึงข้อมูลและการส่งโดยอัตโนมัติ: เบราว์เซอร์ที่รองรับ WebOTP API จะยืนยันต้นทางกับบรรทัด @domain #code ใน SMS, ป้อน <input> และทริกเกอร์การยืนยันด้วยการแตะเพียงครั้งเดียว
  2. การป้อนข้อความอัตโนมัติบนแป้นพิมพ์ที่เชื่อมโยงกับต้นทาง: เบราว์เซอร์และระบบปฏิบัติการที่รองรับ autocomplete="one-time-code" (เช่น Safari บน iOS, iPadOS และ macOS) จะแยกวิเคราะห์ข้อความ @domain #code และแนะนำรหัสในแถบป้อนข้อความอัตโนมัติบนแป้นพิมพ์เฉพาะเมื่อโดเมนตรงกันเท่านั้น
  3. การป้อนข้อมูลด้วยตนเองที่ราบรื่น: ผู้ใช้ที่ป้อนรหัสจากอุปกรณ์อื่นจะได้รับปุ่มกดเสมือนที่เป็นตัวเลขผ่าน inputmode="numeric" โดยไม่มีอันตรายด้านความสามารถในการใช้งานของ <input type="number">

ข้อควรพิจารณาด้านความปลอดภัย

OTP ทาง SMS จะยืนยันการเป็นเจ้าของหมายเลขโทรศัพท์และให้การยืนยันแบบ Step-up ที่สะดวก แต่การส่ง SMS และหมายเลขโทรศัพท์อาจเสี่ยงต่อการสลับซิม การรีไซเคิลหมายเลข และการดักฟัง จับคู่การป้อนข้อความอัตโนมัติฝั่งไคลเอ็นต์และ WebOTP API กับรูปแบบ SMS @domain #code ที่เชื่อมโยงกับต้นทางเสมอ เพื่อให้เบราว์เซอร์และระบบปฏิบัติการยืนยันว่าโดเมนเป้าหมายตรงกันก่อนที่จะแนะนำหรือส่งรหัส สำหรับการตรวจสอบสิทธิ์หลักที่ทนทานต่อฟิชชิง ให้เลือกใช้พาสคีย์แทนรหัสที่ส่งทาง SMS (ดูการทำความเข้าใจพาสคีย์)

มาร์กอัปแบบฟอร์มเชิงความหมาย

รวมการควบคุมการยืนยันไว้ในองค์ประกอบ <form> เชิงความหมายที่มี method="POST" และ <button type="submit"> ที่ชัดเจน ใช้เอลิเมนต์ <input> รายการเดียวเสมอ แทนที่จะแยกตัวเลขออกเป็นหลายช่อง

  • ใช้ type="text" กับ inputmode="numeric": อย่าใช้ type="number" สำหรับรหัส OTP รหัสผ่านแบบใช้งานครั้งเดียวคือสตริงตัวระบุที่อาจมีเลขศูนย์นำหน้า (เช่น 012345) ไม่ใช่จำนวนที่นับได้ การใช้ type="number" จะเพิ่มปุ่มหมุนเพิ่ม/ลด สามารถตัดเลขศูนย์นำหน้า และอนุญาตให้เปลี่ยนค่าปุ่มลูกกลิ้งโดยไม่ตั้งใจ นอกจากนี้ ให้หลีกเลี่ยง type="tel" ซึ่งเป็นวิธีแก้ปัญหาเดิมที่มีไว้สำหรับหมายเลขโทรศัพท์แบบเต็ม จับคู่ type="text" กับ inputmode="numeric" เพื่อแสดงแป้นพิมพ์เสมือนจริงแบบตัวเลขเท่านั้นบนอุปกรณ์เคลื่อนที่
  • เพิ่ม autocomplete="one-time-code": สั่งให้เบราว์เซอร์และระบบปฏิบัติการแนะนำรหัสยืนยันทาง SMS ที่เข้ามาในแถบป้อนข้อความอัตโนมัติเมื่อโฟกัสที่ <input>
  • วางคำแนะนำไว้เหนือ <input>: เชื่อมโยง <label> ที่มองเห็นได้โดยใช้ for และ id และวางคำแนะนำรูปแบบ (ลิงก์ผ่าน aria-describedby) ไว้เหนือ <input> เพื่อไม่ให้ป๊อปโอเวอร์การป้อนข้อความอัตโนมัติและแป้นพิมพ์เสมือนบนอุปกรณ์เคลื่อนที่บดบังคำแนะนำ
  • อย่าแยกตัวเลขออกเป็นองค์ประกอบ <input> หลายรายการ: รูปแบบ OTP แบบหลายช่อง (อินพุต maxlength="1" แยกกัน 6 ช่อง) จะทำให้การป้อนข้อความอัตโนมัติ autocomplete="one-time-code" แบบเนทีฟในเบราว์เซอร์หลายรายการใช้งานไม่ได้ ขัดขวางการคัดลอกและวาง และทำให้การไปยังส่วนต่างๆ ของโปรแกรมอ่านหน้าจอซับซ้อนขึ้น หากจำเป็นต้องใช้การออกแบบภาพแบบแบ่งส่วน ให้จัดรูปแบบ <input> รายการเดียวด้วย letter-spacing และแบบอักษรที่มีความกว้างอักษรขนาดเดียว (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;
}

รูปแบบข้อความ SMS ที่เชื่อมโยงกับต้นทาง

เพื่อให้ทั้ง WebOTP API และการป้อนข้อความอัตโนมัติ autocomplete="one-time-code" ที่เชื่อมโยงกับโดเมนของ Safari ยืนยันได้ว่ารหัสนั้นเป็นของเว็บไซต์คุณ บริการ SMS แบ็กเอนด์ต้องจัดรูปแบบบรรทัดสุดท้ายของ SMS โดยให้โดเมนต้นทางขึ้นต้นด้วย @ และ OTP ขึ้นต้นด้วย # ดังนี้

Your verification code is 123456.

@example.com #123456

บังคับใช้กฎการจัดรูปแบบต่อไปนี้กับข้อความ SMS ขาออก

  • ใส่ข้อความที่มนุษย์อ่านได้ก่อน: เริ่มต้น SMS ด้วยประโยคที่มนุษย์อ่านได้ชัดเจนซึ่งมีรหัสอยู่เสมอ เพื่อให้ผู้ใช้ที่พิมพ์รหัสด้วยตนเองในอุปกรณ์อื่นอ่านได้ง่าย
  • วาง @<domain> #<otp> ไว้ที่บรรทัดสุดท้าย: อักขระ @ ต้องเป็นอักขระตัวแรกของบรรทัดสุดท้าย โดยไม่มีบรรทัดต่อท้ายหรือข้อความลายเซ็นต่อท้าย
  • ใช้เฉพาะโดเมนโฮสต์: อย่าใส่ URL Scheme (https:// หรือ http://), พอร์ต (:8080) หรือเส้นทาง (/verify) ในโทเค็น @domain
  • แยกโทเค็นด้วยการเว้นวรรค 1 ครั้ง: เว้นวรรค 1 ครั้งระหว่าง @<domain> และ #<otp> และอย่าเว้นวรรคภายในโดเมนหรือระหว่าง # กับโค้ด

การผสานรวม WebOTP API เข้ากับขั้นตอนการยืนยัน

WebOTP API (OTPCredential) ต้องใช้บริบทที่ปลอดภัย (https:// ในเวอร์ชันที่ใช้งานจริง หรือ http://localhost ระหว่างการพัฒนาในเครื่อง) แทนที่จะวางสคริปต์ DOMContentLoaded ที่ไม่ได้เชื่อมต่อซึ่งค้นหา DOM และเรียกใช้ form.submit() ให้ผสานรวม navigator.credentials.get() เข้ากับวงจรและตัวแฮนเดิลการส่งของ UI การยืนยันโดยตรง ดังนี้

  • ผูกวงจรคำขอกับขั้นตอน OTP ที่ใช้งานอยู่: เริ่มฟัง OTP เมื่ออินพุตการยืนยันแสดงผลและใช้งานอยู่ (เช่น เมื่อขั้นตอน OTP เริ่มทำงานหลังจากที่ผู้ใช้ขอรหัส SMS และอีกครั้งเมื่อผู้ใช้คลิกปุ่ม "ส่งรหัสอีกครั้ง")
  • จัดการ AbortController ในเส้นทางออกทั้งหมด: ส่ง AbortSignal (signal: ac.signal) ไปยัง navigator.credentials.get() โทรหา ac.abort() ได้ทุกเมื่อเพื่อดำเนินการต่อไปนี้
    • ผู้ใช้ส่งแบบฟอร์มด้วยตนเอง (หรือส่งรหัสที่กรอกผ่าน autocomplete="one-time-code")
    • มีการขอรหัส SMS ใหม่ ("ส่งรหัสอีกครั้ง") ซึ่งจะยกเลิกตัวควบคุมก่อนหน้าก่อนที่จะสร้างตัวควบคุมใหม่
    • คอมโพเนนต์ กล่องโต้ตอบ หรือเส้นทางการยืนยันจะยกเลิกการต่อเชื่อม
  • ทริกเกอร์เครื่องมือจัดการการส่งของแอปพลิเคชัน (form.requestSubmit()): เมื่อ navigator.credentials.get() แก้ไขด้วย otp ให้ตั้งค่า otpInput.value = otp.code และเรียกใช้ form.requestSubmit() (หรือเรียกใช้ฟังก์ชันการยืนยันโดยตรง) อย่าเรียกใช้ form.submit() เนื่องจาก form.submit() จะข้าม Listener เหตุการณ์ submit (เครื่องจัดการ event.preventDefault() / fetch()) และข้ามการตรวจสอบข้อจำกัดแบบเนทีฟ
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();

การยืนยัน iframe แบบข้ามต้นทาง

โดยค่าเริ่มต้น ระบบจะจำกัดการรับ OTP ทาง SMS ภายใน <iframe> แบบข้ามต้นทาง และใช้สำหรับการยืนยันการชำระเงินแบบฝังเป็นหลัก (เช่น การยืนยัน 3D Secure ที่ shop.example ฝังการตรวจสอบของผู้ออกบัตรจาก bank.example) การรองรับ WebOTP ภายใน <iframe> แบบข้ามต้นทางต้องมีการกำหนดค่าที่สอดคล้องกัน 3 รายการดังนี้

  1. เชื่อมโยงทั้ง 2 ต้นทางในบรรทัดสุดท้ายของ SMS: ระบุโดเมนที่ฝังระดับบนสุดก่อน (@shop.example) ตามด้วย #<otp> ตามด้วยเว้นวรรคและโดเมน iframe ที่ฝัง (@bank.example) ดังนี้

    Your verification code is 123456.
    
    @shop.example #123456 @bank.example
    
  2. ส่งส่วนหัวการตอบกลับ HTTP Permissions-Policy: มอบสิทธิ์ otp-credentials ให้กับต้นทาง iframe แบบข้ามต้นทางในส่วนหัว HTTP ของเอกสารหลัก

    Permissions-Policy: otp-credentials=(self "https://bank.example")
    
  3. เพิ่ม allow="otp-credentials" ลงในองค์ประกอบ <iframe>: ให้สิทธิ์นโยบายสิทธิ์อย่างชัดเจนในแท็ก <iframe> ในหน้าเว็บที่ฝัง และใส่แอตทริบิวต์ title ที่สื่อความหมายสำหรับโปรแกรมอ่านหน้าจอ ดังนี้

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

ภายในเอกสาร <iframe> ที่แสดงโดย https://bank.example ให้เรียกใช้มาร์กอัป <form> และวงจรการยืนยัน navigator.credentials.get({ otp: { transport: ['sms'] }, signal: ac.signal }) เดียวกันกับที่แสดงด้านบน

กลยุทธ์สำรอง

WebOTP API สำรอง

หากเบราว์เซอร์เป้าหมายไม่รองรับ WebOTP API (OTPCredential) อย่าโหลด Polyfill ของ JavaScript ของบุคคลที่สาม เนื่องจากการอ่านข้อความ SMS ขาเข้าต้องมีการผสานรวมระดับระบบปฏิบัติการ แต่ให้ใช้การเพิ่มประสิทธิภาพแบบต่อเนื่อง 3 ระดับที่สร้างขึ้นในมาร์กอัป <input> แทน ดังนี้

  • ประสบการณ์การเพิ่มประสิทธิภาพแบบต่อเนื่อง: ปกป้องการเรียกใช้ navigator.credentials.get({ otp: { transport: ['sms'] } }) ไว้หลัง if ('OTPCredential' in window) เมื่อ OTPCredential ไม่พร้อมใช้งาน ให้ทำดังนี้
    1. เบราว์เซอร์ที่รองรับ autocomplete="one-time-code" (เช่น Safari ใน iOS 14 ขึ้นไป, iPadOS และ macOS ที่มีรีเลย์ SMS ของ iCloud) จะแยกวิเคราะห์ SMS @domain #code และแสดงรหัสในแถบป้อนข้อความอัตโนมัติของแป้นพิมพ์ระบบเมื่อโฟกัสที่ <input>
    2. เบราว์เซอร์อื่นๆ ทั้งหมดจะเปลี่ยนกลับไปใช้การป้อนข้อมูลด้วยตนเองใน <input type="text" inputmode="numeric" pattern="\d{6}" maxlength="6" required> เดียว โดยแสดงปุ่มกดตัวเลขบนอุปกรณ์เคลื่อนที่และส่งผ่าน <button type="submit">

นโยบายสิทธิ์สำรอง

หากเบราว์เซอร์ไม่รองรับการมอบสิทธิ์ otp-credentials ผ่าน Permissions-Policy และ <iframe allow="otp-credentials"> การยืนยัน <form> ที่ฝังอยู่ภายใน <iframe> แบบข้ามต้นทางจะลดระดับลงอย่างราบรื่นเป็นคำแนะนำแป้นพิมพ์ autocomplete="one-time-code" และการป้อนตัวเลขด้วยตนเองโดยไม่แสดงข้อยกเว้นที่ไม่ได้จัดการ

การรองรับ Inputmode และ AbortController

ทั้งแอตทริบิวต์ inputmode ของ HTML และ AbortController (AbortSignal) ได้รับการรองรับในเบราว์เซอร์ที่ทันสมัย และไม่จำเป็นต้องใช้โค้ด Polyfill หรือโค้ดสำรอง