เมื่อรวบรวมอีเมลระหว่างการลงชื่อสมัครใช้ การลงชื่อเข้าใช้ การชำระเงิน หรือการกู้คืนบัญชี โดยปกติแล้วการยืนยันการเป็นเจ้าของจะต้องส่งรหัสผ่านแบบใช้งานครั้งเดียว (OTP) หรือ Magic Link นอกแบนด์ ซึ่งทำให้ผู้ใช้ต้องออกจากหน้าเว็บ รอรับอีเมล และคัดลอกรหัส ซึ่งทำให้เกิดความยุ่งยาก การออกกลางคัน และความเสี่ยงต่อฟิชชิง ทั้งยังเปิดเผยต่อผู้ให้บริการอีเมลว่าผู้ใช้กำลังเข้าชมฝ่ายที่เกี่ยวข้อง (RP) ใด
Email Verification API (ส่วนขยาย HTML ฝั่งไคลเอ็นต์) และโปรโตคอลการยืนยันอีเมล (EVP) (โปรโตคอลการเข้ารหัสแบ็กเอนด์) ช่วยให้ฝ่ายที่อาศัยข้อมูลยืนยันได้ว่าผู้ใช้ควบคุมอีเมลโดยไม่ต้องส่งอีเมลยืนยัน เบราว์เซอร์จะทำหน้าที่เป็นสื่อกลางระหว่างฝ่ายที่เกี่ยวข้องกับผู้ให้บริการอีเมลของผู้ใช้ (ผู้ออก) ดังนี้
- ฝ่ายที่อาศัยจะรวม
<input>ที่ซ่อนอยู่ซึ่งมีautocomplete="email-verification-token"และnonceที่เข้ารหัสแบบใช้ครั้งเดียวไว้ใน<form>เดียวกันกับอินพุตอีเมล - เมื่อผู้ใช้เลือกหรือป้อนอีเมล เบราว์เซอร์จะค้นหาระเบียน
_email-verification.<domain>DNSTXTของโดเมน ยืนยันว่าผู้ใช้มีเซสชันที่ใช้งานอยู่กับผู้ออกใบรับรองที่เชื่อถือได้ และขอโทเค็นการยืนยันอีเมล (EVT) โดยไม่เปิดเผยตัวตนของฝ่ายที่อาศัยต่อผู้ออกใบรับรอง - เบราว์เซอร์จะเชื่อมโยง EVT กับต้นทางและเซสชัน
nonceของฝ่ายที่เชื่อถือใน Key Binding JWT (KB-JWT) ที่ลงนามด้วยคู่คีย์เบราว์เซอร์ชั่วคราว (EVT + KB-JWT) เติมข้อมูลอินพุตที่ซ่อนอยู่ และส่งแบบฟอร์ม - แบ็กเอนด์ของฝ่ายที่เกี่ยวข้องจะยืนยันเชนการเข้ารหัส และหากถูกต้อง ระบบจะข้ามขั้นตอน OTP ทางอีเมลหรือ Magic Link
1. เปิดใช้ช่วงทดลองใช้จากต้นทางและแสดงผลแบบฟอร์มการยืนยัน
หากต้องการเข้าร่วมในฐานะเว็บไซต์ที่ยืนยัน (ฝ่ายที่อาศัยข้อมูล) ให้ทำดังนี้
- ระบุโทเค็นช่วงทดลองใช้จากต้นทางในหน้าเว็บที่โฮสต์แบบฟอร์มโดยใช้ส่วนหัวการตอบกลับ HTTP
Origin-Trialหรือองค์ประกอบ<meta http-equiv="origin-trial">- ช่วงทดลองใช้จากต้นทางของบุคคลที่สาม: SDK ข้อมูลประจำตัวที่ฝังสามารถแทรกโทเค็นช่วงทดลองใช้ของบุคคลที่สามได้ หากต้นทางของผู้จดทะเบียนช่วงทดลองใช้จากต้นทางตรงกับโดเมนของผู้ออก (เว็บไซต์เดียวกันกับผู้ออก เช่น
https://issuer.example)
- ช่วงทดลองใช้จากต้นทางของบุคคลที่สาม: SDK ข้อมูลประจำตัวที่ฝังสามารถแทรกโทเค็นช่วงทดลองใช้ของบุคคลที่สามได้ หากต้นทางของผู้จดทะเบียนช่วงทดลองใช้จากต้นทางตรงกับโดเมนของผู้ออก (เว็บไซต์เดียวกันกับผู้ออก เช่น
- สร้าง Nonce แบบสุ่มที่เข้ารหัสลับต่อการแสดงผลแบบฟอร์มที่มีเอนโทรปีอย่างน้อย 128 บิต (เช่น
randomBytes(32).toString('base64url')จากnode:crypto) จัดเก็บไว้ในสถานะเซสชันฝั่งเซิร์ฟเวอร์ที่คีย์โดยคุกกี้รหัสเซสชันHttpOnly,Secure,SameSite=Lax(อย่าจัดเก็บค่าnonceที่คาดไว้ในคุกกี้ฝั่งไคลเอ็นต์โดยตรงซึ่งผู้โจมตีอาจเล่นซ้ำทั้งคุกกี้และโทเค็นได้) และแสดงผลเป็นแอตทริบิวต์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 ทางอีเมลมาตรฐานหรือ 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 === trueevtPayload.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']) ดังนี้
- ลายเซ็น 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()เพื่อไม่ให้ข้ามการยืนยันการเชื่อมโยงคีย์ได้ อย่าเขียนโค้ดเองสำหรับการแยกวิเคราะห์และการตรวจสอบลายเซ็นของ 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-verificationDNSTXTหรือเมื่อผู้ใช้ไม่มีเซสชันที่ใช้งานอยู่กับผู้ให้บริการอีเมลในเบราว์เซอร์ เบราว์เซอร์จะส่งแบบฟอร์มที่มีtokenว่างเปล่า - โทเค็นหมดอายุหรือไม่ถูกต้อง: หาก
nonceหมดอายุ มีการใช้งานไปแล้ว หรือไม่ผ่านขั้นตอนใดๆ ของverifyEmailVerificationTokenในhandleSignupSubmissionเซิร์ฟเวอร์จะตรวจพบข้อผิดพลาดและส่ง OTP แบบเดิมหรืออีเมล Magic Link โดยไม่รบกวนขั้นตอนการลงชื่อสมัครใช้หรือลงชื่อเข้าใช้ของผู้ใช้