メールアドレスの所有権を確認する

登録、ログイン、購入手続き、アカウント復元の際にメールアドレスを収集する場合、所有権の確認には、従来、帯域外のワンタイム パスコード(OTP)またはマジックリンクの送信が必要でした。この方法では、ユーザーはページを離れてメールの配信を待ち、コードをコピーする必要があります。これにより、摩擦、離脱、フィッシングのリスクが生じ、ユーザーがアクセスしている証明書利用者(RP)がメール プロバイダに開示されます。

Email Verification API(クライアントサイドの HTML 拡張機能)とメール確認プロトコル(EVP)(バックエンドの暗号プロトコル)を使用すると、証明書利用者は確認メールを送信せずに、ユーザーがメールアドレスを管理していることを確認できます。ブラウザは、証明書利用者とユーザーのメール プロバイダ(発行者)の間を仲介します。

  1. 証明書利用者は、メール入力と同じ <form> に、autocomplete="email-verification-token" を含む非表示の <input> と、1 回限りの暗号化 nonce を含めます。
  2. ユーザーがメールアドレスを選択または入力すると、ブラウザはドメインの _email-verification.<domain> DNS TXT レコードを検索し、ユーザーが信頼できる発行者とのアクティブなセッションを持っていることを確認して、証明書利用者(RP)の ID を発行者に開示することなく、メール確認トークン(EVT)をリクエストします。
  3. ブラウザは、一時的なブラウザ鍵ペア(EVT + KB-JWT)で署名された鍵バインディング JWT(KB-JWT)で、EVT を証明書利用者のオリジンとセッション nonce にバインドし、非表示の入力を入力してフォームを送信します。
  4. 証明書利用者のバックエンドが暗号チェーンを検証し、有効な場合は、メール OTP またはマジックリンクのステップをスキップします。

1. オリジン トライアルを有効にして確認フォームをレンダリングする

検証サイト(証明書利用者)として参加するには:

  1. Origin-Trial HTTP レスポンス ヘッダーまたは <meta http-equiv="origin-trial"> 要素を使用して、フォームをホストするページでオリジン トライアル トークンを提供します。
    • サードパーティのオリジン トライアル: 埋め込み ID SDK は、オリジン トライアル登録者のオリジンが発行元ドメイン(発行元と同じサイト、例: https://issuer.example)と一致する場合、サードパーティのトライアル トークンを挿入できます。
  2. フォームのレンダリングごとに、少なくとも 128 ビットのエントロピーを持つ暗号的にランダムなノンスを生成し(例: node:crypto の randomBytes(32).toString('base64url'))、HttpOnly、Secure、SameSite=Lax セッション ID Cookie をキーとするサーバーサイドのセッション状態に保存します(想定される nonce 値をクライアント Cookie 内に直接保存しないでください。攻撃者が Cookie とトークンの両方をリプレイする可能性があります)。そして、非表示のトークン入力の nonce 属性にレンダリングします。Cache-Control: no-store を使用してページを配信し、戻る / 進むナビゲーションですでに使用されたノンスが再利用されないようにします。
  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 をサポートしていないブラウザの場合や、ユーザーがメール プロバイダからログアウトしている場合など)、または確認に失敗した場合は、すぐに標準のメール 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 つのトークン間の暗号バインディングを検証します。

  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() に渡します。@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-verification DNS TXT レコードを公開していない場合、またはユーザーがブラウザでメール プロバイダとのアクティブなセッションを持っていない場合、ブラウザは空の token を含むフォームを送信します。
  • 期限切れまたは無効なトークン: nonce の有効期限が切れている、すでに使用されている、または handleSignupSubmission の verifyEmailVerificationToken のいずれかのステップで失敗した場合、サーバーはエラーをキャッチし、ユーザーの登録フローまたはログインフローを中断することなく、従来の OTP またはマジックリンク メールを送信します。