AWS CognitoでメールOTPログインを実装してハマった3つの落とし穴

こんにちは、ヨシダです。

あるプロジェクトのWebアプリで、メールアドレス宛にワンタイムパスワード(OTP)を送信し、そのコードでログインする仕組みを AWS Cognito で実装する機会がありました。

Cognito は、ID管理や認証基盤をすべて自前で作らなくてよい点が大きな魅力です。
一方で、実際の業務要件に合わせていくと、公式ドキュメントを読んでいるだけでは見えにくい挙動や、アプリケーション側で設計しておくべきポイントにもいくつかぶつかりました。

この記事では、実装を通じて「設計時点で知っておきたかった」と感じたポイントを、3つに絞って共有します。

なお、本記事は CUSTOM_AUTH(Lambda トリガーを使ったカスタム認証フロー)でメールOTPを実装したケース を前提にしています。
現在の Cognito には、パスワードレスサインインとして EMAIL_OTP を利用する選択肢もあります。新規実装の場合は、まず Cognito 標準のパスワードレス認証で要件を満たせるかを確認し、それでも細かい制御が必要な場合に CUSTOM_AUTH を検討するのがよさそうです。

前提:なぜ CUSTOM_AUTH なのか

Cognito の標準的なサインインには、ユーザー名・パスワード認証や SRP 認証があります。

一方で、今回の要件は「ユーザーにパスワードを持たせず、毎回メールに届くOTPだけでログインさせたい」というものでした。
さらに、OTPの送信タイミングや認証フローを業務要件に合わせて細かく制御する必要がありました。

そのため、Lambda トリガーを使った CUSTOM_AUTH で実装しました。

CUSTOM_AUTH では、主に以下の3つのLambdaトリガーを組み合わせて認証フローを構成します。

  • DefineAuthChallenge
    次にどのチャレンジを出すか、または認証成功・失敗とするかを判断する司令塔のような役割です。
  • CreateAuthChallenge
    OTPを生成し、メール送信などを行います。今回の構成では、SESなどを使ってユーザーにOTPを送信する処理をここで実装しました。
  • VerifyAuthChallengeResponse
    ユーザーが入力したOTPが正しいかどうかを判定します。

この構成自体はシンプルです。
ただし、実際に運用を見据えた要件を乗せていくと、いくつか注意すべきポイントが見えてきました。

落とし穴1:OTP再送のレート制御は「自前」で持つ必要がある

メールOTPログインでは、「OTPが届かないので再送したい」という要望が必ず出てきます。

ただ、CUSTOM_AUTH でOTP送信を自前実装する場合、再送間隔や再送回数の制御は、基本的にアプリケーション側で設計する必要があります。

何も考えずに再送ボタンを置くと、ユーザーがボタンを連打したり、APIを直接叩かれたりすることで、メールが大量送信される可能性があります。
その結果、SESの送信制限やコスト、メール到達率にも影響する可能性があります。

今回の実装では、以下のように二段構えで制御しました。

まず、フロントエンド側では、再送ボタンを押したあと一定時間はボタンを無効化します。
たとえば「60秒後に再送可能」と表示することで、ユーザーにも分かりやすい体験になります。

ただし、フロントエンドの制御だけでは不十分です。
ブラウザ上の制御は回避できるため、APIを直接呼ばれると意味がありません。

そのため、サーバー側でも「最後にOTPを送信した時刻」や「一定時間内の送信回数」を保持し、再送可否を判定するようにしました。

ここで大事なのは、フロントエンドの制御はユーザー体験のため、実際の制限はサーバー側で行う という考え方です。

落とし穴2:ログイン失敗時のブロック挙動は、要件に合わせて実測しておく

認証まわりでは、「OTPを何回間違えたらロックするのか」「どれくらい待てば再試行できるのか」といった要件が出てきます。

Cognito には、リクエストが多すぎる場合に TooManyRequestsException が返るなど、サービス側の保護挙動があります。

ただし、CUSTOM_AUTH でOTP認証を組む場合、業務要件として説明したいロックアウト挙動を Cognito 任せにしすぎると、要件定義やテスト時に説明が難しくなることがあります。

特に、以下のような点は事前に整理しておく必要がありました。

  • OTPを何回間違えたら一時停止するのか
  • 一時停止する場合、何分待たせるのか
  • 失敗回数はどこに保持するのか
  • 成功時に失敗回数をリセットするのか
  • ユーザー単位、IP単位、メールアドレス単位のどこで制御するのか

今回の学びとしては、Cognito 側の保護挙動に期待しすぎず、アプリケーションとして必要なロックアウト要件は、自分たちで明示的に設計する ほうが安全だと感じました。

また、セキュリティ観点で特に注意したいのが、ユーザー存在の有無でレスポンスを変えない ことです。

存在しないアカウントと、存在するがOTPを間違えたアカウントで、エラーメッセージやレスポンスの内容、応答時間に差が出ると、攻撃者がアカウントの存在を推測できる可能性があります。

Cognito にはユーザー存在エラーを抑制するための設定や考え方がありますが、CUSTOM_AUTH ではLambda側の実装も含めて、存在しないユーザーに対しても同じように見える応答を返す設計が重要です。

エラーメッセージは、たとえば以下のように共通化するのが無難です。

メールアドレスまたは認証コードが正しくありません。

「このメールアドレスは登録されていません」のような文言は、親切に見えますが、認証画面では情報を出しすぎる可能性があります。

落とし穴3:「特定条件だけOTPをスキップ」は司令塔Lambdaで設計するが、トークン発行のタイミングに注意する

業務システムでは、「社内の特定拠点からのアクセスでは毎回のOTP入力を省略したい」といった要望が出ることがあります。

たとえば、固定IPの社内ネットワークからアクセスしている場合はOTPをスキップし、それ以外の場所からはOTPを要求する、というようなケースです。

このような条件分岐は、DefineAuthChallenge を中心に設計します。
DefineAuthChallenge は、認証フローの中で次のチャレンジを出すか、認証成功とするかを判断する役割を持ちます。

ここで一点、設計時に意識しておきたいのが トークンを発行するタイミング です。

Cognito がトークンを発行するのは、基本的に「チャレンジの結果を評価したうえで認証成立と判断したとき」です。
チャレンジを一度も経ずに、初回呼び出しでいきなりトークンを発行する書き方は、公式に保証された挙動とは言い切れません。

そのため、「OTPをスキップしたい」場合でも、チャレンジの枠組み自体は通したうえで、信頼済みコンテキストではOTPメールを送らないダミーの CUSTOM_CHALLENGE を発行し、クライアントから所定の応答を返して通過させる、という設計のほうが安全です。

たとえば CreateAuthChallenge 側で「信頼済みアクセスである」と判定できる場合は、実際のOTPメールは送らず、VerifyAuthChallengeResponse 側で通過扱いにできるチャレンジを用意する、というイメージです。

DefineAuthChallenge 側は、おおむね次のような形になります。

// 簡略化した例です。
// 実運用では session.length ではなく、
// CUSTOM_CHALLENGE の失敗回数だけを数えるなど、
// 認証フローに合わせて判定してください。
const MAX_ATTEMPTS = 3;

exports.handler = async (event) => {
const session = event.request.session || [];

// 初回: まずチャレンジを提示する
// ここではトークンの即時発行はしない
if (session.length === 0) {
event.response.challengeName = "CUSTOM_CHALLENGE";
event.response.issueTokens = false;
event.response.failAuthentication = false;
return event;
}

const lastChallenge = session[session.length - 1];

// チャレンジが正しく通過していればトークンを発行する
if (
lastChallenge.challengeName === "CUSTOM_CHALLENGE" &&
lastChallenge.challengeResult === true
) {
event.response.issueTokens = true;
event.response.failAuthentication = false;
return event;
}

// 失敗が続いた場合はここで打ち切る
// ロックアウト要件はアプリケーション側で明示的に設計する
if (session.length >= MAX_ATTEMPTS) {
event.response.issueTokens = false;
event.response.failAuthentication = true;
return event;
}

// まだ通過していなければ、再度チャレンジを出す
event.response.challengeName = "CUSTOM_CHALLENGE";
event.response.issueTokens = false;
event.response.failAuthentication = false;
return event;
};

なお、上記は CUSTOM_CHALLENGE のみを前提にした簡略例です。
SRP や NEW_PASSWORD_REQUIRED など他のチャレンジを組み合わせる場合は、session.length ではなく、対象となるチャレンジの失敗回数だけを数えるようにしてください。

「信頼済みアクセスならOTPを省略する」処理は、CreateAuthChallengeVerifyAuthChallengeResponse 側と組み合わせて実装します。
信頼済みと判定できる場合は、実際のOTPメールは送らず、通過扱いにできるチャレンジを用意する、という考え方です。

※ コミュニティでは「初回呼び出しでそのままトークンを発行しても動いた」という報告も見られますが、公式ドキュメントで保証された使い方ではないため、本記事ではチャレンジを1度通す設計を推奨しています。

ここには、もう一つ大きな注意点があります。
スキップ条件を、クライアントが申告した値だけに依存させてはいけません。

ClientMetadata は、認証リクエスト時にアプリケーション側から Cognito に渡せるカスタム値です。
Lambdaトリガーの処理に情報を渡す用途では便利ですが、Cognito はこの値を検証・暗号化しません。つまり、クライアントが自由に詰められる値として扱うべきです。

また、InitiateAuth に載せた ClientMetadata が、そのまま DefineAuthChallengeCreateAuthChallenge で読めるとは限らない点にも注意が必要です。
ClientMetadata は、InitiateAuthRespondToAuthChallenge のどちらの呼び出しに載せたかによって、各Lambdaトリガーに届くかどうかが変わります。

そのため、「どの呼び出しに載せた値を、どのトリガーで参照するのか」は、実際に検証しておくことをおすすめします。

たとえば、以下のような設計は危険です。

// 危険な例:
// クライアント申告値だけでスキップを判定している
if (event.request.clientMetadata?.trustedAccess === "true") {
event.response.issueTokens = true;
}

この実装だと、攻撃者が trustedAccess=true を付けてリクエストできる経路がある場合、OTPをスキップできてしまいます。

IP制限や社内ネットワーク判定を使う場合は、WAF、ALB、API Gateway、CloudFront、VPN、社内プロキシなど、信頼できる層で担保したうえで、その結果をサーバー側で扱う必要があります。

ClientMetadata はあくまで補助情報として使い、信頼境界は別レイヤーで設計するのが安全です。

まとめ

AWS Cognito でメールOTPログインを実装して得た教訓は、主に次の3つです。

1. OTP再送のレート制御は自前で持つ

フロントエンドの再送ボタン制御は、あくまでユーザー体験をよくするためのものです。
実際の制限は、サーバー側で送信時刻や送信回数を管理して行う必要があります。

2. ロックアウト挙動は、業務要件として明示的に設計する

Cognito 側の保護挙動に任せきるのではなく、OTP失敗回数、待ち時間、リセット条件などは、アプリケーション側の要件として整理しておくのが安全です。
また、ユーザー列挙攻撃を防ぐため、エラーメッセージは共通化しておくことをおすすめします。

3. 条件付きスキップは DefineAuthChallenge で設計できるが、トークン発行のタイミングと判定材料の信頼性に注意する

特定条件でOTPをスキップする場合は、DefineAuthChallenge を中心に認証フローを設計できます。

ただし、チャレンジを経ずに初回からトークンを発行する書き方は公式に保証された挙動ではないため、チャレンジの枠組みは通したうえで通過させる設計が無難です。

また、ClientMetadata のようにクライアントから渡せる値だけを信頼してはいけません。
IP判定や信頼済みアクセスの判定は、ネットワーク構成やWAFなど、別の信頼できる層とセットで設計する必要があります。

Cognito は「マネージドだから楽」な一方で、業務要件に合わせるほど、どこまでを Cognito に任せ、どこからを自分たちで設計するかの線引きが重要になります。

逆に言えば、その線引きを早い段階で整理できれば、認証基盤をすべて自前で抱え込まずに済むメリットは大きいと感じました。

本記事の内容は、2026年3月の実装時点での経験をもとにしています。
Cognito は機能追加や仕様変更が行われるサービスのため、導入前には必ず最新の公式ドキュメントで確認してください。