אימות הבעלות על כתובת האימייל

כשאתם אוספים כתובת אימייל במהלך הרשמה, כניסה, תשלום או שחזור חשבון, כדי לאמת את הבעלות על החשבון אתם בדרך כלל צריכים לשלוח קוד אימות חד-פעמי (OTP) או קישור חד-פעמי לכניסה. המשתמשים נאלצים לעזוב את הדף, לחכות שהאימייל יגיע ולהעתיק קודים. זה יוצר חיכוך, נטישה וסיכון לפישינג, וגם חושף בפני ספק האימייל את הצד המסתמך (RP) שהמשתמש מבקר בו.

Email Verification API (תוסף HTML בצד הלקוח) ו-Email Verification Protocol (EVP) (פרוטוקול קריפטוגרפי בקצה העורפי) מאפשרים לגורם המסתמך לאמת שלמשתמש יש שליטה בכתובת אימייל בלי לשלוח הודעת אימות. הדפדפן מתווך בין הצד המסתמך לבין ספק האימייל של המשתמש (המנפיק):

  1. הצד המסתמך כולל <input> מוסתר עם autocomplete="email-verification-token" וnonce קריפטוגרפי לשימוש חד-פעמי באותו <form> כמו קלט האימייל.
  2. כשהמשתמש בוחר או מזין כתובת אימייל, הדפדפן מחפש את רשומת ה-_email-verification.<domain> DNS TXT של הדומיין, מאמת שלמשתמש יש סשן פעיל עם המנפיק המוסמך ומבקש אסימון אימות אימייל (EVT) בלי לחשוף את זהות הצד המסתמך למנפיק.
  3. הדפדפן מקשר את ה-EVT למקור ולסשן nonce של הצד המסתמך ב-Key Binding JWT (KB-JWT)‎ שנחתם באמצעות זוג מפתחות זמני של הדפדפן (EVT + KB-JWT), מאכלס את הקלט הנסתר ושולח את הטופס.
  4. ה-backend של הצד המסתמך מאמת את השרשרת הקריפטוגרפית. אם היא תקינה, הוא מדלג על שלב ה-OTP באימייל או הקישור החד-פעמי לכניסה.

1. הפעלת גרסת המקור לניסיון ועיבוד טופס האימות

כדי להשתתף כאתר מאמת (גורם מסתמך):

  1. מציינים את הטוקן של גרסת המקור לניסיון בדף שמארח את הטופס באמצעות כותרת התגובה Origin-Trial של HTTP או רכיב <meta http-equiv="origin-trial">.
    • גרסאות מקור לניסיון של צד שלישי: ערכות SDK מוטמעות לניהול זהויות יכולות להוסיף אסימון ניסיון של צד שלישי, בתנאי שהמקור של רושם הדומיין של גרסת המקור לניסיון תואם לדומיין של המנפיק (אותו אתר כמו המנפיק, למשל https://issuer.example).
  2. יוצרים מספר חד-פעמי (nonce) אקראי מבחינה קריפטוגרפית לכל עיבוד של טופס עם אנטרופיה של 128 ביט לפחות (לדוגמה, randomBytes(32).toString('base64url') מ-node:crypto), מאחסנים אותו במצב סשן בצד השרת עם מפתח של קובץ Cookie של מזהה סשן מסוג HttpOnly,‏ Secure או SameSite=Lax (אף פעם אל תאחסנו את הערך הצפוי של nonce ישירות בתוך קובץ Cookie של לקוח, שבו תוקף יכול להפעיל מחדש גם את קובץ ה-Cookie וגם את האסימון), ומעבדים אותו למאפיין nonce של קלט האסימון המוסתר. מציגים את הדף עם Cache-Control: no-store כדי שהניווט לדף הקודם/הבא לא ישתמש שוב ב-nonce שכבר נעשה בו שימוש.
  3. מוסיפים את קלט הטוקן המוסתר (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']):

  1. חתימת EVT‏ (verifier): אימות מול issuerJwks (תפיסת ERR_JWKS_MULTIPLE_MATCHING_KEYS כדי לחזור על מפתחות מועמדים כש-kid מושמט), אכיפת iss וחלון עדכניות צר (maxTokenAge: '5m', clockTolerance: '1m').
  2. קישור למחזיק (האפשרויות 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-verification TXT, או כשאין למשתמש סשן פעיל עם ספק האימייל שלו בדפדפן, הדפדפן שולח את הטופס עם token ריק.
  • אסימונים לא תקינים או שפג תוקפם: אם פג התוקף של nonce, אם כבר נעשה בו שימוש או אם הוא נכשל בשלב כלשהו של verifyEmailVerificationToken ב-handleSignupSubmission, השרת מזהה את השגיאה ושולח סיסמה חד-פעמית רגילה או אימייל עם קישור חד-פעמי לכניסה, בלי להפריע לתהליך ההרשמה או הכניסה של המשתמש.