ブログ

【AWS】Amazon CognitoにおけるMFAの「メール通知」に潜む罠と対処法

この記事をSNSでシェア!

目次

  • MFA方式の比較
  • なぜ標準機能だけではメール通知に一本化できないのか
  • 標準ではできないなりの実装案
  • 注意すべき5つのポイント
  • AWS CDKによる実装例
  • まとめ

はじめに

ユーザー認証のセキュリティを高めるうえで、多要素認証・Multi-Factor Authentication(以下 MFA)は今や一般的な要件です。AWSで構築しているシステムでは、Amazon Cognito ( 以下 Cognito )  を使えば、SMS、メール、TOTP(Time-based One-Time Password) などの方式を使って、比較的短期間でMFAを導入できます。

導入の傾向として「ログイン時のMFAも、パスワードリセットも、同じメールアドレスで受けさせたい」というご要望をいただくことがあります。既にメールアドレスを中心に認証基盤を構築しているサービスでは自然な流れです。

ただし、Cognito はこの構成を標準機能では許容していません。これは単なる機能不足ではなく、MFA とパスワードリセットを同一の宛先に集約してしまうと、単一障害点になり得るためです。

本記事で紹介する実装は、Cognito が標準の設計として意図していない構成です。 MFAを導入しないよりははるかに安全ですが、可能であれば SMS、TOTP などを含めた、標準で用意されている認証機能を優先してください。

この制約を理解したうえで、「それでも要件上、ユーザー体験をメールに寄せたい」ケースに限った実装方法を紹介します。

ターゲット読者

  • Cognito を利用してMFAの認証基盤を構築するエンジニア

本記事で扱わないこと

  • Amazon Simple Email Service ( 以下 SES ) のドメイン認証や送信ドメインの詳細設定
    • 過去記事で紹介していますので気になる方はこちらからご確認ください
  • フロントエンド側のサインイン画面や MFA 画面の実装詳細

MFA方式の比較

まずは、Cognito で選択可能なMFA方式を整理します。

認証方式SMSメールTOTP
主なメリット即時性が高く、ユーザーが気付きやすいメール中心のUXと整合しやすいメッセージ配送に依存せず、安全性が高い
主な注意点電話番号が必要で、SMS料金や国別要件の影響を受ける標準ではパスワードリセットと同じメール宛先にできない認証アプリのセットアップが必要
ユーザー負担低〜中
Cognitoの必須ユーザー属性phone_numberemailなし
Cognitoでの利用条件Lite 以上Essentials または Plus、かつ SES の送信設定が必要Lite 以上
コスト(※1)約 $0.075 / 件$0.0001 / 件 ($0.10 / 1,000件)なし

(※1)公開時期 2026年5月現在、アジアパシフィック(東京)リージョンの料金(USD)。SMSは日本国内への送信、メールはSESを利用した場合の概算です。

メールでの通知は、メールアドレスだけで完結させやすく、ユーザー体験の一貫性を保ちやすい方式です。一方で、AWSの仕様上、MFAでメール通知を使う場合は、パスワードリセットで同じ方法を選択することはできません。

ただ、コスト、UX、属性管理のしやすさなどを総合すると、標準機能では実現できませんが「できればメールに寄せたい」と考えるのは十分にありえる要件です。

なぜ標準機能だけではメール通知に一本化できないのか

Cognito では、MFA とパスワードリセットを同じ通知方法で送ることができません。AWS公式ドキュメントでも、次のように明記されています。(少々わかりやすく調整しています。)

  • メール通知を使うユーザーは、パスワードリセットコードをメールでは受け取れない
  • SMSを使うユーザーは、パスワードリセットコードをSMSでは受け取れない
  • ユーザーがメールアドレスと電話番号の両方を持っている場合、Cognito は MFA に使っていない側へリセットコードを送る
  • MFAとは別のリセット手段がない場合、パスワードリセットはエラーを返す

この制約の背景にあるのは、単一障害点を避けるための設計です。

MFA の本来の目的は、パスワードとは異なる経路や手段でもう1段階確認することです。もし、MFAとパスワードリセットの両方が同じメールボックスに届くなら、そのメールアカウントが不正アクセスされた時点で、MFAの効果が薄れます。

つまり、Cognito が標準機能でこの共通化を許していないのは、単に「できない」のではなく、「させない」ためのガードレールとしているためです。

標準ではできないなりの実装案

ここで紹介する回避策は、Custom SMS Sender Lambda を利用して、メールの送信元をSESに寄せる、という方法です。

この構成の流れ

  1. ログイン時のMFAは、Cognito 標準のメールMFAを利用する
  2. パスワードリセットは、Cognito 上は SMS を正規のパスワードリセット手段として設定する
  3. パスワードリセット 時に発生する SMS イベントを Custom SMS Sender Lambda で受ける
  4. Lambda 内でそのコードを SES を使ってメール送信する
  5. 結果として、ユーザーはMFAもパスワードリセットも同じメールで受け取る

フロー図

注意すべき5つのポイント

本記事の本題である、MFAとパスワードリセットの通知方法を同じメール通知とする点については上記までの内容で実現可能です。ここからは実装を進める上で、CognitoでメールによるMFAを実現するために陥りがちなポイントを補足として紹介します。

① メールMFAには Essentials または Plus が必要

ここは誤解しやすいポイントです。

Cognito で SMS MFA と TOTP MFA を使うだけなら Lite でも利用できます。一方で、メールMFAを利用するには Essentials または Plus が必要です。さらに、メールMFAを有効にするには、Cognito のメール送信を自前の SES リソースで設定する必要があります。

そのため、「MFAだから Essentials が必要」ではなく、「メールMFAを採用するから Essentials 以上が必要」となります。

② 電話番号に関する多数の注意点

メールで送信するという方法を紹介しているにも関わらずなぜ電話番号の注意点がでてくるのか?と思われたかもしれません。本構成では、ユーザーへの通知はメールに寄せますが、Cognito のパスワードリセットの仕組みとしては SMS を経由する構成になっています。Cognito がパスワードリセットコードを SMS として発行するため、その宛先となる電話番号の登録が必要です。Lambda がその SMS イベントを受け取り、実際の配送先をメールに差し替えています。

・登録が必須

本構成では、電話番号は Cognito がパスワードリセットの SMS イベントを発行する際の送信先として使います。実際には Lambda がそのイベントを受け取りメールに差し替えるため、実在する電話番号である必要はなく、ダミーの番号で問題なく動作します。
むしろ個人情報管理の観点では、実在しない番号を使う方が望ましいです。総務省の電気通信番号計画では、音声伝送携帯電話番号(090 / 080 / 070)は 090CDEFGHJK(C は 0 を除く)と定められており、090-0XXX-XXXX のレンジはどの事業者にも指定されていない番号となるため、ダミーとして利用するには適切な範囲といえます。ただし、電気通信番号計画は改定されることがあるため、実装時は総務省の最新の指定状況を確認することをおすすめします。

・確認済みへ変更が必要

Cognito がパスワードリセットの送信先として使えるのは、確認済みの電話番号(verified_phone_number)のみです。ここでいう「確認済み」とは、Cognito のユーザー属性において verified フラグが true になっている状態を指します。電話番号を登録するだけでは verified フラグは立たないため、明示的に確認済み状態へ更新するフローも合わせて設計する必要があります。

・登録の形式

電話番号の登録は E.164 形式の指定があります。例えば日本の携帯番号なら、+819000000000 のような形式です。ダミーの番号を使用する場合も E.164 形式に準拠する必要があります。「登録が必須」で紹介したレンジを使う場合の例としては +819001234567(= 090-0123-4567)のような形式になります。

・必須属性の変更不可

Cognito の必須属性はユーザープール作成後に変更ができません。TOTPを利用しない本記事のようなケースでは電話番号の登録が必須となるため、「電話番号を必須属性に変えよう」と考えるかもしれませんが、必須属性は更新が行えないため、Cognitoによる登録の担保は行えません。

アプリケーション側のバリデーションや、管理画面での補完などの検討が不可欠です。

③ Custom SMS Sender は「パスワードリセットだけのトリガー」ではない

Custom SMS Sender を設定すると、Cognito はそのユーザープール内のすべてのSMS通知をそのLambdaに渡します。パスワードリセットだけを選んで差し替える専用設定ではありません。

本記事の構成では、実運用上他のSMSイベントを極力発生させない前提を置くのが現実的です。もし アカウント登録、管理者作成などでSMS通知が発生するのであれば、それらの動作も考慮してLambda 側で明示的に扱わなければなりません。

④ ワンタイムパスワードの復号が必要

Custom Sender を使う場合、Cognito はワンタイムパスワードを平文で Lambda に渡しません。KMSキーで暗号化し、Lambda 側で復号します。

このとき、少なくとも次の前提が必要です。

  • カスタマー管理KMSキーを用意する
  • ユーザープールを作成または更新する
  • IAM プリンシパル に kms:CreateGrant を付与する
  • Lambda 実行ロールに kms:Decrypt を付与する
  • cognito-idp.amazonaws.com に Lambda の Invoke 権限を付与する

⑤ AWSコンソールからの設定が不可

Custom SMS Sender と Custom Email Sender の設定は Cognito コンソールでは行えず、API、CLI、SDK、または CDK から設定します。ここも実装前に見落としやすい点です。

AWS CDKによる実装例

以下は、構成の要点だけを示した TypeScript の例です。

import * as cdk from 'aws-cdk-lib';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as kms from 'aws-cdk-lib/aws-kms';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';

export class AuthStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const senderKey = new kms.Key(this, 'CustomSenderKey', {
      enableKeyRotation: true,
    });

    const customSmsSender = new lambda.Function(this, 'CustomSmsSenderFn', {
      runtime: lambda.Runtime.NODEJS_20_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset('lambda/custom-sms-sender'),
      environment: {
        KEY_ID: senderKey.keyId,
        KEY_ARN: senderKey.keyArn,
        FROM_ADDRESS: 'no-reply@example.com',
      },
    });

    senderKey.grantDecrypt(customSmsSender);

    const userPool = new cognito.CfnUserPool(this, 'UserPool', {
      userPoolName: 'mail-mfa-pool',
      userPoolTier: 'ESSENTIALS',
      usernameAttributes: ['email'],
      autoVerifiedAttributes: ['email'],
      mfaConfiguration: 'ON',
      enabledMfas: ['EMAIL_OTP'],
      emailConfiguration: {
        emailSendingAccount: 'DEVELOPER',
        sourceArn: 'arn:aws:ses:ap-northeast-1:123456789012:identity/example.com',
        from: 'no-reply@example.com',
      },
      accountRecoverySetting: {
        recoveryMechanisms: [
          {
            name: 'verified_phone_number',
            priority: 1,
          },
        ],
      },
      schema: [
        {
          name: 'email',
          attributeDataType: 'String',
          required: true,
          mutable: true,
        },
        {
          name: 'phone_number',
          attributeDataType: 'String',
          required: true,
          mutable: true,
        },
      ],
      lambdaConfig: {
        kmsKeyId: senderKey.keyArn,
        customSmsSender: {
          lambdaArn: customSmsSender.functionArn,
          lambdaVersion: 'V1_0',
        },
      },
    });

    customSmsSender.addPermission('AllowCognitoInvoke', {
      principal: new iam.ServicePrincipal('cognito-idp.amazonaws.com'),
      sourceArn: userPool.attrArn,
      action: 'lambda:InvokeFunction',
    });
  }
}

Lambda 側の分岐イメージ

前提

  • Lambda に aws-encryption-sdk を同梱すること 
  • 環境変数 KMS_KEY_ARN と FROM_EMAIL_ADDRESS を設定すること
import base64
import os

import aws_encryption_sdk
import boto3
from aws_encryption_sdk import CommitmentPolicy

ses_client = boto3.client("ses")

KMS_KEY_ARN = os.environ["KMS_KEY_ARN"]
FROM_EMAIL_ADDRESS = os.environ["FROM_EMAIL_ADDRESS"]


def decrypt_code(encrypted_code: str) -> str:
    ciphertext = base64.b64decode(encrypted_code)

    client = aws_encryption_sdk.EncryptionSDKClient(
        commitment_policy=CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT
    )

    kms_key_provider = aws_encryption_sdk.StrictAwsKmsMasterKeyProvider(
        key_ids=[KMS_KEY_ARN]
    )

    plaintext, _header = client.decrypt(
        source=ciphertext,
        key_provider=kms_key_provider,
    )

    return plaintext.decode("utf-8")


def send_reset_email(email: str, code: str) -> None:
    subject = "パスワード再設定コード"
    body_text = f"""パスワード再設定コードは以下の通りです。

{code}

画面にコードを入力して、パスワード再設定を完了してください。
"""

    ses_client.send_email(
        Source=FROM_EMAIL_ADDRESS,
        Destination={"ToAddresses": [email]},
        Message={
            "Subject": {
                "Data": subject,
                "Charset": "UTF-8",
            },
            "Body": {
                "Text": {
                    "Data": body_text,
                    "Charset": "UTF-8",
                }
            },
        },
    )


def handler(event, context):
    if event.get("triggerSource") != "CustomSMSSender_ForgotPassword":
        return event

    encrypted_code = event["request"].get("code")
    user_attributes = event["request"].get("userAttributes", {})
    email = user_attributes.get("email")

    if not encrypted_code:
        raise ValueError("request.code is required")

    if not email:
        raise ValueError("userAttributes.email is required")

    code = decrypt_code(encrypted_code)
    send_reset_email(email, code)

    return event

実装時の補足

このコードはサンプルです。実運用では、次の項目などを別途追加または設計する必要があります。

  • 電話番号の確認 を成立させる運用
  • SES 送信のリトライ、監視、失敗時のハンドリング
  • 想定外の triggerSource が来た場合の明確な運用方針

まとめ

Cognito では、メールMFAとパスワードリセットを同じメールアドレスへ送る構成を、標準機能だけで実現することはできません。これは、MFA とパスワードリセットを同一宛先に集約して単一障害点を作らないための設計です。

それでも通知をメールに寄せたい場合は、Cognito の構成は「メール MFA + SMSパスワードリセット」のまま維持し、パスワードリセット によって発生する SMS送信だけを Custom SMS Sender Lambda でメール送信に差し替えるという実装案が現実的です。

個人的にはユーザー体験や、セキュリティ担保、コストなどのバランスを総合すると自然な選択肢だと感じますが、Cognito の標準設計の範囲外の実装です。可能であれば、SMS、TOTPなども含めて、標準で用意されている認証を優先してください。そのうえで、メール通知に統一するケースに限り、今回紹介した標準機能ではない実装となっている点と注意点を正しく理解した上で、導入をご検討いただければと思います。

参考にしたAWS公式ドキュメント

投稿者プロフィール

岡田 優輝
岡田 優輝
2015年入社

AWSを利用したシステム開発を、フロントエンド/バックエンド問わず担当しています。
開発後の運用負荷が少ない設計や、アーキテクチャでのシステム構築を心がけています。
この記事をSNSでシェア!