
ユーザー移行 Lambda トリガーで別アカウントの Cognito ユーザープールへパスワードごとユーザーを移行してみた
製造ビジネステクノロジー部の小林です。
最近、Amazon Cognito のユーザープールを別アカウントに移す場面がありました。CSV のインポート機能ではパスワードが引き継がれず、全ユーザーにパスワードの再設定をお願いすることになります。
そこで、パスワードをそのまま引き継げるユーザー移行 Lambda トリガーを使い、アカウント A のユーザープールからアカウント B へユーザーを移行してみました。
移行元のユーザープールは、MFA(E メールのワンタイムコード)を必須にしています。
MFA 必須のプールでもパスワードを引き継げるのか、あわせて確認します。
全体の流れ
ユーザーから見ると、移行先のアプリケーションに今までと同じユーザー名とパスワードでサインインするだけです。裏側では次のことが起きています。
- 移行先にユーザーが存在しないため、Cognito が移行 Lambda を呼び出す
- Lambda が移行元アカウントのロールを引き受け、移行元で同じ認証情報を検証する。移行元は MFA 必須なので、パスワードが正しければトークンではなく EMAIL_OTP チャレンジが返る
- チャレンジが返った時点でパスワードは正しいと分かるので、移行元のユーザー属性を取得して返す
- 移行先も MFA 必須なので、移行先からワンタイムコードが送られる。ユーザーがコードを入力すると、移行先にユーザーが作成され、入力されたパスワードがそのまま保存される
https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/user-pool-lambda-migrate-user.html |
検証環境
| 項目 | 内容 |
|---|---|
| リージョン | 東京(ap-northeast-1)。両アカウントとも同じ |
| アカウント A(移行元) | ユーザープール、移行用アプリクライアント、クロスアカウント用 IAM ロール |
| アカウント B(移行先) | ユーザープール、アプリクライアント、移行 Lambda |
| サインイン識別子と必須属性 | ユーザー名、email(両プールで揃える) |
| MFA | 必須。E メールのワンタイムコード(両プールで同じ) |
| メール送信 | Amazon SES。サンドボックスのまま、検証済みのメールアドレスだけに送る |
| Lambda ランタイム | Python 3.13 |
クロスアカウント構成では「B の Lambda 実行ロールの ARN を A のロールの信頼ポリシーに書く」「A のロールの ARN を B の Lambda の環境変数に書く」という相互参照があるため、次の順番で進めます。
- アカウント A: SES の検証済み ID、移行元ユーザープール、テストユーザー、移行用アプリクライアント
- アカウント B: SES の検証済み ID、移行先ユーザープールとアプリクライアント
- アカウント B: Lambda 関数(実行ロールの ARN を控える)
- アカウント A: Lambda 実行ロールを信頼するクロスアカウント用ロール(ロールの ARN を控える)
- アカウント B: Lambda 実行ロールに sts:AssumeRole を許可し、環境変数とトリガーを設定
- 動作確認
1. 移行元(アカウント A)を用意する
SES の検証済み ID
E メール MFA を使うには、ユーザープールのメール送信を自分の SES リソースにしておく必要があります。
SES コンソールの [ID] から [ID の作成] を選び、ID タイプに E メールアドレスを選んで送信元のアドレスを登録し、届いた確認メールのリンクを開いて検証済みにします。


SES はサンドボックスのままなので、検証済みのアドレス宛てにしか送れません。今回はこのアドレスをテストユーザーのメールアドレスにも使います。
ユーザープール
移行元のプールは検証用に作ります。ウィザードでアプリケーションタイプに SPA を選び、サインイン識別子をユーザー名、必須属性を email にします。



作成後、プール名を migration-source-pool に変え、メール送信と MFA を設定します。

| 設定 | 場所 | 値 |
|---|---|---|
| E メール | [認証] の [認証方法] | Amazon SES で送信。送信元に検証済み ID、送信者の名前は Source <検証済みのアドレス> |
| MFA | [認証] の [サインイン] | MFA を必須にする。方法は E メールメッセージ |
| MFA メッセージの件名 | [ブランディング] の [メッセージテンプレート] | [移行元] 認証コード |


テストユーザー
[ユーザー] からテストユーザーを 3 名作成します。メールアドレスは SES で検証済みのものを使い、確認済みにしておきます。

コンソールから作成したユーザーは FORCE_CHANGE_PASSWORD 状態なので、CLI でパスワードを恒久的に設定して CONFIRMED にします。
# 移行元ユーザープールの ID([概要] の「ユーザープール ID」)
SRC_POOL_ID=ap-northeast-1_xxxxxxxxx
for u in test-user-1 test-user-2 test-user-3; do
aws cognito-idp admin-set-user-password \
--user-pool-id "$SRC_POOL_ID" \
--username "$u" \
--password 'Passw0rd!' \
--permanent \
--profile src
done
aws cognito-idp list-users \
--user-pool-id "$SRC_POOL_ID" \
--query 'Users[].[Username,UserStatus]' \
--output table \
--profile src
------------------------------
| ListUsers |
+--------------+-------------+
| test-user-3 | CONFIRMED |
| test-user-1 | CONFIRMED |
| test-user-2 | CONFIRMED |
+--------------+-------------+

移行用のアプリクライアント
Lambda が移行元で認証するための、移行専用のアプリクライアントを作ります。[アプリケーション] の [アプリケーションクライアント] から、アプリケーションタイプを SPA、名前を migration-source-client にして作成します。

SPA を選ぶのはクライアントシークレットなしで作るためです。シークレットは作成後に外せず、付いていると Lambda 側で SECRET_HASH の計算が必要になります。
作成直後の認証フローには管理者 API によるパスワード認証が含まれていません。
「アプリケーションクライアントに関する情報」の [編集] を開き、「サーバー側の管理者認証情報 (ALLOW_ADMIN_USER_PASSWORD_AUTH) を使用してサインインします」にチェックを入れて保存し、クライアント ID を控えます。
ウィザードが作ったクライアント(今回は source-app)とは別物なので、取り違えないようにします。

MFA 必須のプールで AdminInitiateAuth が何を返すか確認する
Lambda を書く前に、移行元で AdminInitiateAuth を直接呼び出しておきます。
# 移行用アプリクライアント migration-source-client のクライアント ID
SRC_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxx
aws cognito-idp admin-initiate-auth \
--user-pool-id "$SRC_POOL_ID" \
--client-id "$SRC_CLIENT_ID" \
--auth-flow ADMIN_USER_PASSWORD_AUTH \
--auth-parameters USERNAME=test-user-1,PASSWORD='Passw0rd!' \
--query '{Challenge:ChallengeName,Params:ChallengeParameters}' \
--profile src
{
"Challenge": "EMAIL_OTP",
"Params": {
"CODE_DELIVERY_DELIVERY_MEDIUM": "EMAIL",
"CODE_DELIVERY_DESTINATION": "k***@c***",
"USER_ID_FOR_SRP": "test-user-1"
}
}

MFA 必須のプールでは、パスワードが正しくてもトークンは返らず、EMAIL_OTP チャレンジが返ります(このときコードのメールも送られます)。パスワードが違うときは次のエラーになります。
An error occurred (NotAuthorizedException) when calling the AdminInitiateAuth operation: Incorrect username or password.
つまり MFA のチャレンジが返った時点でパスワードの検証は済んでいます。移行 Lambda は「トークンが返った」または「MFA のチャレンジが返った」をパスワード一致とみなします。
2. 移行先(アカウント B)のユーザープールを作成する
アカウント B でも SES の検証済み ID を作ります(SES の ID はアカウントをまたいで使えません)。ユーザープールは、サインイン識別子と必須属性を移行元と同じにして作成します。

作成後、プール名を migration-dest-pool にし、移行元と同じように E メール設定を SES に切り替え、MFA を必須の E メールメッセージにします。



アプリクライアントはウィザードが作った migration-dest-client を使います。「アプリケーションクライアントに関する情報」の [編集] で「ユーザー名とパスワード (ALLOW_USER_PASSWORD_AUTH) を使用してサインインします」にチェックを追加します。
SRP ではパスワードがサーバーに届かずトリガーが呼び出されないため、移行期間中はこのフローが必要です。

3. 移行 Lambda を作成する(アカウント B)
関数の作成
Lambda のコンソールで、関数名 cognito-user-migration、ランタイム Python 3.13、実行ロールは新規作成で関数を作ります。

作成後、[設定] の [一般設定] でタイムアウトを 5 秒にします。Cognito が待つのは 5 秒までです。同じ画面の実行ロールの ARN を控えておきます。

関数コード
コードは、開発者ガイドの例と、AWS re:Post の記事「How to setup cross-account Cognito User Pool migration with the Migrate User Lambda Trigger」の Python テンプレートを元に、MFA 必須のプール向けに手を加えたものです。
lambda_function.py
import logging
import os
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
logger = logging.getLogger()
logger.setLevel(logging.INFO)
# 移行元(アカウント A)の情報。環境変数で渡す
SRC_ROLE_ARN = os.environ["SRC_ROLE_ARN"]
SRC_POOL_ID = os.environ["SRC_POOL_ID"]
SRC_CLIENT_ID = os.environ["SRC_CLIENT_ID"]
SRC_REGION = os.environ.get("SRC_REGION", "ap-northeast-1")
# Cognito は Lambda の応答を 5 秒までしか待たない。
# 既定値(接続・読み取りとも 60 秒、リトライあり)のままだと、
# 移行元の API が遅延したときに 5 秒を超えて待ち続けるため、1 回の通信を短く切る
API_CONFIG = Config(
connect_timeout=1,
read_timeout=3,
retries={"total_max_attempts": 1},
)
# 上の設定は 1 回の通信ごとの上限なので、API を順に呼ぶと合計で 5 秒を超え得る。
# 各 API を呼ぶ前に残り時間を確認し、足りなければ呼ばずに終了する(ミリ秒)
TIME_BUDGET_MS = {
"admin_initiate_auth": 2000,
"admin_get_user": 700,
}
# 「認証情報が正しくない」として扱う AdminInitiateAuth のエラー。
# これ以外(スロットリング、メール送信の失敗、設定の誤りなど)は障害として扱う
AUTH_FAILURE_CODES = {
"NotAuthorizedException",
"UserNotFoundException",
"UserNotConfirmedException",
"PasswordResetRequiredException",
}
# 移行先にコピーしない属性。sub は移行先で新しく採番される
EXCLUDED_ATTRIBUTES = {"sub", "identities"}
# パスワード検証のあとに返る MFA 系のチャレンジ。これが返ればパスワードは正しい
MFA_CHALLENGES = {
"EMAIL_OTP",
"SMS_MFA",
"SOFTWARE_TOKEN_MFA",
"SELECT_MFA_TYPE",
"MFA_SETUP",
}
sts = boto3.client("sts", config=API_CONFIG)
class MigrationError(Exception):
"""利用者に見せてよい文言だけを持つ例外。メッセージはクライアントに返る"""
def source_cognito_client():
"""移行元アカウントのロールを引き受け、その認証情報で Cognito クライアントを作る"""
credentials = sts.assume_role(
RoleArn=SRC_ROLE_ARN,
RoleSessionName="cognito-user-migration",
)["Credentials"]
return boto3.client(
"cognito-idp",
region_name=SRC_REGION,
config=API_CONFIG,
aws_access_key_id=credentials["AccessKeyId"],
aws_secret_access_key=credentials["SecretAccessKey"],
aws_session_token=credentials["SessionToken"],
)
def to_user_attributes(attributes):
"""AdminGetUser の属性リストを、レスポンス用の辞書に変換する"""
return {
a["Name"]: a["Value"]
for a in attributes
if a["Name"] not in EXCLUDED_ATTRIBUTES
}
def ensure_time_left(context, api):
"""次の API を呼ぶ時間が残っていなければ、Lambda のタイムアウトを待たずに終了する"""
remaining = context.get_remaining_time_in_millis()
if remaining < TIME_BUDGET_MS[api]:
logger.warning("not enough time left for %s: %d ms", api, remaining)
raise MigrationError("Migration failed")
def migrate(event, context, trigger_source, username):
if trigger_source == "UserMigration_Authentication":
idp = source_cognito_client()
# 移行元で同じユーザー名・パスワードで認証できるか確認する
ensure_time_left(context, "admin_initiate_auth")
try:
result = idp.admin_initiate_auth(
UserPoolId=SRC_POOL_ID,
ClientId=SRC_CLIENT_ID,
AuthFlow="ADMIN_USER_PASSWORD_AUTH",
AuthParameters={
"USERNAME": username,
"PASSWORD": event["request"]["password"],
},
)
except ClientError as e:
code = e.response["Error"]["Code"]
if code not in AUTH_FAILURE_CODES:
raise # 障害として外側で記録する
logger.info("source auth failed: %s", code)
raise MigrationError("Bad credentials")
challenge = result.get("ChallengeName")
if "AuthenticationResult" not in result and challenge not in MFA_CHALLENGES:
# NEW_PASSWORD_REQUIRED(仮パスワードのまま)などは移行しない
logger.info("source auth returned challenge: %s", challenge)
raise MigrationError("Bad credentials")
logger.info("source auth ok challenge=%s", challenge)
ensure_time_left(context, "admin_get_user")
user = idp.admin_get_user(UserPoolId=SRC_POOL_ID, Username=username)
event["response"]["userAttributes"] = to_user_attributes(user["UserAttributes"])
event["response"]["finalUserStatus"] = "CONFIRMED"
event["response"]["messageAction"] = "SUPPRESS"
return event
if trigger_source == "UserMigration_ForgotPassword":
# パスワードが渡されない経路。E メール MFA のプールではリセットコードを
# E メールに送れないため、パスワードを持たないユーザーを作らないように拒否する
logger.info("forgot-password migration is not supported in this pool")
raise MigrationError("Password reset is not available. Please contact support.")
raise MigrationError(f"Bad triggerSource {trigger_source}")
def lambda_handler(event, context):
trigger_source = event["triggerSource"]
username = event["userName"]
# event 全体を出力しない。request.password にパスワードが入っている
logger.info("triggerSource=%s userName=%s", trigger_source, username)
try:
return migrate(event, context, trigger_source, username)
except MigrationError:
raise
except Exception:
# AssumeRole の失敗やスロットリングなど。詳細はログにだけ残し、
# ロール ARN などを含む生のメッセージをクライアントに返さない
logger.exception("migration failed unexpectedly")
raise MigrationError("Migration failed")
ポイントは次のとおりです。
- 移行元で AdminInitiateAuth(ADMIN_USER_PASSWORD_AUTH)を呼び、トークンか MFA 系チャレンジが返った場合だけ移行します。公式の例は AuthenticationResult 前提のため、MFA 必須の移行元ではそのままでは動きません
- この判定で確認できるのはパスワードの一致までです。移行先でも同じメールアドレス宛ての MFA を行う今回の構成なら問題ありませんが、移行元が TOTP で認証アプリを登録し直す構成では MFA のリセットに相当するため、別の確認が必要です
- NEW_PASSWORD_REQUIRED など MFA 以外のチャレンジでは移行しません
コードを貼り付けたら [Deploy] します。環境変数とトリガーはアカウント A のロールを作ってから設定します。

4. クロスアカウント用 IAM ロールを作成する(アカウント A)
アカウント A の IAM で、B の Lambda 実行ロールだけが引き受けられるロールを作ります。[ロールを作成] で信頼されたエンティティタイプに [カスタム信頼ポリシー] を選びます。

次の JSON を貼り付けます。Principal は手順 3 で控えた Lambda 実行ロールの ARN です。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::222222222222:role/service-role/cognito-user-migration-role-xxxxxxxx"
},
"Action": "sts:AssumeRole"
}
]
}
ステップ 2 の「許可を追加」で [インラインポリシーを作成] を選び、移行元ユーザープールに対する AdminInitiateAuth と AdminGetUser だけを許可します。Resource は移行元ユーザープールの [概要] にある ARN です。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"cognito-idp:AdminInitiateAuth",
"cognito-idp:AdminGetUser"
],
"Resource": "arn:aws:cognito-idp:ap-northeast-1:111111111111:userpool/ap-northeast-1_xxxxxxxxx"
}
]
}
ステップ 3 でロール名を cognito-migration-source-role にして作成し、ロールの ARN を控えます。
5. Lambda にロール引き受けの許可、環境変数、トリガーを設定する(アカウント B)
Lambda の [設定] の [アクセス権限] から実行ロールを開き、インラインポリシーで手順 4 のロールへの sts:AssumeRole を許可します。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::111111111111:role/cognito-migration-source-role"
}
]
}
[設定] の [環境変数] に次の 4 つを設定します。
| キー | 値 |
|---|---|
| SRC_ROLE_ARN | 手順 4 で作成したロールの ARN |
| SRC_POOL_ID | 移行元ユーザープールの ID |
| SRC_CLIENT_ID | 移行用アプリクライアントの ID |
| SRC_REGION | ap-northeast-1 |

移行先ユーザープールの [認証] の [拡張機能] から [Lambda トリガーを追加] を選びます。トリガータイプは [サインアップ]、その下の [ユーザーを移行 トリガー] を選び、cognito-user-migration を割り当てます。
コンソールから割り当てると、Cognito が関数を呼び出すためのリソースベースポリシーが自動で追加されます。


6. 動作確認
実際のユーザーと同じように、マネージドログインの画面からサインインします。画面から見えない部分だけ CLI で確認します。
マネージドログインの準備
migration-dest-client の [ログインページ] タブを開きます。

移行元のパスワードでサインインする
移行先にユーザーがいない状態で [ログインページを表示] を選び、移行元と同じ test-user-1 とパスワードでサインインします。


移行先に存在しないユーザーなのに「ユーザー名またはパスワードが違う」とはならず、ワンタイムコードの入力画面に進みます。この裏で Lambda が動き、移行元の認証が通っています。

メールボックスには移行元と移行先から 2 通届きます。移行元のものは Lambda の AdminInitiateAuth で送られたもので、入力するのは移行先のコードです。

コードを入力するとサンプルページにリダイレクトされ、サインインが完了します。

移行先に作成されたユーザーを確認する
移行先の [ユーザー] に test-user-1 が「確認済み」で表示されます。

E メール MFA はデバイス登録を持たず email 属性で成り立つため、移行先でも初回からコードが届きました。
Lambda のログにはトリガーの種類、ユーザー名、チャレンジ名だけが出ていて、パスワードは出ていません。
[INFO] triggerSource=UserMigration_Authentication userName=test-user-1
[INFO] source auth ok challenge=EMAIL_OTP
REPORT RequestId: 109907ca-... Duration: 3181.85 ms Billed Duration: 3513 ms Memory Size: 128 MB Max Memory Used: 91 MB Init Duration: 330.51 ms
2 回目のサインインでは Lambda が呼ばれない
同じユーザーでもう一度サインインすると、移行先に保存されたパスワードで認証されます。届くメールは移行先からの 1 通だけで、Lambda のログに新しい行は増えません。
間違ったパスワードではユーザーが作られない
未移行の test-user-2 に間違ったパスワードでサインインすると、エラーになります。

Lambda のログでは移行元の認証に失敗し、Bad credentials の例外を投げています。
[INFO] triggerSource=UserMigration_Authentication userName=test-user-2
[INFO] source auth failed: NotAuthorizedException
[ERROR] MigrationError: Bad credentials
パスワードを忘れたユーザーの移行は、E メール MFA のプールでは成立しない
このトリガーには「パスワードをお忘れですか」から移行する経路もありますが、記事のコードではこの分岐で例外を投げています。
この状態で test-user-3 のリセットを試すと、エラーではなく「SMS でリセットコードを送りました」と表示されました。移行先には SMS の設定がなくユーザーも存在しないため、実際には何も送られていません。

これは「ユーザー存在エラーの防止」の動作です。存在しないユーザーへの ForgotPassword には、ユーザーの有無を悟られないよう、コードを送ったかのような応答を返します。
Lambda が例外を投げた場合も同様で、コードを入力しても先へは進みません。
利用者から見ると「コードが届かない」状態になるため、移行期間中はパスワードを忘れた場合の問い合わせ先を案内しておきます。対応は移行元のアプリの状態で決まります。
移行元のアプリが稼働中の場合
移行元で仮パスワードを発行し、本人に移行元で変更してもらってから移行します。
- 管理者が移行元のユーザープールで AdminSetUserPassword を実行し、仮パスワードを設定します。--permanent を付けないと、次回サインイン時に変更が必須の仮パスワードになります
- 本人が移行元のアプリに仮パスワードでサインインし、新しいパスワードを設定します
- 本人が新しいパスワードで移行先にサインインすると、通常どおり移行されます
移行元でも E メール MFA のためにパスワードの再設定を本人だけでは完了できないので、管理者による仮パスワードの発行が起点になります。
移行元のアプリを停止済みの場合
移行先に直接ユーザーを作ります。
- 管理者が移行先のユーザープールで AdminCreateUser を実行し、仮パスワード付きでユーザーを作成します。属性は移行元から転記します
- 本人が仮パスワードで移行先にサインインし、新しいパスワードの設定と E メール MFA を経て完了します
なお、ユーザーに phone_number を持たせて SMS でコードを受け取る構成なら、この経路はそのまま使えます。
最終的に、3 名とも移行先に揃いました。

移行時に押さえておきたいポイント
サインインしないユーザーは移行されないため、終わらせ方を決めておく
この方式はサインイン時に 1 人ずつ移す「遅延移行」なので、一度もサインインしないユーザーは移行されません。先に CSV インポートでユーザーを作ると、トリガーが呼ばれずパスワードを引き継げなくなります。
一方で移行 Lambda を残し続けると、アプリクライアントを USER_PASSWORD_AUTH のままにする必要があり、移行元のプールやクロスアカウントのロールも残ります。
移行先にいないユーザー名での試行はすべて移行元に転送されるため、総当たりの入口にもなります。次のように終わらせ方まで計画しておきます。
- サービスの利用間隔に合わせて移行期間を決める
- 移行元の list-users と移行先を突き合わせて未移行の人数を追う
- 残ったユーザーに、期限後はパスワードの再設定が必要になると案内する
- 期限後、未移行ユーザーを CSV インポートで作る(RESET_REQUIRED になる)。E メール MFA 必須のプールではセルフサービスのリセットができないため、管理者が AdminSetUserPassword で仮パスワードを発行する
- トリガーを外し、移行用クライアントとロールを削除する
移行元が MFA 必須でも移行できるが、初回はコードが 2 通届く
移行元が E メールや SMS の MFA だと、AdminInitiateAuth のたびに移行元からもコードが送られます。使うのは移行先のコードだけなので、件名や送信者名を変え、「初回のみ 2 通届く」と事前に案内しておくと混乱を防げます。
パスワードポリシーは検証されない
移行時は移行先のパスワードポリシーが適用されません。厳密に適用したい場合は、Lambda で強度を判定し、満たさないユーザーを finalUserStatus: RESET_REQUIRED にする方法が公式で案内されています。
移行されない情報
| 情報 | 扱い |
|---|---|
| sub | 移行先で新しく採番される。sub をキーにしている場合は付け替えが必要 |
| グループ | 対象外。create-group、admin-add-user-to-group で再作成する |
| MFA デバイス(TOTP) | 対象外。移行先で再登録(MFA_SETUP)が必要で、MFA のリセットに相当する。E メール・SMS MFA は属性が移ればそのまま使える |
| 外部 IdP 経由のユーザー | 対象外。フェデレーションではトリガーが呼ばれない |
| 作成日時 | 移行した日時になる |
まとめ
ユーザー移行 Lambda トリガーで、別アカウントの Cognito へユーザーをパスワードごと移してみました。Lambda は移行先に置き、移行元のロールを借りて API を 2 つ呼ぶだけなので、コードは 150 行ほどです。
移行元が E メール MFA 必須でも、「MFA のコードを求められた=パスワードは合っている」と判断すれば移行できます。
ただし、パスワードを忘れた人は移行できないので、その場合の対応は別に決めておきましょう。また、サインインしない人は移行されないため、移行期間をいつ終えるかも先に考えておくと安心です。







