שליחת קוד גישה חד-פעמי (OTP) ב-SMS היא תהליך נפוץ לאימות מספר טלפון, שחזור חשבון, אימות מתקדם ואישור תשלום. המעבר בין דפדפן לאפליקציית הודעות SMS כדי לזכור או להעתיק ולהדביק קוד בן כמה ספרות גורם לחיכוך עם המשתמשים, מגדיל את שיעור הנטישה מתהליך ההמרה ומשאיר את המשתמשים חשופים לפישינג בדומיינים מזויפים.
שילוב של תגי עיצוב סמנטיים של <form>, <input autocomplete="one-time-code" inputmode="numeric">, פורמט SMS שקשור למקור (@domain #code) ו-WebOTP API (OTPCredential) יוצר תהליך אימות שכבתי עם שיפור הדרגתי:
- חילוץ ושליחה אוטומטיים: דפדפנים שתומכים ב-WebOTP API מאמתים את המקור מול השורה
@domain #codeבהודעת ה-SMS, מאכלסים את<input>ומפעילים את האימות בלחיצה אחת. - מילוי אוטומטי במקלדת שקשור למקור: דפדפנים ומערכות הפעלה שתומכים ב-
autocomplete="one-time-code"(כמו Safari ב-iOS, ב-iPadOS וב-macOS) מנתחים את ההודעה@domain #codeומציעים את הקוד בסרגל המילוי האוטומטי במקלדת רק כשהדומיין תואם. - הזנה ידנית חלקה: משתמשים שמזינים קוד ממכשיר אחר מקבלים לוח מקשים וירטואלי מספרי דרך
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> ממקורות שונים, נדרשות שלוש הגדרות מתואמות:
קישור של שני מקורות בשורה האחרונה של הודעת ה-SMS: קודם מציגים את הדומיין המוטמע ברמה העליונה (
@shop.example), אחריו את#<otp>, ואז רווח ואת הדומיין המוטמע של iframe (@bank.example):Your verification code is 123456. @shop.example #123456 @bank.exampleשליחת כותרת התגובה
Permissions-Policyב-HTTP: מקצים אתotp-credentialsלמקור של ה-iframe ממקורות שונים בכותרות ה-HTTP של מסמך ההורה:Permissions-Policy: otp-credentials=(self "https://bank.example")מוסיפים את
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לא זמינה:- דפדפנים שתומכים ב-
autocomplete="one-time-code"(כמו Safari ב-iOS 14 ואילך, iPadOS ו-macOS עם ממסר SMS של iCloud) מנתחים את ה-SMS של@domain #codeומציגים את הקוד בסרגל המילוי האוטומטי של מקלדת המערכת כשהמיקוד הוא על<input>. - בכל שאר הדפדפנים, המשתמשים צריכים להזין את הקוד באופן ידני בשדה
<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 או קוד חלופי.