在註冊、登入、結帳或帳戶救援期間收集電子郵件地址時,傳統上需要傳送頻外動態密碼 (OTP) 或魔術連結,才能驗證擁有權。這會迫使使用者離開網頁、等待電子郵件送達,並複製驗證碼,導致使用者體驗不佳、流失率和網路釣魚風險增加,同時也會向電子郵件服務供應商透露使用者造訪的依賴方 (RP)。
Email Verification API (用戶端 HTML 擴充功能) 和電子郵件驗證通訊協定 (EVP) (後端加密通訊協定) 可讓依賴方驗證使用者是否控制電子郵件地址,而不必傳送驗證電子郵件。瀏覽器會做為依賴方和使用者電子郵件服務供應商 (核發者) 之間的中介:
- 依賴方會在與電子郵件輸入欄位相同的
<form>中,加入含有autocomplete="email-verification-token"的隱藏<input>和單次使用的加密編譯nonce。 - 使用者選取或輸入電子郵件地址時,瀏覽器會查詢網域的
_email-verification.<domain>DNSTXT記錄,確認使用者與授權核發者之間有有效的活動時段,並要求電子郵件驗證權杖 (EVT),而不會向核發者揭露依賴方的身分。 - 瀏覽器會使用暫時瀏覽器金鑰組 (
EVT + KB-JWT) 簽署金鑰繫結 JWT (KB-JWT),將 EVT 繫結至依賴方的來源和工作階段nonce,然後填入隱藏輸入內容並提交表單。 - 依賴方的後端會驗證密碼編譯鏈結,如果有效,就會略過電子郵件動態密碼或魔術連結步驟。
1. 啟用來源試用並顯示驗證表單
如要以驗證網站 (依賴方) 的身分參與:
- 在託管表單的網頁上,使用
Origin-TrialHTTP 回應標頭或<meta http-equiv="origin-trial">元素提供來源試用權杖。- 第三方來源試用:只要來源試用註冊者的來源與發布者網域相符 (與發布者位於相同網站,例如
https://issuer.example),嵌入式身分 SDK 就能插入第三方試用權杖。
- 第三方來源試用:只要來源試用註冊者的來源與發布者網域相符 (與發布者位於相同網站,例如
- 每次轉譯表單時,產生加密隨機 Nonce,其熵至少為 128 位元 (例如
node:crypto的randomBytes(32).toString('base64url')),並將其儲存在伺服器端工作階段狀態中,以HttpOnly、Secure、SameSite=Lax工作階段 ID Cookie 為索引鍵 (切勿將預期的nonce值直接儲存在用戶端 Cookie 中,否則攻擊者可能會重送 Cookie 和權杖),然後將其轉譯為隱藏權杖輸入的nonce屬性。使用Cache-Control: no-store提供網頁,這樣往返瀏覽就不會重複使用已用過的 Nonce。 - 在與電子郵件輸入 (
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']),驗證兩個簽章和兩個權杖之間的加密繫結:
- EVT 簽章 (
verifier):根據issuerJwks進行驗證 (省略kid時,擷取ERR_JWKS_MULTIPLE_MATCHING_KEYS以疊代候選金鑰),強制執行iss和嚴格的新鮮度時間範圍 (maxTokenAge: '5m'、clockTolerance: '1m')。 - 持有者繫結 (
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-verificationDNSTXT記錄,或使用者在瀏覽器中未與電子郵件服務供應商建立有效工作階段,瀏覽器會提交空白token表單。 - 權杖過期或無效:如果
nonce已過期、已使用,或未通過handleSignupSubmission中verifyEmailVerificationToken的任何步驟,伺服器會擷取錯誤,並傳送傳統動態密碼或魔術連結電子郵件,不會中斷使用者的註冊或登入流程。