在注册、登录、结账或账号恢复过程中收集邮箱时,传统上需要发送带外动态密码 (OTP) 或魔法链接来验证所有权。这会迫使用户离开您的页面、等待电子邮件送达并复制验证码,从而导致用户体验不顺畅、用户流失和钓鱼式攻击风险,同时还会向邮件服务提供商透露用户正在访问哪个依赖方 (RP)。
借助邮箱验证 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">元素在托管表单的页面上提供源试用令牌。- 第三方源试用:嵌入式身份 SDK 可以注入第三方试用令牌,前提是源试用注册者的源与颁发者网域匹配(与颁发者位于同一网站,例如
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-verification-token"),使其与电子邮件输入 (autocomplete="email") 位于同一<form>中。
<!-- 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 密钥集 (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的任何步骤,服务器会捕获错误并发送传统的动态密码或魔法链接电子邮件,而不会中断用户的注册或登录流程。