驗證電子郵件地址擁有權

在註冊、登入、結帳或帳戶救援期間收集電子郵件地址時,傳統上需要傳送頻外動態密碼 (OTP) 或魔術連結,才能驗證擁有權。這會迫使使用者離開網頁、等待電子郵件送達,並複製驗證碼,導致使用者體驗不佳、流失率和網路釣魚風險增加,同時也會向電子郵件服務供應商透露使用者造訪的依賴方 (RP)。

Email Verification API (用戶端 HTML 擴充功能) 和電子郵件驗證通訊協定 (EVP) (後端加密通訊協定) 可讓依賴方驗證使用者是否控制電子郵件地址,而不必傳送驗證電子郵件。瀏覽器會做為依賴方和使用者電子郵件服務供應商 (核發者) 之間的中介:

  1. 依賴方會在與電子郵件輸入欄位相同的 <form> 中,加入含有 autocomplete="email-verification-token" 的隱藏 <input> 和單次使用的加密編譯 nonce。
  2. 使用者選取或輸入電子郵件地址時,瀏覽器會查詢網域的 _email-verification.<domain> DNS TXT 記錄,確認使用者與授權核發者之間有有效的活動時段,並要求電子郵件驗證權杖 (EVT),而不會向核發者揭露依賴方的身分。
  3. 瀏覽器會使用暫時瀏覽器金鑰組 (EVT + KB-JWT) 簽署金鑰繫結 JWT (KB-JWT),將 EVT 繫結至依賴方的來源和工作階段 nonce,然後填入隱藏輸入內容並提交表單。
  4. 依賴方的後端會驗證密碼編譯鏈結,如果有效,就會略過電子郵件動態密碼或魔術連結步驟。

1. 啟用來源試用並顯示驗證表單

如要以驗證網站 (依賴方) 的身分參與:

  1. 在託管表單的網頁上,使用 Origin-Trial HTTP 回應標頭或 <meta http-equiv="origin-trial"> 元素提供來源試用權杖。
    • 第三方來源試用:只要來源試用註冊者的來源與發布者網域相符 (與發布者位於相同網站,例如 https://issuer.example),嵌入式身分 SDK 就能插入第三方試用權杖。
  2. 每次轉譯表單時,產生加密隨機 Nonce,其熵至少為 128 位元 (例如 node:crypto 的 randomBytes(32).toString('base64url')),並將其儲存在伺服器端工作階段狀態中,以 HttpOnly、Secure、SameSite=Lax 工作階段 ID Cookie 為索引鍵 (切勿將預期的 nonce 值直接儲存在用戶端 Cookie 中,否則攻擊者可能會重送 Cookie 和權杖),然後將其轉譯為隱藏權杖輸入的 nonce 屬性。使用 Cache-Control: no-store 提供網頁,這樣往返瀏覽就不會重複使用已用過的 Nonce。
  3. 在與電子郵件輸入 (autocomplete="email") 相同的 <form> 中,新增隱藏的權杖輸入 (autocomplete="email-verification-token")。
<!-- 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,或使用者已登出電子郵件服務供應商),或驗證失敗,請立即改用標準電子郵件動態密碼或魔術連結流程:

// 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 程式庫 (例如 Node.js 中的 @sd-jwt/core 和 jose、Python 中的 sd-jwt-python/jwcrypto,或 JVM 上的 sd-jwt-java/Nimbus JOSE+JWT) 來實作 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 逐位元組相符後,請透過 HTTPS 擷取 ${iss}/.well-known/email-verification,確認 metadata.issuer === iss,並載入核發者的 JSON Web Key Set (JWKS)。

kid 處理注意事項:部分主要電子郵件服務供應商 (例如 Gmail) 會省略 EVT 標頭中的 kid。如果沒有 kid,且 JWKS 包含多個金鑰,jose 會擲回 ERR_JWKS_MULTIPLE_MATCHING_KEYS (相符 CryptoKey 的 AsyncIterable),請疊代 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 進行驗證 (省略 kid 時,擷取 ERR_JWKS_MULTIPLE_MATCHING_KEYS 以疊代候選金鑰),強制執行 iss 和嚴格的新鮮度時間範圍 (maxTokenAge: '5m'、clockTolerance: '1m')。
  2. 持有者繫結 (kbVerifier 和 sdJwt.verify 選項):從 evtPayload.cnf.jwk 擷取瀏覽器的臨時公開金鑰,確認 cnf.jwk.alg 和 decoded.kbJwt.header.alg 都在 ALLOWED_ALGS 中且相符 (在來源試用實作轉換至 RFC 9864 'Ed25519' 時,將 'Ed25519' 和 'EdDSA' 視為等效),並使用 typ: 'kb+jwt' 驗證 KB-JWT 簽章。將 keyBindingNonce: expectedNonce、expectedKeyBindingAudience: expectedAudience 和 keyBindingMaxAgeSeconds: 300 傳遞至 sdJwt.verify():只有在提供 keyBindingNonce 時,@sd-jwt/core 才會叫用 kbVerifier 並驗證 nonce、aud、iat 和 sd_hash。
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 程式庫 (例如 Node.js 中的 @sd-jwt/core 和 jose) 剖析及驗證 EVT~KB-JWT 憑證,並將 keyBindingNonce 傳遞至 sdJwt.verify(),確保金鑰繫結驗證不會遭到略過。請勿手動建立自訂 JWT、SD-JWT 或 sd_hash 剖析和簽章驗證常式。
  • 務必在伺服器上為每個表單轉譯作業產生新的密碼編譯 nonce (至少 128 位元,例如 randomBytes(32).toString('base64url')),並儲存在伺服器端工作階段狀態 (而非直接儲存在用戶端 Cookie),然後使用 Cache-Control: no-store 提供表單頁面。
  • 務必在 POST 要求送達時,立即從伺服器端工作階段儲存空間自動讀取並刪除預期的 nonce,以免遭攔截的權杖遭到重播。
  • 務必在 evtPayload.email 和提交的表單電子郵件地址 (email.toLowerCase()) 之間進行不區分大小寫的比較,同時保留提交的電子郵件字串。
  • 如果 EVT 標頭中沒有 kid (Gmail 等供應商會省略 kid),請試驗驗證核發者 JWKS 中的所有金鑰。
  • 務必先驗證 DNS 委派 (_email-verification.<domain>),再信任 EVT 中的任何 iss 宣告,並確認 .well-known/email-verification 回應中的 metadata.issuer === iss。
  • 請勿將電子郵件擁有權驗證視為收件匣送達率的證明。EVP 可證明使用者能控管權威供應商的電子郵件帳戶;仍會傳送交易歡迎電子郵件,並照常處理退件。
  • token 為空白或驗證失敗時,請勿禁止使用者建立帳戶或登入,請一律改用標準電子郵件動態密碼或魔術連結流程。

備用策略

電子郵件驗證 (autocomplete="email-verification-token") 是傳統頻外電子郵件驗證 (動態密碼或魔術連結) 的漸進增強:

  • 不支援的瀏覽器:不支援 autocomplete="email-verification-token" 的瀏覽器會忽略隱藏的輸入內容,並提交 token 值為空白的表單。
  • 未經驗證或未參與的電子郵件網域:如果使用者輸入的電子郵件地址網域未發布 _email-verification DNS TXT 記錄,或使用者在瀏覽器中未與電子郵件服務供應商建立有效工作階段,瀏覽器會提交空白 token 表單。
  • 權杖過期或無效:如果 nonce 已過期、已使用,或未通過 handleSignupSubmission 中 verifyEmailVerificationToken 的任何步驟,伺服器會擷取錯誤,並傳送傳統動態密碼或魔術連結電子郵件,不會中斷使用者的註冊或登入流程。