ワンタイムコードのメールは、プロダクトが送るメッセージの中で最も「待ったなし」のものです。ユーザーは「お送りした6桁のコードを入力してください」と表示された画面を見つめていて、メールの到着が1秒遅れるごとに、諦めようかと迷う時間が1秒増えていきます。
認証メールは、セキュリティの境界線上にも位置しています。扱いを誤れば、正規のユーザーを締め出すか、攻撃者を招き入れることになります。このガイドでは、コードをすばやく受信トレイに届けることと、正しい本人だけが使えるようにすることの両面を解説します。
コードかリンクか
一般的な形式は2つあります。
- ワンタイムコード(OTP):
482913のような短い数字で、ユーザーがアプリに入力します。サインインや2段階認証に適しており、ユーザーが別のデバイスでメールを読んでいても使えます。 - 確認リンク:URLに長いランダムなトークンを含めたものです。ワンクリックで手続きが完了するため、新規登録時のメールアドレスの確認やパスワードのリセットに適しています。
以下の内容はどちらにも当てはまります。主な違いは長さで、リンクには長いトークンを含められますが、コードは入力できる程度に短くなければなりません。
コードを安全に生成する
使用する言語の暗号論的に安全な乱数生成器を使いましょう。Math.random()やPythonのrandomモジュールは予測可能なので、セキュリティ用のコードには絶対に使ってはいけません。
// Node.js
import { randomInt, createHmac } from 'node:crypto';
const code = String(randomInt(0, 1_000_000)).padStart(6, '0'); // "048213"
// Keyed hash: OTP_SECRET lives in your secrets manager, never in the database.
const codeHash = createHmac('sha256', process.env.OTP_SECRET).update(`${user.id}:${code}`).digest('hex');
# Python
import hashlib
import hmac
import os
import secrets
code = f"{secrets.randbelow(1_000_000):06d}"
# Keyed hash: OTP_SECRET lives in your secrets manager, never in the database.
code_hash = hmac.new(os.environ["OTP_SECRET"].encode(), f"{user_id}:{code}".encode(), hashlib.sha256).hexdigest()
保存するのは鍵付きハッシュ(HMAC)だけにし、ユーザーID、有効期限、試行回数カウンターと一緒に保存します。単純なSHA-256では不十分です。コードの候補は100万通りしかないため、データベースを入手した人なら1秒もかからずにすべて試せてしまいます。HMACにはデータベースの外で保管するシークレットが必要なので、データベースが漏えいしただけでは有効なコードは明らかになりません。
有効期限は短く、試行回数は制限する
6桁のコードには100万通りの組み合わせがあります。これで十分なのは、攻撃者が推測を続けられない場合に限られます。
| 対策 | 推奨値 |
|---|---|
| コードの有効期間 | サインインコードは10分。メールアドレス確認のリンクは最長24時間 |
| コードごとの誤入力回数 | 5回まで。超えたらコードを無効にする |
| アドレスごとのコード送信数 | 15分あたり約3回。「再送信」には待ち時間を表示する |
| IPアドレスごとのコード送信数 | より高い上限を設け、1人の攻撃者が多数のアドレスを試すのを防ぐ |
| 使用回数 | 1回限り。成功した時点でコードを削除するか、使用済みにする |
コードの比較は必ず一定時間で行い(Node.jsではcrypto.timingSafeEqual、Pythonではhmac.compare_digest)、新しいコードを発行したら以前のコードは無効にしましょう。
すぐに送信する
OTPのメールは、1分ごとに実行されるバッチジョブではなく、コードを要求したリクエストの中で送信しましょう。Inbixioなら呼び出しは1回だけです。
await fetch('https://backend.inbixio.com/api/send-email/', {
method: 'POST',
headers: { 'X-API-Key': process.env.INBIXIO_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
to_email: user.email,
subject: `${code} is your YourApp sign-in code`,
message: otpTemplate({ code, minutes: 10 }),
}),
signal: AbortSignal.timeout(10_000),
});
Inbixioはリクエストを受け付けるとすぐに配信をキューに追加するので、エンドポイントは素早くレスポンスを返せます。呼び出しが失敗した場合は、決して届かないメールをユーザーに待たせるのではなく、すぐに知らせましょう(「コードを送信できませんでした。しばらくしてからもう一度お試しください」)。自動でリトライする価値があるエラーは、メールAPIのエラーへの対処にまとめています。
コードならではの配信のコツが2つあります。
- 専用の送信元を使う:
security@yourco.comのようなアドレスを独立したサーバーとして接続し、そのサーバーに固定したAPIキーを使います(そのサーバーを削除した場合はキーを固定し直します)。ユーザーはそのアドレスを信頼するようになり、マーケティングキャンペーンがその評価に影響することもありません。 - ドメインを認証する:迷惑メールフォルダーに入ったコードは、届かなかったのと同じです。SPF、DKIM、DMARCのチェックリストを順に進めましょう。
フィッシングと間違われないメールを書く
あなたのテンプレートは、まさにこの形式をまねたフィッシングメールと競い合っています。本物だとすぐにわかるようにしましょう。
- 件名にコードを入れる:例:「482913 is your YourApp sign-in code」。ユーザーはメールを開かなくても通知でコードを読め、スマートフォンのキーボードが入力候補として表示することもできます。
- 用途と有効期間を伝える:「YourAppにサインインするには、このコードを使用してください。有効期限は10分です。」
- 心当たりがない場合の対応を伝える:「サインインしようとしていない場合は、このメールを無視してください。アカウントは安全です。」
- コードを聞き返さない:「YourAppが電話やメールでこのコードをお尋ねすることはありません」とはっきり書きます。
- 短く、宣伝は入れず、リンクは必要最小限にとどめます。
シンプルでアクセシブルなHTMLテンプレート:
<div style="font-family:system-ui,sans-serif;max-width:480px;margin:auto;color:#111">
<p>Use this code to sign in to YourApp:</p>
<p style="font-size:32px;font-weight:700;letter-spacing:6px;margin:24px 0">482913</p>
<p>It expires in 10 minutes. If you didn't try to sign in, you can ignore this email.</p>
<p style="color:#666;font-size:13px">YourApp will never ask you for this code.</p>
</div>
インラインスタイルは意図的なものです。多くのメールクライアントは<style>ブロックを削除してしまうため、インラインCSSがメールを装飾する最も確実な方法です。
アカウントの有無を漏らさない
サインイン画面や「コードを送信」画面では、アドレスが登録済みかどうかにかかわらず同じレスポンスを表示しましょう。「そのアドレスのアカウントが存在する場合は、コードを送信しました」。そうしないと、どのメールアドレスがあなたのプロダクトを使っているかを調べられる無料ツールになってしまいます。
チェックリスト
- 暗号論的に安全な生成器でコードを作り、保存するのはHMACだけで、シークレットはデータベースの外にある
- 有効期限10分、1回限り、試行5回まで、一定時間での比較
- アドレスごと・IPごとのレート制限と、再送信の待ち時間
- すぐに送信し、送信に失敗したらわかりやすいエラーを表示する
- 専用の、認証済みの送信元アドレス
- 件名にコード、本文に用途と有効期限、「心当たりがない場合」の案内
- アカウントの有無にかかわらず同じレスポンス