验证邮箱所有权

在注册、登录、结账或账号恢复过程中收集邮箱时,传统上需要发送带外动态密码 (OTP) 或魔法链接来验证所有权。这会迫使用户离开您的页面、等待电子邮件送达并复制验证码,从而导致用户体验不顺畅、用户流失和钓鱼式攻击风险,同时还会向邮件服务提供商透露用户正在访问哪个依赖方 (RP)。

借助邮箱验证 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"> 元素在托管表单的页面上提供源试用令牌。
    • 第三方源试用:嵌入式身份 SDK 可以注入第三方试用令牌,前提是源试用注册者的源与颁发者网域匹配(与颁发者位于同一网站,例如 https://issuer.example)。
  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-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']) 验证两个签名以及两个令牌之间的加密绑定:

  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 的任何步骤,服务器会捕获错误并发送传统的动态密码或魔法链接电子邮件,而不会中断用户的注册或登录流程。