Khi thu thập địa chỉ email trong quá trình đăng ký, đăng nhập, thanh toán hoặc khôi phục tài khoản, việc xác minh quyền sở hữu thường yêu cầu gửi mật mã một lần (OTP) ngoài băng tần hoặc đường liên kết dùng một lần. Điều này buộc người dùng phải rời khỏi trang của bạn, chờ email được gửi đến và sao chép mã. Việc này gây ra sự phiền hà, tỷ lệ bỏ ngang và nguy cơ lừa đảo, đồng thời cũng tiết lộ cho nhà cung cấp dịch vụ email biết bên tin cậy (RP) mà người dùng đang truy cập.
API xác minh email (tiện ích HTML phía máy khách) và Giao thức xác minh email (EVP) (giao thức mật mã phụ trợ) cho phép bên bị phụ thuộc xác minh rằng người dùng kiểm soát một địa chỉ email mà không cần gửi email xác minh. Trình duyệt đóng vai trò trung gian giữa bên Relying Party và nhà cung cấp dịch vụ email của người dùng (bên phát hành):
- Bên bị phụ thuộc bao gồm một
<input>ẩn cóautocomplete="email-verification-token"và mộtnoncemật mã học dùng một lần trong cùng một<form>với dữ liệu đầu vào email. - Khi người dùng chọn hoặc nhập địa chỉ email, trình duyệt sẽ tra cứu bản ghi
_email-verification.<domain>DNSTXTcủa miền, xác minh rằng người dùng có một phiên hoạt động với tổ chức phát hành có thẩm quyền và yêu cầu Mã xác minh email (EVT) mà không tiết lộ danh tính của bên tin cậy cho tổ chức phát hành. - Trình duyệt liên kết EVT với nguồn gốc và phiên của bên bị phụ thuộc
noncetrong một JWT liên kết khoá (KB-JWT) được ký bằng một cặp khoá trình duyệt tạm thời (EVT + KB-JWT), điền vào dữ liệu đầu vào ẩn và gửi biểu mẫu. - Phần phụ trợ của bên bị phụ thuộc sẽ xác minh chuỗi mật mã và nếu hợp lệ, sẽ bỏ qua bước gửi mã OTP qua email hoặc đường liên kết dùng một lần.
1. Bật Bản dùng thử theo nguyên gốc và hiển thị Biểu mẫu xác minh
Cách tham gia với tư cách là trang web xác minh (bên tin cậy):
- Cung cấp mã thông báo Bản dùng thử theo nguyên gốc trên trang lưu trữ biểu mẫu bằng cách sử dụng tiêu đề phản hồi HTTP
Origin-Trialhoặc phần tử<meta http-equiv="origin-trial">.- Bản dùng thử theo nguyên gốc của bên thứ ba: SDK nhận dạng được nhúng có thể chèn mã thông báo dùng thử của bên thứ ba, miễn là nguồn gốc của người đăng ký Bản dùng thử theo nguyên gốc khớp với miền của công ty phát hành (cùng trang web với công ty phát hành, chẳng hạn như
https://issuer.example).
- Bản dùng thử theo nguyên gốc của bên thứ ba: SDK nhận dạng được nhúng có thể chèn mã thông báo dùng thử của bên thứ ba, miễn là nguồn gốc của người đăng ký Bản dùng thử theo nguyên gốc khớp với miền của công ty phát hành (cùng trang web với công ty phát hành, chẳng hạn như
- Tạo một số chỉ dùng một lần ngẫu nhiên bằng mật mã cho mỗi lần hiển thị biểu mẫu với ít nhất 128 bit entropy (ví dụ:
randomBytes(32).toString('base64url')từnode:crypto), lưu trữ số đó ở trạng thái phiên phía máy chủ được khoá bằng cookie mã nhận dạng phiênHttpOnly,Secure,SameSite=Lax(không bao giờ lưu trữ giá trịnoncedự kiến trực tiếp bên trong cookie ứng dụng nơi kẻ tấn công có thể phát lại cả cookie và mã thông báo) và hiển thị giá trị đó vào thuộc tínhnoncecủa dữ liệu đầu vào mã thông báo ẩn. Phân phát trang bằngCache-Control: no-stoređể thao tác di chuyển tiến/lùi không sử dụng lại số chỉ dùng một lần đã được sử dụng. - Thêm dữ liệu đầu vào mã thông báo ẩn (
autocomplete="email-verification-token") vào cùng một<form>với dữ liệu đầu vào email (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. Xác minh mã thông báo đã gửi trên máy chủ
Khi biểu mẫu được gửi, hãy coi trường token là dữ liệu đầu vào không đáng tin cậy. Đọc và xoá nonce dự kiến khỏi kho lưu trữ phiên phía máy chủ của bạn ngay khi nhận được. Nếu trường token trống (ví dụ: trong các trình duyệt không hỗ trợ EVP hoặc khi người dùng đã đăng xuất khỏi nhà cung cấp dịch vụ email của họ) hoặc nếu quy trình xác minh không thành công, hãy quay lại ngay quy trình OTP qua email hoặc đường liên kết dùng một lần tiêu chuẩn:
// 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);
}
Luôn sử dụng các thư viện SD-JWT và JOSE/JWT đã được thiết lập cho nền tảng phụ trợ (chẳng hạn như @sd-jwt/core và jose trong Node.js, sd-jwt-python/jwcrypto trong Python hoặc sd-jwt-java/Nimbus JOSE+JWT trên JVM) để triển khai verifyEmailVerificationToken thay vì tự viết mã phân tích cú pháp JWT hoặc SD-JWT tuỳ chỉnh.
Bước 1: Phân tích cú pháp các thông báo xác nhận quyền sở hữu SD-JWT và xác thực địa chỉ email
Sau khi sử dụng expectedNonce một lần từ bộ nhớ phiên phía máy chủ, hãy giải mã bản trình bày SD-JWT (EVT + KB-JWT) và xác minh rằng:
- Cả JWT của tổ chức phát hành (
jwt) và JWT liên kết khoá (kbJwt) đều có mặt, cùng với thông tin xác nhận quyền sở hữu của tổ chức phát hành (iss). evtPayload.email_verified === true.evtPayload.emailkhớp với email trong biểu mẫu đã gửi bằng cách so sánh không phân biệt chữ hoa chữ thường (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.');
}
Bước 2: Xác thực việc uỷ quyền TXT DNS và tải JWKS của tổ chức phát hành
Trước khi gửi yêu cầu mạng đến iss, hãy xác minh rằng miền của email đã gửi uỷ quyền cho chính xác tổ chức phát hành đó thông qua bản ghi TXT DNS _email-verification.<domain> (iss=<issuer-host> -> https://<issuer-host> không có cổng, đường dẫn hoặc dấu gạch chéo ở cuối). Sau khi việc uỷ quyền DNS khớp từng byte với iss, hãy tìm nạp ${iss}/.well-known/email-verification qua HTTPS, xác nhận rằng metadata.issuer === iss và tải Bộ khoá web JSON (JWKS) của tổ chức phát hành.
Lưu ý về cách xử lý
kid: Một số nhà cung cấp dịch vụ email lớn (chẳng hạn như Gmail) bỏ quakidkhỏi tiêu đề EVT. Khi không cókidvà JWKS chứa nhiều khoá,josesẽ gửiERR_JWKS_MULTIPLE_MATCHING_KEYS(mộtAsyncIterablecủaCryptoKeykhớp) – lặp lạifor await (const publicKey of err)để xác minh thử từng khoá, như trong Bước 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));
Bước 3: Xác minh chữ ký EVT và JWT liên kết khoá (KB-JWT)
Cuối cùng, hãy xác minh cả chữ ký và mối liên kết mật mã giữa hai mã thông báo bằng danh sách cho phép thuật toán bất đối xứng rõ ràng (['Ed25519', 'EdDSA', 'ES256']):
- Chữ ký EVT (
verifier): Xác minh dựa trênissuerJwks(bắtERR_JWKS_MULTIPLE_MATCHING_KEYSđể lặp lại các khoá đề xuất khi bỏ quakid), thực thiissvà một khoảng thời gian mới hẹp (maxTokenAge: '5m',clockTolerance: '1m'). - Liên kết chủ sở hữu (các tuỳ chọn
kbVerifiervàsdJwt.verify): Trích xuất khoá công khai tạm thời của trình duyệt từevtPayload.cnf.jwk, xác nhận cảcnf.jwk.algvàdecoded.kbJwt.header.algđều nằm trongALLOWED_ALGSvà khớp nhau (coi'Ed25519'và'EdDSA'là tương đương trong khi các phương thức triển khai Bản dùng thử theo nguyên gốc chuyển sang RFC 9864'Ed25519') và xác minh chữ kýKB-JWTbằngtyp: 'kb+jwt'. TruyềnkeyBindingNonce: expectedNonce,expectedKeyBindingAudience: expectedAudiencevàkeyBindingMaxAgeSeconds: 300đếnsdJwt.verify()–@sd-jwt/corechỉ gọikbVerifiervà xác thựcnonce,aud,iatvàsd_hashkhikeyBindingNonceđược cung cấp.
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,
});
Các phương pháp hay nhất
- NÊN thực hiện tất cả quy trình xác minh mã thông báo một cách nghiêm ngặt trên máy chủ. KHÔNG xác thực mã thông báo xác thực trong JavaScript phía máy khách hoặc tin tưởng bất kỳ trạng thái xác minh nào phía máy khách – các bước kiểm tra phía máy khách có thể dễ dàng bị bỏ qua hoặc giả mạo và không đảm bảo độ tin cậy cho bên tin cậy.
- NÊN sử dụng các thư viện SD-JWT và JOSE/JWT đã thiết lập cho nền tảng phụ trợ của bạn (ví dụ:
@sd-jwt/corevàjosetrong Node.js) để phân tích cú pháp và xác thực bản trình bàyEVT~KB-JWT, đồng thời truyềnkeyBindingNonceđếnsdJwt.verify()để không thể bỏ qua quy trình xác minh Liên kết khoá. KHÔNG tự tạo quy trình phân tích cú pháp và xác minh chữ ký JWT, SD-JWT hoặcsd_hashtuỳ chỉnh. - NÊN tạo một
noncemật mã mới (ít nhất 128 bit, chẳng hạn nhưrandomBytes(32).toString('base64url')) trên máy chủ cho mỗi lần kết xuất biểu mẫu, lưu trữ trong trạng thái phiên phía máy chủ (không trực tiếp trong cookie của ứng dụng) và phân phát trang biểu mẫu bằngCache-Control: no-store. - NÊN đọc và xoá
noncedự kiến khỏi bộ nhớ phiên phía máy chủ ngay lập tức khi yêu cầuPOSTđến để không thể phát lại mã thông báo bị chặn. - NÊN thực hiện so sánh không phân biệt chữ hoa chữ thường giữa
evtPayload.emailvà địa chỉ email đã gửi trong biểu mẫu (email.toLowerCase()), đồng thời giữ nguyên chuỗi email đã gửi. - NÊN xác minh thử tất cả khoá trong JWKS của nhà phát hành khi
kidkhông có trong tiêu đề EVT (các nhà cung cấp như Gmail bỏ quakid). - NÊN xác minh việc uỷ quyền DNS (
_email-verification.<domain>) trước khi tin tưởng bất kỳ thông tin xác nhận quyền sở hữuissnào trong EVT và xác nhậnmetadata.issuer === isstrong phản hồi.well-known/email-verification. - KHÔNG coi việc xác minh quyền sở hữu email là bằng chứng về khả năng gửi thư đến hộp thư đến. EVP chứng minh rằng người dùng kiểm soát tài khoản email với nhà cung cấp có thẩm quyền; vẫn gửi email chào mừng giao dịch và xử lý thư bị trả lại như bình thường.
- KHÔNG chặn việc tạo tài khoản hoặc đăng nhập khi
tokentrống hoặc khi không xác minh được. Hãy luôn hạ cấp một cách linh hoạt xuống quy trình OTP qua email hoặc đường liên kết dùng một lần tiêu chuẩn.
Chiến lược dự phòng
Tính năng Xác minh email (autocomplete="email-verification-token") được thiết kế dưới dạng một cải tiến tăng dần so với phương thức xác minh email ngoài băng tần truyền thống (mã OTP hoặc đường liên kết dùng một lần):
- Trình duyệt không được hỗ trợ: Trình duyệt không hỗ trợ
autocomplete="email-verification-token"sẽ bỏ qua dữ liệu đầu vào bị ẩn và gửi biểu mẫu có giá trịtokentrống. - Miền email không được xác thực hoặc không tham gia: Khi người dùng nhập một địa chỉ email có miền không xuất bản bản ghi
TXTDNS_email-verificationhoặc khi người dùng không có phiên hoạt động với nhà cung cấp dịch vụ email của họ trong trình duyệt, trình duyệt sẽ gửi biểu mẫu cótokentrống. - Mã thông báo đã hết hạn hoặc không hợp lệ: Nếu
nonceđã hết hạn, đã được sử dụng hoặc không thực hiện được bước nào trongverifyEmailVerificationTokentronghandleSignupSubmission, thì máy chủ sẽ phát hiện lỗi và gửi OTP truyền thống hoặc email có đường liên kết dùng một lần mà không làm gián đoạn quy trình đăng ký hoặc đăng nhập của người dùng.