כשאתם אוספים כתובת אימייל במהלך הרשמה, כניסה, תשלום או שחזור חשבון, כדי לאמת את הבעלות על החשבון אתם בדרך כלל צריכים לשלוח קוד אימות חד-פעמי (OTP) או קישור חד-פעמי לכניסה. המשתמשים נאלצים לעזוב את הדף, לחכות שהאימייל יגיע ולהעתיק קודים. זה יוצר חיכוך, נטישה וסיכון לפישינג, וגם חושף בפני ספק האימייל את הצד המסתמך (RP) שהמשתמש מבקר בו.
Email Verification API (תוסף HTML בצד הלקוח) ו-Email Verification Protocol (EVP) (פרוטוקול קריפטוגרפי בקצה העורפי) מאפשרים לגורם המסתמך לאמת שלמשתמש יש שליטה בכתובת אימייל בלי לשלוח הודעת אימות. הדפדפן מתווך בין הצד המסתמך לבין ספק האימייל של המשתמש (המנפיק):
- הצד המסתמך כולל
<input>מוסתר עםautocomplete="email-verification-token"וnonceקריפטוגרפי לשימוש חד-פעמי באותו<form>כמו קלט האימייל. - כשהמשתמש בוחר או מזין כתובת אימייל, הדפדפן מחפש את רשומת ה-
_email-verification.<domain>DNSTXTשל הדומיין, מאמת שלמשתמש יש סשן פעיל עם המנפיק המוסמך ומבקש אסימון אימות אימייל (EVT) בלי לחשוף את זהות הצד המסתמך למנפיק. - הדפדפן מקשר את ה-EVT למקור ולסשן
nonceשל הצד המסתמך ב-Key Binding JWT (KB-JWT) שנחתם באמצעות זוג מפתחות זמני של הדפדפן (EVT + KB-JWT), מאכלס את הקלט הנסתר ושולח את הטופס. - ה-backend של הצד המסתמך מאמת את השרשרת הקריפטוגרפית. אם היא תקינה, הוא מדלג על שלב ה-OTP באימייל או הקישור החד-פעמי לכניסה.
1. הפעלת גרסת המקור לניסיון ועיבוד טופס האימות
כדי להשתתף כאתר מאמת (גורם מסתמך):
- מציינים את הטוקן של גרסת המקור לניסיון בדף שמארח את הטופס באמצעות כותרת התגובה
Origin-Trialשל HTTP או רכיב<meta http-equiv="origin-trial">.- גרסאות מקור לניסיון של צד שלישי: ערכות SDK מוטמעות לניהול זהויות יכולות להוסיף אסימון ניסיון של צד שלישי, בתנאי שהמקור של רושם הדומיין של גרסת המקור לניסיון תואם לדומיין של המנפיק (אותו אתר כמו המנפיק, למשל
https://issuer.example).
- גרסאות מקור לניסיון של צד שלישי: ערכות SDK מוטמעות לניהול זהויות יכולות להוסיף אסימון ניסיון של צד שלישי, בתנאי שהמקור של רושם הדומיין של גרסת המקור לניסיון תואם לדומיין של המנפיק (אותו אתר כמו המנפיק, למשל
- יוצרים מספר חד-פעמי (nonce) אקראי מבחינה קריפטוגרפית לכל עיבוד של טופס עם אנטרופיה של 128 ביט לפחות (לדוגמה,
randomBytes(32).toString('base64url')מ-node:crypto), מאחסנים אותו במצב סשן בצד השרת עם מפתח של קובץ Cookie של מזהה סשן מסוגHttpOnly,SecureאוSameSite=Lax(אף פעם אל תאחסנו את הערך הצפוי שלnonceישירות בתוך קובץ Cookie של לקוח, שבו תוקף יכול להפעיל מחדש גם את קובץ ה-Cookie וגם את האסימון), ומעבדים אותו למאפייןnonceשל קלט האסימון המוסתר. מציגים את הדף עםCache-Control: no-storeכדי שהניווט לדף הקודם/הבא לא ישתמש שוב ב-nonce שכבר נעשה בו שימוש. - מוסיפים את קלט הטוקן המוסתר (
autocomplete="email-verification-token") בתוך אותו<form>כמו קלט האימייל (autocomplete="email").
<!-- Serve with:
Origin-Trial: <YOUR_ORIGIN_TRIAL_TOKEN>
Cache-Control: no-store -->
<form method="POST" action="/api/verify-email">
<label for="email">Email address:</label>
<input
type="email"
id="email"
name="email"
autocomplete="email"
required
/>
<!-- MANDATORY: Must be in the same <form> as the email input.
The nonce value must be freshly generated on the server per form render. -->
<input
type="hidden"
name="token"
nonce="SERVER_GENERATED_PER_FORM_NONCE"
autocomplete="email-verification-token"
/>
<button type="submit">Continue</button>
</form>
2. אימות הטוקן שנשלח בשרת
כששולחים את הטופס, צריך להתייחס לשדה token כקלט לא מהימן. קוראים ומוחקים באופן אטומי את הערך הצפוי של nonce ממאגר הסשנים בצד השרת עם קבלת הנתונים. אם השדה token ריק (לדוגמה, בדפדפנים שלא תומכים ב-EVP או כשהמשתמש לא מחובר לספק האימייל שלו) או אם האימות נכשל, צריך לחזור מיד לתהליך הרגיל של סיסמה חד-פעמית (OTP) או קישור חד-פעמי לכניסה:
// Replace with your public relying-party origin
const RP_ORIGIN = 'https://rp.example.com';
export async function handleSignupSubmission(request) {
const formData = await request.formData();
const submittedEmail = String(formData.get('email') || '').trim();
const rawToken = String(formData.get('token') || '').trim();
// Atomically read and delete the single-use nonce from server-side session state
const expectedNonce = await consumeSessionNonce(request);
if (rawToken && expectedNonce) {
try {
await verifyEmailVerificationToken({
rawToken,
submittedEmail,
expectedNonce,
expectedAudience: RP_ORIGIN,
});
// Fast path: email ownership is cryptographically verified; skip OTP email.
return completeAccountCreation(submittedEmail);
} catch (err) {
// Log token verification failures before falling back to standard email verification.
console.error('Email verification token check failed:', err);
}
}
// Fallback path: send standard OTP code or magic link email to submittedEmail.
return sendTraditionalVerificationEmail(submittedEmail);
}
כדי להטמיע את verifyEmailVerificationToken, תמיד צריך להשתמש בספריות SD-JWT ו-JOSE/JWT מבוססות לפלטפורמת הקצה העורפי (כמו @sd-jwt/core ו-jose ב-Node.js, sd-jwt-python / jwcrypto ב-Python או sd-jwt-java / Nimbus JOSE+JWT ב-JVM), במקום להשתמש בקוד מותאם אישית לניתוח JWT או SD-JWT.
שלב 1: מנתחים את ההצהרות של SD-JWT ומאמתים את כתובת האימייל
אחרי שמשתמשים ב-expectedNonce החד-פעמי מאחסון הסשן בצד השרת, מפענחים את מצגת ה-SD-JWT (EVT + KB-JWT) ומוודאים ש:
- גם ה-JWT של המנפיק (
jwt) וגם ה-JWT של קישור המפתח (kbJwt) קיימים, יחד עם ההצהרה של המנפיק (iss). evtPayload.email_verified === true.-
evtPayload.emailתואם לכתובת האימייל בטופס שנשלח באמצעות השוואה לא תלוית-רישיות (toLowerCase()).
import { createHash } from 'node:crypto';
import { decodeSdJwtSync } from '@sd-jwt/core';
const hasher = (data, alg) =>
createHash(alg === 'sha-256' ? 'sha256' : alg).update(data).digest();
const decoded = decodeSdJwtSync(rawToken, hasher);
const evtPayload = decoded.jwt.payload;
const iss = evtPayload.iss;
if (!expectedNonce || !decoded.kbJwt || !iss || evtPayload.email_verified !== true) {
throw new Error('Missing nonce, KB-JWT, issuer, or email_verified !== true.');
}
// MANDATORY: Compare email addresses case-insensitively
if (evtPayload.email?.toLowerCase() !== submittedEmail.toLowerCase()) {
throw new Error('Submitted email does not match the EVT email claim.');
}
שלב 2: מאמתים את ההקצאה של רשומת ה-TXT ב-DNS וטוענים את ה-JWKS של המנפיק
לפני ששולחים בקשה לאחזור מהרשת אל iss, צריך לוודא שהדומיין של כתובת האימייל שנשלחה מאציל סמכות למנפיק הספציפי הזה באמצעות רשומת _email-verification.<domain> DNS TXT (iss=<issuer-host> -> https://<issuer-host> בלי יציאה, נתיב או לוכסן בסוף). אחרי שהקצאת ה-DNS תואמת ל-iss בייט אחר בייט, צריך לאחזר את ${iss}/.well-known/email-verification באמצעות HTTPS, לוודא ש-metadata.issuer === iss ולטעון את קבוצת מפתחות האינטרנט בפורמט JSON (JWKS) של המנפיק.
הערה לגבי הטיפול ב-
kid: חלק מספקי האימייל הגדולים (כמו Gmail) משמיטים אתkidמהכותרת של EVT. אםkidלא קיים וב-JWKS יש כמה מפתחות,joseזורקERR_JWKS_MULTIPLE_MATCHING_KEYS(AsyncIterableשלCryptoKeyתואמים) – צריך לחזור עלfor await (const publicKey of err)כדי לאמת כל מפתח, כמו שמוצג בשלב 3.
import dns from 'node:dns/promises';
import { createRemoteJWKSet } from 'jose';
const domain = submittedEmail.split('@')[1];
const txtRecords = (await dns.resolveTxt(`_email-verification.${domain}`)).map((r) => r.join(''));
if (!txtRecords.some((txt) => txt.startsWith('iss=') && `https://${txt.slice(4).trim()}` === iss)) {
throw new Error(`Issuer "${iss}" is not delegated via DNS for "${domain}".`);
}
const discoveryUrl = new URL('/.well-known/email-verification', iss);
if (discoveryUrl.protocol !== 'https:') throw new Error('Issuer must use HTTPS.');
const metadata = await (await fetch(discoveryUrl)).json();
if (metadata.issuer !== iss || !metadata.jwks_uri?.startsWith('https://')) {
throw new Error('Invalid issuer metadata or non-HTTPS jwks_uri.');
}
const issuerJwks = createRemoteJWKSet(new URL(metadata.jwks_uri));
שלב 3: מאמתים את החתימה של ה-EVT ואת ה-JWT של קישור המפתחות (KB-JWT)
לסיום, מאמתים את שתי החתימות ואת הקישור הקריפטוגרפי בין שני הטוקנים באמצעות רשימת ההיתרים המפורשת של האלגוריתם האסימטרי (['Ed25519', 'EdDSA', 'ES256']):
- חתימת EVT (
verifier): אימות מולissuerJwks(תפיסתERR_JWKS_MULTIPLE_MATCHING_KEYSכדי לחזור על מפתחות מועמדים כש-kidמושמט), אכיפתissוחלון עדכניות צר (maxTokenAge: '5m',clockTolerance: '1m'). - קישור למחזיק (האפשרויות
kbVerifierו-sdJwt.verify): מחלצים את המפתח הציבורי הזמני של הדפדפן מ-evtPayload.cnf.jwk, מוודאים שגםcnf.jwk.algוגםdecoded.kbJwt.header.algנמצאים ב-ALLOWED_ALGSותואמים (מתייחסים ל-'Ed25519'ול-'EdDSA'כשווי ערך בזמן שההטמעות של גרסת המקור לניסיון עוברות ל-RFC 9864'Ed25519'), ומאמתים את החתימה שלKB-JWTבאמצעותtyp: 'kb+jwt'. מעבירים אתkeyBindingNonce: expectedNonce,expectedKeyBindingAudience: expectedAudienceו-keyBindingMaxAgeSeconds: 300אלsdJwt.verify()–@sd-jwt/coreמפעיל רק אתkbVerifierומאמת אתnonce,aud,iatו-sd_hashכשמספקים אתkeyBindingNonce.
import { SDJwtInstance } from '@sd-jwt/core';
import { importJWK, jwtVerify } from 'jose';
// IETF draft-hardt-email-verification-02 (§3.3, §5.1.1) specifies RFC 9864 'Ed25519' and 'ES256'.
// TODO: Remove 'EdDSA' once live issuers (e.g. Gmail) and Chrome Origin Trial builds finish
// transitioning from 'EdDSA' (with crv: 'Ed25519') to 'Ed25519'.
const ALLOWED_ALGS = ['Ed25519', 'EdDSA', 'ES256'];
const normalizeAlg = (alg) => (alg === 'Ed25519' ? 'EdDSA' : alg);
const evtOptions = {
typ: 'ev+sd-jwt',
issuer: iss,
algorithms: ALLOWED_ALGS,
maxTokenAge: '5m',
clockTolerance: '1m',
};
const sdJwt = new SDJwtInstance({
hasher,
verifier: async (data, sig) => {
const compactEvt = `${data}.${sig}`;
try {
await jwtVerify(compactEvt, issuerJwks, evtOptions);
} catch (err) {
// When `kid` is omitted (e.g. Gmail) and multiple keys match in the JWKS,
// jose throws JWKSMultipleMatchingKeys, which yields each matching CryptoKey.
if (err?.code !== 'ERR_JWKS_MULTIPLE_MATCHING_KEYS') throw err;
let matched = false;
for await (const publicKey of err) {
try {
await jwtVerify(compactEvt, publicKey, evtOptions);
matched = true;
break;
} catch {}
}
if (!matched) throw err;
}
return true;
},
kbVerifier: async (data, sig) => {
const holderJwk = evtPayload.cnf?.jwk;
const holderAlg = holderJwk?.alg;
const kbAlg = decoded.kbJwt.header.alg;
if (
!holderAlg ||
!kbAlg ||
!ALLOWED_ALGS.includes(holderAlg) ||
!ALLOWED_ALGS.includes(kbAlg) ||
normalizeAlg(kbAlg) !== normalizeAlg(holderAlg)
) {
throw new Error('Missing, disallowed, or mismatched KB-JWT algorithm.');
}
const holderKey = await importJWK(holderJwk, kbAlg);
await jwtVerify(`${data}.${sig}`, holderKey, {
typ: 'kb+jwt',
audience: expectedAudience,
algorithms: [kbAlg],
maxTokenAge: '5m',
clockTolerance: '1m',
});
return true;
},
});
const verified = await sdJwt.verify(rawToken, {
requiredClaimKeys: ['email', 'email_verified'],
keyBindingNonce: expectedNonce,
expectedKeyBindingAudience: expectedAudience,
keyBindingMaxAgeSeconds: 300,
skewSeconds: 60,
});
שיטות מומלצות
- כן מבצעים את כל אימותי האסימונים אך ורק בשרת. אל תאמתו טוקנים ב-JavaScript בצד הלקוח ואל תסמכו על מצב אימות בצד הלקוח – אפשר לעקוף או לזייף בקלות בדיקות בצד הלקוח, והן לא מספקות שום ערובה לאמינות לצד המסתמך.
- מומלץ להשתמש בספריות SD-JWT ו-JOSE/JWT מבוססות לפלטפורמת הקצה העורפי (לדוגמה,
@sd-jwt/coreו-joseב-Node.js) כדי לנתח ולאמת את ההצגה שלEVT~KB-JWT, ולהעביר אתkeyBindingNonceאלsdJwt.verify()כדי שלא יהיה אפשר לעקוף את אימות ה-Key Binding. אל תפתחו בעצמכם שגרות מותאמות אישית לניתוח ואימות חתימה של JWT, SD-JWT אוsd_hash. - מומלץ ליצור
nonceקריפטוגרפי חדש (לפחות 128 ביטים, כמוrandomBytes(32).toString('base64url')) בשרת לכל עיבוד של טופס, לאחסן אותו במצב סשן בצד השרת (לא ישירות בקובץ Cookie של הלקוח), ולהציג את דף הטופס עםCache-Control: no-store. - חשוב לקרוא ולמחוק באופן אטומי את ה-
nonceהצפוי מאחסון הסשן בצד השרת מיד כשמגיעה הבקשה שלPOST, כדי שלא יהיה אפשר להפעיל מחדש טוקן שמישהו יירט. - חשוב לבצע השוואה לא תלוית-רישיות בין
evtPayload.emailלבין כתובת האימייל שנשלחה בטופס (email.toLowerCase()), תוך שמירה על מחרוזת האימייל שנשלחה. - מומלץ לבצע אימות ניסיון של כל המפתחות ב-JWKS של המנפיק כש-
kidלא מופיע בכותרת ה-EVT (ספקים כמו Gmail משמיטים אתkid). - חשוב לאמת את הקצאת ה-DNS (
_email-verification.<domain>) לפני שנותנים אמון בטענתissב-EVT, ולאשר אתmetadata.issuer === issבתגובה של.well-known/email-verification. - אל תתייחסו לאימות הבעלות על האימייל כהוכחה לכך שהאימייל נמסר לתיבת הדואר הנכנס. אימות EVP מוכיח שהמשתמש שולט בחשבון האימייל אצל הספק המהימן. עדיין צריך לשלוח אימיילים טרנזקציונליים של הודעות פתיחה ולטפל בהודעות חוזרות כרגיל.
- אל תחסמו את האפשרות ליצור חשבון או להיכנס אליו אם
tokenריק או אם האימות נכשל. תמיד צריך להציע חלופה פשוטה, כמו תהליך רגיל של סיסמה חד-פעמית (OTP) באימייל או קישור חד-פעמי לכניסה.
אסטרטגיית גיבוי
אימות אימייל (autocomplete="email-verification-token") נועד לשמש כשיפור הדרגתי של אימות אימייל מסורתי מחוץ לערוץ (קודי OTP או קישורים חד-פעמיים לכניסה):
- דפדפנים לא נתמכים: דפדפנים שלא תומכים ב-
autocomplete="email-verification-token"מתעלמים מהקלט הנסתר ושולחים את הטופס עם ערךtokenריק. - דומיינים של אימייל שלא אומתו או שלא משתתפים: כשמשתמש מזין כתובת אימייל שהדומיין שלה לא מפרסם רשומת DNS של
_email-verificationTXT, או כשאין למשתמש סשן פעיל עם ספק האימייל שלו בדפדפן, הדפדפן שולח את הטופס עםtokenריק. - אסימונים לא תקינים או שפג תוקפם: אם פג התוקף של
nonce, אם כבר נעשה בו שימוש או אם הוא נכשל בשלב כלשהו שלverifyEmailVerificationTokenב-handleSignupSubmission, השרת מזהה את השגיאה ושולח סיסמה חד-פעמית רגילה או אימייל עם קישור חד-פעמי לכניסה, בלי להפריע לתהליך ההרשמה או הכניסה של המשתמש.