如需了解更多详情,您可以浏览电子邮件提供商模拟演示代码,并参阅电子邮件验证 API 和电子邮件验证协议提案中的签发方步骤。
作为签发者,您无需注册源试用或提供令牌,因为依赖方网站会触发浏览器行为。确保您的端点已配置为响应这些请求。
配置发行方发现
如需允许浏览器在选择属于您网域的电子邮件地址时自动发现您的验证端点,请使用 DNS 和 .well-known HTTP 端点公开您的配置。
配置 DNS 委托记录
在您的电子邮件网域中配置 DNS TXT 记录,以将验证权限委托给您的签发者标识符。这些标识符可以使用相同的网域,具体取决于您的基础架构。
记录格式:_email-verification.<email-domain>
区域文件示例:
_email-verification.example.com IN TXT "iss=accounts.issuer.example"
托管 .well-known/email-verification 端点
在发布者网域的 /.well-known/ 路径下托管 JSON 元数据文件。此文件概述了您的签发功能以及您的基础架构支持的加密签名算法。
端点:https://<issuer-domain>/.well-known/email-verification
示例响应:
{
"issuance_endpoint": "https://accounts.issuer.example/email-verification/issuance",
"jwks_uri": "https://accounts.issuer.example/.well-known/vc-public-jwks",
"signing_alg_values_supported": ["EdDSA", "ES256"]
}
托管 .well-known/web-identity 端点
您可能已经实现了额外的 .well-known JSON 资源,作为联合凭据 (FedCM) API 的一部分。此资源提供指向您的账号端点和登录网址的链接。
端点:https://<domain>/.well-known/web-identity
示例响应:
{
"accounts_endpoint": "https://accounts.issuer.example/accounts",
"login_url": "https://accounts.issuer.example/login"
}
使用账号端点
FedCM API 中的账号端点会提供当前已登录账号的列表。以下示例展示了一个最简响应。如需了解详情,请参阅身份提供方实施指南。
端点:如 .well-known/web-identity 中所指定
以下是一个示例响应:
{
"accounts": [
{
"id": "demo-example",
"name": "Demo User",
"email": "demo@example.com",
"given_name": "Demo"
}
]
}
与登录状态 API 集成
用户必须与提供方建立有效的会话,并且您必须使用 Login Status API 向浏览器发出信号。
当用户成功登录或退出时,提供匹配的 HTTP 响应标头:
Set-Login: logged-in
Set-Login: logged-out
或者,在 Web 应用上下文中使用 JavaScript 更新状态:
navigator.login.setStatus("logged-in");
navigator.login.setStatus("logged-out");
处理签发请求
您的 issuance_endpoint 收到包含 email 密钥和 Signature、Signature-Input 和 Signature-Key 的 HTTP 消息签名标头的 application/json POST 请求。
使用支持结构化标头和 HTTP 消息签名的库(适用于您的环境)。在 Node.js 中,您可以使用 structured-headers 和 http-message-sig。
完整请求格式:
POST /email-verification/issuance HTTP/1.1
Host: provider.example
Accept: application/json
Content-Digest: sha-256=:aBc123aBc123aBc123aBc123aBc123=:
Content-Type: application/json
Sec-Fetch-Dest: email-verification
Signature: sig=:+dEf567dEf567/dEf567dEf567dEf567/dEf567==:
Signature-Input: sig=("@method" "@authority" "@path" "content-digest" "signature-key");created=1786455840
Signature-Key: sig=hwk;crv="Ed25519";kty="OKP";x="gHi890_gHi890_gHi890"
{email: "demo@example.com"}
解析并验证请求:
- 会话身份验证:验证随请求发送的第一方会话 Cookie。用户必须通过身份验证。
Sec-Fetch-Dest标头:设置为email-verification。- HTTP 消息签名:使用
Signature-Key中的临时公钥验证请求签名,并验证Content-Digest。 - 载荷:JSON 正文包含请求验证的
email字符串。
签发响应
成功验证会话和请求令牌后,生成已签名的选择性披露 JWT (SD-JWT),并使用适合您平台的库以 JSON 格式返回。例如,对于 Node,您可以使用 @sd-jwt/core 和 jose。
原始载荷格式应如下所示:
{
"iss": "https://accounts.issuer.example",
"iat": 1780272000,
"exp": 1780272300,
"cnf": {
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "pUbLiCKeY123pUbLiCKeY123pUbLiCKeY123",
"y": "pUbLiCKeY456pUbLiCKeY456pUbLiCKeY456"
}
},
"email": "demo@example.com",
"email_verified": true
}
创建、签名并返回令牌:
import { importJWK, CompactSign } from "jose";
import { SDJwtInstance } from "@sd-jwt/core";
import crypto from "node:crypto";
// Issuer private key from secure storage
const privateKey = await importJWK(PRIVATE_KEY_JWK, "EdDSA");
const origin = url.origin;
const currentTime = Math.floor(Date.now() / 1000);
const evtPayload = {
iss: origin,
iat: currentTime,
exp: currentTime + 300, // 5 minutes
cnf: {
jwk: browserJwk,
},
email: payload.email, // exactly as received in payload
email_verified: true,
};
const sdJwt = new SDJwtInstance({
signer: async (data) => {
const [headerB64, payloadB64] = data.split(".");
const header = JSON.parse(Buffer.from(headerB64, "base64url").toString());
const payload = Buffer.from(payloadB64, "base64url");
const signed = await new CompactSign(payload).setProtectedHeader(header).sign(privateKey);
return signed.split(".").pop()!;
},
signAlg: "EdDSA",
hasher: async (data, alg) => {
const nodeAlg = alg.replace("-", "");
return new Uint8Array(crypto.createHash(nodeAlg).update(data).digest());
},
hashAlg: "sha-256",
saltGenerator: async () => crypto.randomBytes(16).toString("base64url"),
});
const issuanceToken = await sdJwt.issue(evtPayload, undefined, {
header: {
alg: "EdDSA",
kid: PRIVATE_KEY_JWK.kid,
typ: "evt+jwt",
},
});
return sendResponse({ issuance_token: issuanceToken, }, 200);
生成的响应正文类似于以下内容:
{
"issuance_token": "tOkEn123tOkEn123tOkEn123...~"
}
创建并签署电子邮件验证令牌后,浏览器会将此令牌传递给验证器网站以进行验证。
问题排查
如果浏览器未与您的端点联系或拒绝了已签发的令牌,请检查以下常见问题:
浏览器从不调用 accounts_endpoint 或 issuance_endpoint
- 未设置登录状态:Chrome 仅在知道用户已登录时查询端点。确保登录流程设置了
Set-Login: logged-inHTTP 标头或调用了navigator.login.setStatus("logged-in")。 - 会话 Cookie 被屏蔽 (
SameSite=None):浏览器从不同网站上的信赖方获取accounts_endpoint和issuance_endpoint。由于这些是跨网站请求,因此您的会话 Cookie 必须包含SameSite=None; Secure。在同网站验证器上进行测试时,SameSite=LaxCookie 可以正常运行,但在跨网站请求中会被省略。 - 发现或账号不匹配:验证
_email-verification.<email-domain>是否返回单个 TXT 记录(iss=<issuer-domain>,不含https://)、两个.well-known端点是否都返回Content-Type: application/json,以及accounts_endpoint响应是否包含email与输入的地址匹配的账号。
签发请求验证失败
Sec-Fetch-Dest标头拼写:Chrome 154 及更高版本发送Sec-Fetch-Dest: email-verification(带连字符),而 Chrome 153 发送emailverification(不带连字符)。在发布期间接受这两个值。- 代理后面的 HTTP 消息签名 (
@authority) 不匹配:验证 RFC 9421 签名时,@authority组件反映的是面向公众的主机。如果您的服务器位于反向代理或负载均衡器后面,请使用X-Forwarded-Host(或您的公共来源)而非内部主机名来重建验证网址,并在 JSON 解析之前计算原始请求正文字节的Content-Digest。
浏览器拒绝返回的 issuance_token
- 经过修改或规范化的
email声明:从 Chrome 156 开始,浏览器会检查 EVTemail声明是否与所请求的email完全一致。如果后端将地址规范化为标准账号格式(例如,在请求first.LAST@example.com时返回First.Last@example.com),Chrome 会舍弃令牌。将请求与用户账号进行匹配,但返回在请求正文中收到的确切email字符串。 - 缺少末尾的波浪号 (
~):即使披露内容为零,issuance_token也必须是有效的 SD-JWT,并且以末尾的波浪号 (<Issuer-signed-JWT>~) 结尾,以便浏览器可以附加<KB-JWT>。 iss或cnf.jwk不匹配:确保 EVTiss声明是与.well-known/email-verification元数据完全匹配的 HTTPS 源(https://<issuer-domain>,不含尾部斜杠),并且cnf.jwk会嵌入来自Signature-Key标头的浏览器临时公钥。