אימות טופס באמצעות סיסמה חד-פעמית (OTP) ב-SMS

שליחת קוד גישה חד-פעמי (OTP) ב-SMS היא תהליך נפוץ לאימות מספר טלפון, שחזור חשבון, אימות מתקדם ואישור תשלום. המעבר בין דפדפן לאפליקציית הודעות 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 מאמת את הבעלות על מספר טלפון ומספק אימות נוח, אבל העברת SMS ומספרי טלפון יכולים להיות חשופים להחלפת כרטיס SIM, למיחזור מספרים וליירוט. תמיד צריך לשלב בין מילוי אוטומטי בצד הלקוח ו-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") משבשות את המילוי האוטומטי המקורי של autocomplete="one-time-code" בדפדפנים רבים, מקשות על העתקה והדבקה ומסבכות את הניווט באמצעות קורא מסך. אם נדרש עיצוב חזותי מקוטע, צריך לעצב רכיב <input> יחיד עם letter-spacing וגופן ברוחב אחיד.
<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 וגם המילוי האוטומטי של Safari שקשור לדומיין autocomplete="one-time-code" יאמתו שהקוד שייך לאתר שלכם, שירות ה-SMS של ה-backend צריך לעצב את השורה האחרונה של הודעת הטקסט כך שדומיין המקור יתחיל ב-@ וה-OTP יתחיל ב-#:

Your verification code is 123456.

@example.com #123456

אוכפים את כללי העיצוב הבאים על הודעת סמס יוצאת:

  • התחילו בטקסט קריא לאנשים: תמיד התחילו את הודעת ה-SMS במשפט ברור וקריא לאנשים שמכיל את הקוד, כדי שמשתמשים שמקלידים אותו ידנית במכשיר אחר יוכלו לקרוא אותו בקלות.
  • הציבו את התו @<domain> #<otp> בשורה האחרונה: התו @ חייב להיות התו הראשון בשורה האחרונה, בלי שורות נגררות או טקסט חתימה אחריו.
  • משתמשים רק בדומיין המארח: אל תכללו סכמת URL‏ (https:// או http://), יציאה (:8080) או נתיב (/verify) באסימון @domain.
  • הפרדת אסימונים באמצעות רווח אחד: צריך להוסיף רווח אחד בדיוק בין @<domain> ל-#<otp>, ואף פעם לא להוסיף רווח בתוך הדומיין או בין # לקוד.

שילוב WebOTP API בתהליך האימות

‫WebOTP API (OTPCredential) דורש הקשר מאובטח (https:// בסביבת הייצור, או http://localhost במהלך פיתוח מקומי). במקום להדביק סקריפט DOMContentLoaded מנותק ששולח שאילתות ל-DOM וקורא ל-form.submit(), צריך לשלב את navigator.credentials.get() ישירות במחזור החיים וב-submission handler של ממשק המשתמש לאימות:

  • לקשר את מחזור החיים של הבקשה לשלב הפעיל של ה-OTP: מתחילים להאזין ל-OTP כשהקלט של האימות מוצג ופעיל (לדוגמה, כששלב ה-OTP נטען אחרי שהמשתמש מבקש קוד ב-SMS, ושוב בכל פעם שהמשתמש לוחץ על הלחצן "שליחת הקוד מחדש").
  • ניהול AbortController בכל נתיבי היציאה: מעבירים AbortSignal (signal: ac.signal) אל navigator.credentials.get(). אתם יכולים להתקשר אל ac.abort() בכל פעם ש:
    • המשתמש שולח את הטופס באופן ידני (או שולח קוד שמולא באמצעות autocomplete="one-time-code").
    • נשלחת בקשה לקוד חדש ב-SMS ("שליחת הקוד מחדש"), והבקר הקודם מבוטל לפני שנוצר בקר חדש.
    • רכיב האימות, תיבת הדו-שיח או הנתיב מוסרים.
  • מפעילים את ה-handler של שליחת האפליקציה (form.requestSubmit()): כש-navigator.credentials.get() פותר את otp, מגדירים את otpInput.value = otp.code וקוראים ל-form.requestSubmit() (או מפעילים את פונקציית האימות ישירות). אל תקראו ל-form.submit(), כי form.submit() עוקף את רכיבי ה-event listener של submit (רכיבי handler של 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 ממקורות שונים

קבלת סיסמה חד-פעמית ב-SMS בתוך <iframe> ממקורות שונים מוגבלת כברירת מחדל, ומשמשת בעיקר לתהליכי אישור תשלום מוטמעים (כמו אימות 3D Secure שבו shop.example מטמיע אתגר של מנפיק מ-bank.example). כדי לתמוך ב-WebOTP בתוך <iframe> ממקורות שונים, נדרשות שלוש הגדרות מתואמות:

  1. קישור של שני מקורות בשורה האחרונה של הודעת ה-SMS: קודם מציגים את הדומיין המוטמע ברמה העליונה (@shop.example), אחריו את #<otp>, ואז רווח ואת הדומיין המוטמע של iframe‏ (@bank.example):

    Your verification code is 123456.
    
    @shop.example #123456 @bank.example
    
  2. שליחת כותרת התגובה Permissions-Policy ב-HTTP: מקצים את 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 נכנסות נדרש שילוב ברמת מערכת ההפעלה. במקום זאת, כדאי להסתמך על השיפור ההדרגתי (progressive enhancement) בשלוש רמות שמובנה בתגי העיצוב של <input>:

  • חוויית שיפור הדרגתי (progressive enhancement): הגנה על הקריאה ל-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 או קוד חלופי.