登録、ログイン、購入手続き、アカウント復元の際にメールアドレスを収集する場合、所有権の確認には、従来、帯域外のワンタイム パスコード(OTP)またはマジックリンクの送信が必要でした。この方法では、ユーザーはページを離れてメールの配信を待ち、コードをコピーする必要があります。これにより、摩擦、離脱、フィッシングのリスクが生じ、ユーザーがアクセスしている証明書利用者(RP)がメール プロバイダに開示されます。
Email Verification API(クライアントサイドの HTML 拡張機能)とメール確認プロトコル(EVP)(バックエンドの暗号プロトコル)を使用すると、証明書利用者は確認メールを送信せずに、ユーザーがメールアドレスを管理していることを確認できます。ブラウザは、証明書利用者とユーザーのメール プロバイダ(発行者)の間を仲介します。
- 証明書利用者は、メール入力と同じ
<form>に、autocomplete="email-verification-token"を含む非表示の<input>と、1 回限りの暗号化nonceを含めます。 - ユーザーがメールアドレスを選択または入力すると、ブラウザはドメインの
_email-verification.<domain>DNSTXTレコードを検索し、ユーザーが信頼できる発行者とのアクティブなセッションを持っていることを確認して、証明書利用者(RP)の ID を発行者に開示することなく、メール確認トークン(EVT)をリクエストします。 - ブラウザは、一時的なブラウザ鍵ペア(
EVT + KB-JWT)で署名された鍵バインディング JWT(KB-JWT)で、EVT を証明書利用者のオリジンとセッションnonceにバインドし、非表示の入力を入力してフォームを送信します。 - 証明書利用者のバックエンドが暗号チェーンを検証し、有効な場合は、メール OTP またはマジックリンクのステップをスキップします。
1. オリジン トライアルを有効にして確認フォームをレンダリングする
検証サイト(証明書利用者)として参加するには:
Origin-TrialHTTP レスポンス ヘッダーまたは<meta http-equiv="origin-trial">要素を使用して、フォームをホストするページでオリジン トライアル トークンを提供します。- サードパーティのオリジン トライアル: 埋め込み ID SDK は、オリジン トライアル登録者のオリジンが発行元ドメイン(発行元と同じサイト、例:
https://issuer.example)と一致する場合、サードパーティのトライアル トークンを挿入できます。
- サードパーティのオリジン トライアル: 埋め込み ID SDK は、オリジン トライアル登録者のオリジンが発行元ドメイン(発行元と同じサイト、例:
- フォームのレンダリングごとに、少なくとも 128 ビットのエントロピーを持つ暗号的にランダムなノンスを生成し(例:
node:cryptoのrandomBytes(32).toString('base64url'))、HttpOnly、Secure、SameSite=Laxセッション ID Cookie をキーとするサーバーサイドのセッション状態に保存します(想定されるnonce値をクライアント Cookie 内に直接保存しないでください。攻撃者が Cookie とトークンの両方をリプレイする可能性があります)。そして、非表示のトークン入力のnonce属性にレンダリングします。Cache-Control: no-storeを使用してページを配信し、戻る / 進むナビゲーションですでに使用されたノンスが再利用されないようにします。 - 非表示のトークン入力(
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 をサポートしていないブラウザの場合や、ユーザーがメール プロバイダからログアウトしている場合など)、または確認に失敗した場合は、すぐに標準のメール OTP またはマジックリンク フローにフォールバックします。
// 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);
}
verifyEmailVerificationToken を実装するには、カスタム JWT または SD-JWT 解析コードを手動で作成するのではなく、バックエンド プラットフォーム用に確立された SD-JWT ライブラリと JOSE/JWT ライブラリ(Node.js の @sd-jwt/core と jose、Python の sd-jwt-python / jwcrypto、JVM の sd-jwt-java / Nimbus JOSE+JWT など)を常に使用してください。
ステップ 1: SD-JWT クレームを解析してメールアドレスを検証する
サーバーサイドのセッション ストレージから 1 回限りの 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)をスローします。ステップ 3 に示すように、for await (const publicKey of err)を反復処理して各鍵を試行検証します。
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'])を使用して、両方の署名と 2 つのトークン間の暗号バインディングを検証します。
- 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()に渡します。@sd-jwt/coreは、keyBindingNonceが指定されている場合にのみ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(randomBytes(32).toString('base64url')など、128 ビット以上)を生成し、サーバーサイドのセッション状態(クライアント Cookie に直接保存しない)に保存して、Cache-Control: no-storeを含むフォームページを提供します。 - 推奨:
POSTリクエストが届いたら、想定されるnonceをサーバー側のセッション ストレージから直ちにアトミックに読み取って削除し、インターセプトされたトークンが再利用されないようにします。 - 必ず、
evtPayload.emailと送信されたフォームのメールアドレス(email.toLowerCase())を大文字と小文字を区別せずに比較し、送信されたメール文字列を保持してください。 kidが EVT ヘッダーにない場合(Gmail などのプロバイダはkidを省略します)、発行元の JWKS 内のすべての鍵を試験的に検証してください。- EVT の
issクレームを信頼する前に、DNS 委任(_email-verification.<domain>)を確認してください。また、.well-known/email-verificationレスポンスのmetadata.issuer === issを確認してください。 - メールアドレスの所有権の確認を、受信トレイへの配信可能性の証明として扱わないでください。EVP は、ユーザーが信頼できるプロバイダのメール アカウントを管理していることを証明します。トランザクションのウェルカム メールを送信し、バウンスを通常どおり処理します。
tokenが空の場合や確認に失敗した場合に、アカウントの作成やログインをブロックしないでください。常に標準のメール OTP またはマジックリンク フローに適切にフォールバックしてください。
フォールバック戦略
メール確認(autocomplete="email-verification-token")は、従来の帯域外メール確認(OTP コードまたはマジックリンク)のプログレッシブ エンハンスメントとして設計されています。
- サポートされていないブラウザ:
autocomplete="email-verification-token"をサポートしていないブラウザは、非表示の入力を無視し、空のtoken値を含むフォームを送信します。 - 認証されていない、または参加していないメールドメイン: ユーザーが入力したメールアドレスのドメインが
_email-verificationDNSTXTレコードを公開していない場合、またはユーザーがブラウザでメール プロバイダとのアクティブなセッションを持っていない場合、ブラウザは空のtokenを含むフォームを送信します。 - 期限切れまたは無効なトークン:
nonceの有効期限が切れている、すでに使用されている、またはhandleSignupSubmissionのverifyEmailVerificationTokenのいずれかのステップで失敗した場合、サーバーはエラーをキャッチし、ユーザーの登録フローまたはログインフローを中断することなく、従来の OTP またはマジックリンク メールを送信します。