Xác minh quyền sở hữu địa chỉ email

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):

  1. Bên bị phụ thuộc bao gồm một <input> ẩn có autocomplete="email-verification-token" và một nonce mậ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.
  2. 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> DNS TXT củ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.
  3. 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 nonce trong 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.
  4. 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):

  1. 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-Trial hoặ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).
  2. 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ên HttpOnly, Secure, SameSite=Lax (không bao giờ lưu trữ giá trị nonce dự 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ính nonce của dữ liệu đầu vào mã thông báo ẩn. Phân phát trang bằng Cache-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.
  3. 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.email khớ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ỏ qua kid khỏi tiêu đề EVT. Khi không có kid và JWKS chứa nhiều khoá, jose sẽ gửi ERR_JWKS_MULTIPLE_MATCHING_KEYS (một AsyncIterable của CryptoKey khớp) – lặp lại for 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']):

  1. Chữ ký EVT (verifier): Xác minh dựa trên issuerJwks (bắt ERR_JWKS_MULTIPLE_MATCHING_KEYS để lặp lại các khoá đề xuất khi bỏ qua kid), thực thi iss và một khoảng thời gian mới hẹp (maxTokenAge: '5m', clockTolerance: '1m').
  2. Liên kết chủ sở hữu (các tuỳ chọn kbVerifier và 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.alg và decoded.kbJwt.header.alg đều nằm trong ALLOWED_ALGS và 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-JWT bằng typ: 'kb+jwt'. Truyền keyBindingNonce: expectedNonce, expectedKeyBindingAudience: expectedAudience và keyBindingMaxAgeSeconds: 300 đến sdJwt.verify() – @sd-jwt/core chỉ gọi kbVerifier và xác thực nonce, aud, iat và sd_hash khi keyBindingNonce đượ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/core và jose trong Node.js) để phân tích cú pháp và xác thực bản trình bày EVT~KB-JWT, đồng thời truyền keyBindingNonce đến sdJwt.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ặc sd_hash tuỳ chỉnh.
  • NÊN tạo một nonce mậ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ằng Cache-Control: no-store.
  • NÊN đọc và xoá nonce dự kiến khỏi bộ nhớ phiên phía máy chủ ngay lập tức khi yêu cầu POST đế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.email và đị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 kid không có trong tiêu đề EVT (các nhà cung cấp như Gmail bỏ qua kid).
  • 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ữu iss nào trong EVT và xác nhận metadata.issuer === iss trong 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 token trố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ị token trố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 TXT DNS _email-verification hoặ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ó token trố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 trong verifyEmailVerificationToken trong handleSignupSubmission, 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.