12k
All articles

メールを送信せずにメールアドレスを検証する

ブラウザーのメール認証で、メールを送らずに受信箱へのアクセスを確認する方法を解説。フォーム設定、Node.jsでの5段階のトークン検証、試験運用の制限も紹介します。

OpenReplay Team
OpenReplay Team
メールを送信せずにメールアドレスを検証する

Email Verification Protocolを使うと、サイトはメールを送信することなく、ユーザーがそのメールアドレスを管理していることを確認できます。ブラウザがユーザーのメールプロバイダーから署名付きトークンを取得し、フォームと一緒に送信する仕組みです。

ほとんどのサインアップフローでは、いまだに「受信トレイを確認してください」と表示されます。そしてコードを探しにタブを切り替えたユーザーの一部は、二度と戻ってきません。Chromeはこれに代わる仕組みをテストしています。2026年7月8日に発表されたChromeのオリジントライアルはChrome 150で始まり、その後Chromeは2026年8月のアップデートを公開しました。プロトコル自体は、IETFのInternet-Draft draft-hardt-email-verificationで定義されています。

これはSMTPやMXによるリストクリーニングとは異なります。これらのツールはメールボックスが存在するかどうかを確認するものです。一方このプロトコルは、フォームに入力している本人がそのメールボックスにサインインしていることを証明します。以下のセクションでは、三者モデル、フォームのマークアップ、5つのステップに分けたNode.jsによる完全な検証実装、そしてトライアル期間中にどこまで頼れるかについて解説します。

重要なポイント

  • Email Verification Protocolは、キーバインディング付きのSD-JWTを返します。これは、発行者(issuer)が署名したEmail Verification Token、チルダ、ブラウザが署名したキーバインディングJWTで構成されます。
  • ブラウザがサイトとメールプロバイダーの間に立つため、プロバイダーはどのサイトが要求したかを知ることなくトークンを発行します。
  • 有効なトークンは、そのアドレスについてメールプロバイダーとのアクティブなセッションが存在することを証明します。そのアドレスに送信したメールが配信されることを証明するものではありません。
  • この機能はChromeオリジントライアル中の提案段階であり、トラフィック制限があり、Chromeの投稿には終了日が記載されていません。ドラフトの著者ら(Chromeではありません)はIETF 126のスライドで3〜6か月と見積もっています。Chromeのドキュメントで参加プロバイダーとして名前が挙げられているのはGmailのみです。
  • hiddenトークンフィールドが空で届いた場合や、いずれかの検証ステップが失敗した場合は、通常の確認メールを送信してください。

ブラウザによるメール検証は何を置き換えるのか?

ブラウザによるメール検証は、ユーザーをサイトの外へ誘導してしまうワンタイムコードやマジックリンクのメールを置き換えます。IETFドラフトでは、コード方式がユーザーに求める手順が説明されています。メールアプリに移動し、配信を待ち、場合によっては迷惑メールフォルダを探し、コードをコピーしてから戻って貼り付ける、という流れです。ドラフトはさらに、多くの人が途中で離脱すると付け加えています。マジックリンクならコピー&ペーストの手間は省けますが、ユーザーは依然として受信トレイに移動し、メッセージの到着を待たなければなりません。

ソーシャルログインはメールそのものを省略します。ドラフトはその代償についても触れています。ユーザーはそのプロバイダーのアカウントを持っている必要があり、サイト側はプロバイダーごとに連携を構築しなければなりません。Email Verification Protocolは同じ原理(プロバイダーがアドレスを保証する)を用いながら、ブラウザを介して動作するため、サイトにプロバイダー固有のSDKを組み込む必要がありません。

Email Verification Protocolの仕組み:三者モデル

Email Verification Protocolには三者が関わります。あなたのサイトは検証者(verifier、リライングパーティ)です。ブラウザは、あなたのサイトと、ユーザーのメールアカウントを運営するサービスである発行者(issuer)との間に位置します。すべてのやり取りがブラウザを経由するため、発行者はどのサイトがトークンを要求したのかを知ることはありません。

Verifier (your site) <--> Browser <--> Issuer (e.g. accounts.google.com)
   nonce, validation      binds token      checks session,
                          to your origin   signs EVT

メールプロバイダーと発行者は別のホストでも構いません。Chromeの例では、プロバイダーはgmail.com、発行者はaccounts.google.comです。

発行者側では、メールドメインが発行者に委任するDNS TXTレコードを公開します。発行者は/.well-known/email-verificationメタデータを提供し、Login Status APIを通じてログイン状態をブラウザに通知し、何かに署名する前に、ユーザーの既存のファーストパーティセッションCookieを確認します(これらのCookieをどう設定すべきかについてはSecure Cookies and HttpOnlyを参照してください)。ドラフトでは、ブラウザがサイトごとのプライベートアドレスを要求することも可能で、その場合EVTにはis_private_email: trueが付与されます。Chromeのトライアルドキュメントではプライベートアドレスについて説明されていません。以前のリビジョンのドラフトでは、有効なCookieが存在しない場合に発行者がWebAuthnチャレンジにフォールバックすることが認められていましたが、リビジョン-02でこの選択肢は削除されたため、現在はセッションのないリクエストは単純に失敗します。

サイト側:フォームのマークアップとnonce

サイト側では、フォームに2つのinputが必要です。autocomplete="email"を指定した通常のemailフィールドと、ユーザーのセッションに紐づいたnonceを保持する、autocomplete="email-verification-token"を指定したhiddenフィールドです。フォームが送信されると、ブラウザがhiddenフィールドにトークンを入力します。以下はChromeの投稿に掲載されているマークアップで、nonceはサーバーがレンダリングしています:

<input
  name="email-address"
  type="email"
  autocomplete="email">
<input
  type="hidden"
  name="token"
  nonce="rAnD0m-VaLuE"
  autocomplete="email-verification-token">

ドラフトのセッションバインディングに関する規定では、少なくとも128ビットのエントロピーを持つランダムなnonceが求められています。検証リクエストごとに新しいnonceを生成し、短時間で失効させてください。検証処理のステップ2で必要になるため、レンダリング時刻も一緒に保存しておきます:

import { randomBytes } from "node:crypto";

const nonce = randomBytes(32).toString("base64url"); // 256 bits
req.session.evp = { nonce, renderedAt: Date.now() };

ページにはオリジントライアルトークンも必要です。その指定方法はChromeのオリジントライアルのドキュメントで説明されています。8月のアップデートによると、この機能ではサードパーティのオリジントライアルはサポートされていません。

サーバーでのトークン検証

Email Verification Protocolのトークンをサーバー側で検証するには、5つのステップを踏みます。パース、期待値の確認、キーバインディングの確認、DNS委任の確認、そして発行者の署名の検証です。フォームの値は<EVT>~<KB-JWT>という形式です。EVTは発行者が署名したトークンで、email、email_verified、iss、iat、およびブラウザの鍵であるcnf.jwkを保持しています。KB-JWTはブラウザが署名したもので、aud、nonce、iat、sd_hashを保持しています。以下のすべてのステップをパスするまでは、デコードしたクレームを一切信用しないでください。コードではjoseとNodeの組み込みモジュールを使用します。各ブロックは、何らかの例外がスローされた場合にfalseを返す1つのverifyEmailToken()関数内のtryブロックの中で実行されます。

5つのステップで必要なimportは以下のとおりです:

import { createHash } from "node:crypto";
import { resolveTxt } from "node:dns/promises";
import {
  createRemoteJWKSet,
  decodeJwt,
  decodeProtectedHeader,
  importJWK,
  jwtVerify,
} from "jose";

1. パース。 値をチルダで分割し、typヘッダーを確認します。

const [evt, kb, ...rest] = token.split("~");
if (!evt || !kb || rest.length) return false;
const evtHeader = decodeProtectedHeader(evt);
const kbHeader = decodeProtectedHeader(kb);
if (evtHeader.typ !== "evt+jwt" || kbHeader.typ !== "kb+jwt") return false;
const evtClaims = decodeJwt(evt);
const kbClaims = decodeJwt(kb);

現在のトークンには選択的開示(selective disclosure)が含まれていないため、値はきれいに2つに分割できます。8月のアップデートでは、将来ディスクロージャーが追加された場合に壊れるため、この簡略化は脆弱であると指摘されています。Chromeは、@sd-jwt/coreのようなSD-JWTライブラリでトークンをパースすることを推奨しており、Chrome自身のデモも現在はこれを使用しています。

2. 期待値。 Chromeの検証手順では、email_verified、email、nonce、aud、およびフォームのレンダリング後の時刻であるiatを確認します。メールアドレスは大文字・小文字を区別せずに比較してください。8月のアップデートでは、プロバイダーがユーザーの入力とは異なる大文字・小文字でアドレスを返す場合があると警告されています。

const { nonce, renderedAt } = req.session.evp ?? {};
delete req.session.evp; // single use
if (evtClaims.email_verified !== true) return false;
if (String(evtClaims.email).toLowerCase() !== submittedEmail.toLowerCase()) return false;
if (!nonce || kbClaims.nonce !== nonce) return false;
if (kbClaims.aud !== "https://example.com") return false;
if (kbClaims.iat < Math.floor(renderedAt / 1000)) return false;
// Any maximum age is your own policy; the sources define none.

3. キーバインディング。 sd_hashは、末尾の~を含むEVTのSHA-256です。さらに、KB-JWTはcnf.jwkの鍵で検証できなければなりません。

const hash = createHash("sha256").update(evt + "~").digest("base64url");
if (hash !== kbClaims.sd_hash) return false;
const holderKey = await importJWK(evtClaims.cnf.jwk, kbHeader.alg);
await jwtVerify(kb, holderKey, { typ: "kb+jwt" });

4. DNS委任。 _email-verification.<domain>を問い合わせます。ドラフトでは、TXTレコードがちょうど1つだけ存在し、その値がiss=で始まることが求められています。ドメインを手動で確認するにはdig +short TXT _email-verification.gmail.comを使用します。

const domain = submittedEmail.split("@").pop().toLowerCase();
const records = (await resolveTxt(`_email-verification.${domain}`))
  .map((chunks) => chunks.join(""));
if (records.length !== 1 || !records[0].startsWith("iss=")) return false;
const issuer = records[0].slice(4);

5. ディスカバリーとEVTの署名。 発行者のメタデータを取得し、そのjwks_uriを使ってEVTを検証します。ドラフトでは、発行者識別子をhttps://の後にDNSレコードのホストを続けたものと定義しており、EVTのissクレームはこれと完全に一致しなければなりません。

const meta = await (await fetch(`https://${issuer}/.well-known/email-verification`)).json();
const jwks = createRemoteJWKSet(new URL(meta.jwks_uri));
const { payload } = await jwtVerify(evt, jwks, {
  algorithms: meta.signing_alg_values_supported,
  typ: "evt+jwt",
}); // written so it does not depend on a kid header
// The draft's issuer identifier is "https://" plus the DNS host, compared byte for byte.
if (payload.iss !== `https://${issuer}`) return false;
return true;
チェック対象失敗時
パース~による分割、typヘッダー確認メールを送信
期待値email_verified、email、nonce、aud、iat確認メールを送信
キーバインディングsd_hash、cnf.jwkに対するKB-JWTの署名確認メールを送信
DNS単一のiss= TXTレコード確認メールを送信
発行者の署名メタデータ、jwks_uri、iss確認メールを送信

Node DNSモジュールは各TXTレコードをチャンクの配列として返すため、ステップ4ではそれらを結合しています。

ブラウザベースのメール検証の限界とは?

Email Verification Protocolのチェックに成功したということは、ユーザーが現在そのアドレスでメールプロバイダーにサインインしていることを示します。そこに送ったメールが届くかどうかについては何も示しません。Chromeもドラフトもこの点を明言しているので、ウェルカムメールは引き続き送信してください。

ユーザーがフォームに入力するブラウザプロファイルは、すでにプロバイダーとのサインイン済みセッションを保持している必要があります。また、アドレスを初めて検証する際には、アドレスごとに1回、許可を求めるプロンプトが表示されます。7月のリリースでは、アドレスはオートフィルから入力される必要がありました。8月のアップデートによると、現在はアドレスを手入力または貼り付けした場合でも、フィールドからフォーカスが外れたときに検証が開始されます。ドラフトでは、わずかなプライバシー上の情報漏洩も指摘されています。トークンが届くかどうかによって、ユーザーが発行者にサインインしているかどうかがサイトに分かってしまうのです。

今すべきこと

Email Verification Protocolは、まだ確認メールを置き換えられる段階にはありません。ブラウザAPIはWICGでインキュベーション中であり、プロトコルは個人提出のInternet-Draft(リビジョン-02、2026年8月)です。Chromeは、リリース前にサイトがこの機能を前提とした構築を行わないよう、トライアルのトラフィックに上限を設けています。Chromeの投稿には終了日が記載されていません。3〜6か月という見積もりは、Chromeではなく、ドラフトの著者らによるIETF 126 DISPATCHのスライドに記載されたものです。Chromeは後方互換性のない変更についても警告しており、8月のアップデートでは、この機能はChrome 152まではデスクトップ専用で、Androidのサポートはまだ検討中であるとされています。Chromeのドキュメントで参加プロバイダーとして名前が挙げられているのはGmailのみです。なお、ドラフトにはHellōも実装済みの発行者として記載されており、Microsoft Edgeのオリジントライアルについても言及されています。

トークン発行の仕組みはすでに一度変更されています。7月の投稿では、フォームエンコードされたrequest_token JWTについて説明されていました。ドラフトではこれがHTTP Message Signatures(RFC 9421)に置き換えられ、旧フォーマットは非推奨とされています。Chrome 153以降、ChromeはSignature、Signature-Input、Signature-Keyヘッダーを付けたapplication/jsonを送信し、ボディにはメールアドレスのみが含まれます。これは発行者側の変更であり、検証側のコードには影響しません。

OTPまたはマジックリンクのフローを引き続きデフォルトの経路として維持してください。トライアルに参加する場合は、トークンをプログレッシブエンハンスメントとして扱います。つまり、検証に成功した場合にのみメール送信を省略します。検証後は、通常どおりの方法でセッションを作成してください(Cookies vs localStorage for JWT Authを参照)。EVT+KBは一度限りの証明であり、セッショントークンとして使用すべきではありません。

このプロトコルは「受信トレイを確認してください」を、自社サーバーで実行する署名チェックに置き換えるものであり、検証処理はわずか5つのステップで済みます。hidden inputとnonceを追加し、既存の確認メールの手前にverifyEmailToken()をデプロイしたうえで、Chromeのリリースごとにアップデート投稿を確認して変更点を把握しておきましょう。

よくある質問

Email Verification ProtocolはSign in with Googleとどう違うのですか?

Sign in with Googleはプロバイダーごとのログイン連携であり、サイトとGoogleの間にアイデンティティ上の関係を構築します。Email Verification Protocolは、ユーザーがメールアドレスを管理していることを確認するだけです。サイトにプロバイダーのSDKを組み込む必要はなく、ブラウザがやり取りを仲介するため、発行者はどのサイトが要求したかを知ることなくトークンに署名します。

nonceの生成にcrypto.randomUUID()を使用できますか?

いいえ。バージョン4のUUIDは、バージョンフィールドとバリアントフィールドが残りの6ビットを占めるため、ランダムなビットは122ビットしかありません。IETFドラフトでは、少なくとも128ビットのエントロピーを持つnonceが求められています。代わりにnode:cryptoのrandomBytes(32).toString('base64url')を使用してください。これで256ビットが得られます。nonceはサーバーセッションに保存し、一度使用したら削除してください。

Email Verification Protocolは、独自ドメインのアドレスやGmail以外のプロバイダーでも動作しますか?

メールドメインが、プロトコルをサポートする発行者に委任する、iss=で始まる_email-verification TXTレコードをちょうど1つ公開している場合に限り動作します。Chromeのドキュメントで参加メールプロバイダーとして名前が挙げられているのはGmailです。このレコードを持たないその他のドメインでは、ブラウザはトークンを取得できないため、検証処理ではチェック失敗として扱い、通常の確認メールを送信する必要があります。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.