ยืนยันการเป็นเจ้าของอีเมล

เมื่อรวบรวมอีเมลระหว่างการลงชื่อสมัครใช้ การลงชื่อเข้าใช้ การชำระเงิน หรือการกู้คืนบัญชี โดยปกติแล้วการยืนยันการเป็นเจ้าของจะต้องส่งรหัสผ่านแบบใช้งานครั้งเดียว (OTP) หรือ Magic Link นอกแบนด์ ซึ่งทำให้ผู้ใช้ต้องออกจากหน้าเว็บ รอรับอีเมล และคัดลอกรหัส ซึ่งทำให้เกิดความยุ่งยาก การออกกลางคัน และความเสี่ยงต่อฟิชชิง ทั้งยังเปิดเผยต่อผู้ให้บริการอีเมลว่าผู้ใช้กำลังเข้าชมฝ่ายที่เกี่ยวข้อง (RP) ใด

Email Verification API (ส่วนขยาย HTML ฝั่งไคลเอ็นต์) และโปรโตคอลการยืนยันอีเมล (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. แบ็กเอนด์ของฝ่ายที่เกี่ยวข้องจะยืนยันเชนการเข้ารหัส และหากถูกต้อง ระบบจะข้ามขั้นตอน OTP ทางอีเมลหรือ Magic Link

1. เปิดใช้ช่วงทดลองใช้จากต้นทางและแสดงผลแบบฟอร์มการยืนยัน

หากต้องการเข้าร่วมในฐานะเว็บไซต์ที่ยืนยัน (ฝ่ายที่อาศัยข้อมูล) ให้ทำดังนี้

  1. ระบุโทเค็นช่วงทดลองใช้จากต้นทางในหน้าเว็บที่โฮสต์แบบฟอร์มโดยใช้ส่วนหัวการตอบกลับ HTTP Origin-Trial หรือองค์ประกอบ <meta http-equiv="origin-trial">
    • ช่วงทดลองใช้จากต้นทางของบุคคลที่สาม: SDK ข้อมูลประจำตัวที่ฝังสามารถแทรกโทเค็นช่วงทดลองใช้ของบุคคลที่สามได้ หากต้นทางของผู้จดทะเบียนช่วงทดลองใช้จากต้นทางตรงกับโดเมนของผู้ออก (เว็บไซต์เดียวกันกับผู้ออก เช่น https://issuer.example)
  2. สร้าง Nonce แบบสุ่มที่เข้ารหัสลับต่อการแสดงผลแบบฟอร์มที่มีเอนโทรปีอย่างน้อย 128 บิต (เช่น randomBytes(32).toString('base64url') จาก node:crypto) จัดเก็บไว้ในสถานะเซสชันฝั่งเซิร์ฟเวอร์ที่คีย์โดยคุกกี้รหัสเซสชัน HttpOnly, Secure, SameSite=Lax (อย่าจัดเก็บค่า nonce ที่คาดไว้ในคุกกี้ฝั่งไคลเอ็นต์โดยตรงซึ่งผู้โจมตีอาจเล่นซ้ำทั้งคุกกี้และโทเค็นได้) และแสดงผลเป็นแอตทริบิวต์ 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 ทางอีเมลมาตรฐานหรือ Magic Link ทันที โดยทำดังนี้

// 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);
}

ใช้ไลบรารี SD-JWT และ JOSE/JWT ที่สร้างขึ้นสำหรับแพลตฟอร์มแบ็กเอนด์เสมอ (เช่น @sd-jwt/core และ jose ใน Node.js, sd-jwt-python / jwcrypto ใน Python หรือ sd-jwt-java / Nimbus JOSE+JWT ใน JVM) เพื่อใช้ verifyEmailVerificationToken แทนการเขียนโค้ดแยกวิเคราะห์ 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: ตรวจสอบการมอบสิทธิ์ DNS TXT และโหลด 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 Web Key Set (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)

สุดท้าย ให้ยืนยันทั้งลายเซ็นและการเชื่อมโยงการเข้ารหัสระหว่างโทเค็นทั้ง 2 รายการโดยใช้รายการที่อนุญาตของอัลกอริทึมแบบไม่สมมาตรที่ชัดเจน (['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() เพื่อไม่ให้ข้ามการยืนยันการเชื่อมโยงคีย์ได้ อย่าเขียนโค้ดเองสำหรับการแยกวิเคราะห์และการตรวจสอบลายเซ็นของ JWT, SD-JWT หรือ sd_hash ที่กำหนดเอง
  • ควรสร้าง nonce แบบเข้ารหัสใหม่ (อย่างน้อย 128 บิต เช่น randomBytes(32).toString('base64url')) บนเซิร์ฟเวอร์สำหรับการแสดงผลแบบฟอร์มแต่ละครั้ง จัดเก็บไว้ในสถานะเซสชันฝั่งเซิร์ฟเวอร์ (ไม่ใช่ในคุกกี้ฝั่งไคลเอ็นต์โดยตรง) และแสดงหน้าแบบฟอร์มที่มี 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 ทางอีเมลมาตรฐานหรือขั้นตอน Magic Link แทนเสมอ

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

การยืนยันอีเมล (autocomplete="email-verification-token") ได้รับการออกแบบมาเพื่อเป็นการเพิ่มประสิทธิภาพแบบต่อเนื่องเหนือการยืนยันอีเมลนอกแบนด์แบบดั้งเดิม (รหัส OTP หรือ Magic Link) ดังนี้

  • เบราว์เซอร์ที่ไม่รองรับ: เบราว์เซอร์ที่ไม่รองรับ autocomplete="email-verification-token" จะละเว้นอินพุตที่ซ่อนอยู่และส่งแบบฟอร์มที่มีค่า token ว่างเปล่า
  • โดเมนอีเมลที่ไม่ได้รับการตรวจสอบสิทธิ์หรือไม่เข้าร่วม: เมื่อผู้ใช้ป้อนอีเมลที่โดเมนไม่ได้เผยแพร่ระเบียน _email-verification DNS TXT หรือเมื่อผู้ใช้ไม่มีเซสชันที่ใช้งานอยู่กับผู้ให้บริการอีเมลในเบราว์เซอร์ เบราว์เซอร์จะส่งแบบฟอร์มที่มี token ว่างเปล่า
  • โทเค็นหมดอายุหรือไม่ถูกต้อง: หาก nonce หมดอายุ มีการใช้งานไปแล้ว หรือไม่ผ่านขั้นตอนใดๆ ของ verifyEmailVerificationToken ใน handleSignupSubmission เซิร์ฟเวอร์จะตรวจพบข้อผิดพลาดและส่ง OTP แบบเดิมหรืออีเมล Magic Link โดยไม่รบกวนขั้นตอนการลงชื่อสมัครใช้หรือลงชื่อเข้าใช้ของผู้ใช้