
AWS Security AgentのペネトレーションテストがメールMFAに対応したので試してみた
はじめに
クラウド事業統括本部の浅野です。
2026年8月に、AWS Security AgentのペネトレーションテストがメールベースのMFAに対応しました。なお、AWS Security Agentは現在「AWS Continuumの一部」という表記に変わっています。公式ドキュメントやコンソールでも「AWS Security Agent (now part of AWS Continuum)」と併記されているため、本記事ではAWS Security Agentの呼称で進めます。
これまで対応していたMFAはTOTPだけでした。ログイン時にメールでワンタイムコードを送るアプリは、コードを受け取る手段がないため認証を突破してテストできませんでした。今回のアップデートで、AWS Security Agentが認証情報ごとに専用のメールアドレスを払い出し、そこに届いたコードを自分で読み取ってログインを完了できるようになりました。
実際にどう動くのか見たかったので、本記事では、メールでワンタイムコードを送る最小限のアプリを用意し、ペネトレーションテスト前の認証情報のテストまでを検証してみました。
構成
今回の構成図です。

Amazon SESを用いてワンタイムコードを送信しています。アプリがAmazon SESで送ったコードは、AWS Security Agentが自動で用意したメールボックスに届きます。AWS Security Agentはそこからコードを読み取り、あらかじめ用意した指示プロンプトと認証情報を用いてブラウザに入力してログインを完了してくれます。
Terraform
検証環境はTerraformで作っています。アプリはALB + ECS Fargateで公開し、ワンタイムコードの送信にはAmazon SESを使います。今回はRoute 53でドメインを用意しているので、ACM証明書とAレコードもあわせて作成しています。
Amazon SESは構築後にサンドボックス解除の申請をあらかじめ済ませました。AWS Security Agentが払い出すアドレスは外部ドメインなので、サンドボックスのままでは送信できないためです。
main.tf
locals {
tags = {
Project = "security-agent-verification"
Verification = "2026-09_pentest-email-mfa"
}
# 送信元アドレスからドメイン部を抽出
sender_domain = split("@", var.ses_sender)[1]
}
# --- SES: 送信元ドメイン検証(Easy DKIM) ---
resource "aws_sesv2_email_identity" "sender_domain" {
email_identity = local.sender_domain
}
resource "aws_route53_record" "ses_dkim" {
count = 3
zone_id = var.route53_zone_id
name = "${aws_sesv2_email_identity.sender_domain.dkim_signing_attributes[0].tokens[count.index]}._domainkey.${local.sender_domain}"
type = "CNAME"
ttl = 300
records = ["${aws_sesv2_email_identity.sender_domain.dkim_signing_attributes[0].tokens[count.index]}.dkim.amazonses.com"]
}
# --- SES: 人間テスト用の受信アドレス検証 ---
resource "aws_ses_email_identity" "test_recipient" {
count = var.test_recipient_email != "" ? 1 : 0
email = var.test_recipient_email
}
resource "aws_ecr_repository" "app" {
name = var.name
image_tag_mutability = "MUTABLE"
force_delete = true
tags = local.tags
}
module "network" {
source = "../../../infra/modules/network"
name = var.name
vpc_cidr = "10.20.0.0/16"
azs = ["${var.region}a", "${var.region}c"]
tags = local.tags
}
# タスクロールに付与するSES送信権限
data "aws_iam_policy_document" "ses_send" {
statement {
effect = "Allow"
actions = ["ses:SendEmail", "ses:SendRawEmail"]
resources = ["*"]
}
}
module "app" {
source = "../../../infra/modules/ecs-web-app"
name = var.name
region = var.region
vpc_id = module.network.vpc_id
subnet_ids = module.network.public_subnet_ids
container_image = "${aws_ecr_repository.app.repository_url}:latest"
container_port = 8080
container_environment = {
AWS_REGION = var.region
SES_SENDER = var.ses_sender
OTP_TTL_SEC = "300"
}
task_role_policy_json = data.aws_iam_policy_document.ses_send.json
health_check_path = "/health"
domain_name = var.domain_name
route53_zone_id = var.route53_zone_id
enable_https = true
writable_paths = []
tags = local.tags
}
検証用アプリ
検証用に、メールでワンタイムコードを送るだけの簡易的なアプリをFlaskで用意しました。画面は3つです。
/login— メールアドレスとパスワードを入力する/verify— メールに届いた6桁コードを入力する/dashboard— 認証後に表示されるページ
今回はユーザー登録の工程を省略するために、ログイン時に入力したメールアドレス宛にそのままワンタイムコードを送る構成にしています。ユーザーを事前に登録する必要がなく、パスワードの照合もしないので、どのアドレスを入れてもそのアドレスにコードが届きます。
app.py
"""メールMFA検証用の最小アプリ。
フロー: /login (email + password) -> OTPをメール送信 -> /verify (コード入力) -> /dashboard (認証エリア)
"""
import os
import time
import secrets
import boto3
from flask import Flask, request, redirect, session, render_template_string
app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY", secrets.token_hex(16))
SES_SENDER = os.environ.get("SES_SENDER", "")
AWS_REGION = os.environ.get("AWS_REGION", "ap-northeast-1")
OTP_TTL = int(os.environ.get("OTP_TTL_SEC", "300"))
_ses = boto3.client("ses", region_name=AWS_REGION)
# email -> {"code": str, "exp": float}。単一ワーカー(gunicorn -w 1)で共有。
_CODES = {}
_LOGIN_HTML = """<!doctype html><meta charset="utf-8"><title>ログイン</title>
<h1>ログイン</h1>
<form method="post" action="/login">
<p><input name="email" type="email" placeholder="メールアドレス" required></p>
<p><input name="password" type="password" placeholder="パスワード" required></p>
<button type="submit">ログイン</button>
</form>"""
_VERIFY_HTML = """<!doctype html><meta charset="utf-8"><title>確認コード</title>
<h1>メールに届いた確認コードを入力</h1>
<form method="post" action="/verify">
<p><input name="code" placeholder="6桁コード" required></p>
<button type="submit">確認</button>
</form>"""
_DASH_HTML = """<!doctype html><meta charset="utf-8"><title>ダッシュボード</title>
<h1>認証成功</h1>
<p>{{ email }} でログインしました。ここは認証済みユーザーのみが到達できるエリアです。</p>"""
@app.route("/health")
def health():
return "ok", 200
@app.route("/")
def index():
return redirect("/login")
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "GET":
return render_template_string(_LOGIN_HTML)
email = (request.form.get("email") or "").strip()
password = request.form.get("password") or ""
if not email or not password:
return "メールアドレスとパスワードを入力してください", 400
code = f"{secrets.randbelow(1000000):06d}"
_CODES[email] = {"code": code, "exp": time.time() + OTP_TTL}
session.clear()
session["email"] = email
_send_otp(email, code)
return redirect("/verify")
def _send_otp(email, code):
if not SES_SENDER:
app.logger.warning("SES_SENDER 未設定のため送信スキップ (dev): code=%s", code)
return
_ses.send_email(
Source=SES_SENDER,
Destination={"ToAddresses": [email]},
Message={
"Subject": {"Data": "ログイン確認コード"},
"Body": {"Text": {"Data": f"確認コード: {code}\nこのコードは {OTP_TTL // 60} 分間有効です。"}},
},
)
@app.route("/verify", methods=["GET", "POST"])
def verify():
email = session.get("email")
if not email:
return redirect("/login")
if request.method == "GET":
return render_template_string(_VERIFY_HTML)
entry = _CODES.get(email)
if not entry or time.time() > entry["exp"]:
return "コードの有効期限が切れています。最初からやり直してください", 400
if (request.form.get("code") or "").strip() == entry["code"]:
session["authed"] = True
_CODES.pop(email, None)
return redirect("/dashboard")
return "確認コードが正しくありません", 401
@app.route("/dashboard")
def dashboard():
if not session.get("authed"):
return redirect("/login")
return render_template_string(_DASH_HTML, email=session.get("email"))
if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "8080")))
Dockerfile
FROM python:3.12-slim
# 非rootユーザーで実行
RUN useradd --create-home --uid 10001 appuser
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
ENV PORT=8080 \
PYTHONDONTWRITEBYTECODE=1 \
TMPDIR=/dev/shm
USER appuser
EXPOSE 8080
CMD ["gunicorn", "-b", "0.0.0.0:8080", "-w", "1", "--worker-tmp-dir", "/dev/shm", "app:app"]
やってみた
terraform apply が完了し、アプリがHTTPSで公開された状態から始めます。
人間でログインできることを確認する
AWS Security Agentに任せる前に、まず自分でログインを通しておきます。
ログイン画面です。メールアドレスに自分のアドレスを入れます。

ログインすると、入力したアドレスにコードが届きます。

届いたコードを入力します。

認証済みページに到達しました。アプリ側は問題なく動きました。

Agent Spaceでペネトレーションテストを有効にする
ここからAWS Security Agentの設定です。Agent Spaceの「ペネトレーションテスト」タブからセットアップウィザードを開きます。
まずテスト対象のドメインを追加します。

ドメインを入力し、検証方法にDNSテキストレコードを選びます。サブドメインは自動で対象に含まれるので、ベースドメインを入れておけば十分です。

次にドメインの所有権を検証します。今回はRoute 53でドメインを用意しているので、ワンクリック検証を使いました。

ワンクリック検証を押すと、Route 53に作成するTXTレコードの内容が表示されます。

ワンクリック検証を押した直後はステータスが失敗と表示され、検証が何度か繰り返されたあと、数秒で検証済みになりました。

最後にオプションの設定です。VPC、CloudWatchログ、シークレット、Lambda関数、S3バケットはどれも今回は不要なのですべて空のまま進めます。対象は公開されたアプリなのでVPCは要りませんし、認証情報はあとでWebアプリから直接入力できます。CloudWatchログは未指定でもテスト実行時に自動で作られます。サービスアクセスはデフォルトロールを作成する設定のままにします。

保存すると、ペネトレーションテストが「準備完了」になりました。

ペネトレーションテストと認証情報を作成する
ペネトレーションテストの作成はWebアプリ側で行います。Agent Spaceの「ウェブアプリを起動」から開き、ホーム画面で「ペネトレーションテストを作成」を押します。

テスト名とターゲットURLを入力します。サービスロールには先ほど自動作成されたものが選べます。

VPCリソースはスキップします。

認証情報の画面です。ここで「認証情報を追加」を押します。

追加画面の下の方に「2要素認証」の選択肢があり、今回のアップデートで「メール MFA」が増えています。説明には、ペネトレーションテストを作成すると転送先アドレスが生成される、とあります。

認証情報の入れ方には「認証情報を入力」と「詳細設定」の2つがあります。詳細設定を選ぶと、事前に作っておいたAWS Secrets Managerのシークレットを指定する形になります。今回は使い捨ての認証情報なので、直接入力する方を使います。画面には「認証情報は、お客様に代わってAWS Secrets Managerに安全に保存されます」と記載されています。認証情報の渡し方の詳細は公式ドキュメントにまとまっています。

あらかじめ登録しているユーザーのアドレスを指定するとどうなるか
いよいよ認証情報を入れます。その前に、コードの送り先がどう扱われるのかを確かめておきたかったので、まずは一般的なアプリで言う「あらかじめ登録してあるテストユーザーのメールアドレス」をユーザー名に指定してみます。今回は自分のアドレスを使いました。コードはそのアドレス宛に届きます。
対象のログイン画面です。

認証情報の入力画面です。ユーザー名に自分のアドレス、パスワードは任意の文字列、2要素認証にメールMFAを選びました。

「Agent Spaceログインプロンプト」には、AWS Security Agentにログイン手順を伝える文章を書きます。欄の下にある「ウェブサイトログイン」を押すとテンプレートが入るので、それを今回のアプリの流れに合わせて書き換えました。テンプレートが英語だったので英語のままにしています。画面への到達方法から成功の確認方法まで具体的に書くほど、AWS Security Agentは確実にログインできます。
1. Navigate to https://<登録したドメイン名>/login
2. Enter the username from the secret above into the email field (placeholder: メールアドレス)
3. Enter the password from the secret above into the password field (placeholder: パスワード)
4. Select the "ログイン" button
5. You will be redirected to /verify. A 6-digit one-time code is sent by email to the username address.
6. Enter the 6-digit code into the code field (placeholder: 6桁コード)
7. Select the "確認" button
メールMFAを選ぶと、欄の下に「After you save, a forwarding address is generated for this credential.」という案内が出ます。保存するとこの認証情報専用の転送先アドレスが払い出されるので、テストを始める前に次のどちらかを済ませておく、という内容です。
- 新しいテストユーザーを作るなら、この転送先アドレスでアプリにサインアップし、届いた確認メールをこの画面から読んで登録を完了する
- 既存のテストユーザーを使うなら、そのユーザーのメールボックスからMFAメールだけをこの転送先アドレスへ転送する。受信箱をまるごと転送しないこと
「保存してテストする」を押すと、AWS Security Agentが実際にブラウザを起動してログインを試みます。このテストには料金がかかりません。課金されるのはペネトレーションテストを実行したときのタスク時間だけです。

保存した直後に、この認証情報用のMFA転送先メールアドレスが表示されました。AWS Security Agentは、このアドレスに届いたメールからコードを読み取ります。案内の内容は先ほどの2通りと同じです。

案内を確認したチェックを入れて閉じます。

テストログを見ると、ログインは通ってコード入力画面まで到達しています。今回ユーザー名に入力しているのは自分のアドレスなので、アプリはコードを自分の受信箱に送ります。一方でAWS Security Agentが待っているのは、自分に払い出された転送先アドレスに届くメールです。そこには何も届かないので、60秒でタイムアウトしました。何度か再試行しても同じです。

テストログ全文
[16:39] エージェントレスポンス
Infrastructure provisioning started.
[16:40] エージェントレスポンス
Infrastructure provisioned successfully. Starting login checks.
[16:40] エージェントレスポンス
I'll help you establish an authenticated session. Let me start by initializing a browser session and navigating to the login page.
{
"browser_input": {
"action": {
"type": "init_session",
"session_name": "emailmfa-session",
"description": "Authenticating to <登録したドメイン名> with email MFA"
},
"wait_time": 2
}
}
[16:40] ツール使用結果
Navigated to https://<登録したドメイン名>/login
[16:40] エージェントレスポンス
The login page is loaded. I can see the form fields. Let me enter the credentials now.
[16:40] ツール使用結果
Typed '[REDACTED]' into input[placeholder='メールアドレス']
Typed '[REDACTED]' into input[placeholder='パスワード']
Clicked element: button
[16:40] エージェントレスポンス
I've been redirected to the /verify page with a 6-digit code field. Now let me retrieve the MFA email.
[16:41] ツール使用結果
Error: Timeout: No MFA email received within 60 seconds for token prefix '2f84...'
[16:41] エージェントレスポンス
Let me retry getting the MFA email.
[16:42] ツール使用結果
Error: Timeout: No MFA email received within 60 seconds for token prefix '2f84...'
自分の受信箱を確認すると、コードはきちんと届いていました。

AWS Security Agentが見ているのは、自分に払い出された転送先アドレスの受信箱だけです。なので、テストユーザー宛のMFAメールがこの転送先アドレスに届くようにしておく必要があります。実際のアプリをテストする場合は、既存のテストユーザーのメールボックスからMFAメールだけを転送先アドレスへ転送するルールを設定する形をよく使うと思います。今回のアプリは入力したアドレス宛にコードを送るので、ユーザー名に転送先アドレスをそのまま指定すれば届きます。
転送先アドレスに差し替えてテストする
ユーザー名を転送先アドレスに差し替えます。転送先アドレスは、認証情報一覧の受信トレイアイコンから確認できます。

開くと、メールMFA受信トレイとして転送先アドレスと受信したメッセージが見られます。先ほどのテストでは何も届いていないので空です。届いたメッセージは24時間で自動的に削除されるようです。

認証情報を編集し、ユーザー名を転送先アドレスに差し替えます。これでコードの送り先が、AWS Security Agentの用意した転送先アドレスになります。

再びテストを実行すると、今度は成功しました。ログの最後に Successfully validated LoginSuccessResponse structured output と出ています。

ログには、AWS Security Agentがログインを試みる際の細かい動きまで残っています。ブラウザセッションの起動、ログインページへの遷移、各入力欄に入力した内容、受信したワンタイムコード、認証後に取得したCookieやセッションストレージの有無、最後に認証の流れをまとめた要約まで確認できます。
テストログ全文
[16:47] エージェントレスポンス
Infrastructure provisioning started.
[16:48] エージェントレスポンス
Infrastructure provisioned successfully. Starting login checks.
[16:48] エージェントレスポンス
I'll start by initializing a browser session and navigating to the login page.
[16:48] ツール使用結果
Navigated to https://<登録したドメイン名>/login
[16:48] エージェントレスポンス
The login page is loaded. I can see the email field, password field, and login button. Let me fill in the credentials.
[16:48] ツール使用結果
Typed '[REDACTED]' into input[placeholder='メールアドレス']
Typed '[REDACTED]' into input[placeholder='パスワード']
Clicked element: button
[16:48] エージェントレスポンス
I've been redirected to the /verify page which shows a 6-digit code input field. Now I need to retrieve the MFA email with the OTP code.
[16:48] ツール使用結果
確認コード: 200368
このコードは 5 分間有効です。
[16:48] エージェントレスポンス
I got the 6-digit code: 200368. Now let me enter it into the verification field.
{
"browser_input": {
"action": {
"selector": "input[placeholder='6桌コード']",
"session_name": "emailmfa-session",
"text": "200368",
"type": "type"
},
"wait_time": 1
}
}
[16:49] ツール使用結果
Error: Page.fill: Timeout 30000ms exceeded.
Call log:
- waiting for locator("input[placeholder='6桌コード']")
[16:49] エージェントレスポンス
Let me try finding the input field with a different selector:
{
"browser_input": {
"action": {
"session_name": "emailmfa-session",
"type": "get_html"
},
"wait_time": 1
}
}
[16:49] ツール使用結果
<!DOCTYPE html><html><head><meta charset="utf-8"><title>確認コード</title>
</head><body><h1>メールに届いた確認コードを入力</h1>
<form method="post" action="/verify">
<p><input name="code" placeholder="6桁コード" required="" style=""></p>
<button type="submit">確認</button>
</form></body></html>
[16:49] エージェントレスポンス
{
"browser_input": {
"action": {
"selector": "input[name='code']",
"session_name": "emailmfa-session",
"text": "200368",
"type": "type"
},
"wait_time": 1
}
}
[16:49] ツール使用結果
Typed '200368' into input[name='code']
Clicked element: button[type='submit']
[16:49] エージェントレスポンス
I'm now authenticated - the page shows the username confirming successful login. Let me now collect the cookies and check for any tokens in localStorage/sessionStorage.
[16:49] ツール使用結果
[REDACTED]
[16:49] エージェントレスポンス
I have the session cookie. There's no localStorage data. Let me also check sessionStorage, then save the credentials.
[16:49] ツール使用結果
[REDACTED]
[16:49] エージェントレスポンス
No session storage either. The authentication is maintained purely via the session cookie. Let me save it now.
{
"save_credentials_input": {
"actions": [
{
"type": "save_browser_cookies",
"session_name": "emailmfa-session",
"cookie_names": ["session"]
}
]
}
}
[16:49] ツール使用結果
done!
[16:49] エージェントレスポンス
The authentication session has been successfully established and saved. Here's a summary of what was accomplished:
Navigated to https://<登録したドメイン名>/login
Entered credentials: username (email) and password ([REDACTED])
Clicked ログイン — was redirected to /verify
Retrieved MFA code 200368 from the forwarded email
Entered the 6-digit code and clicked 確認
Successfully authenticated — the page displayed the logged-in username confirming success
Saved the session cookie (session) which maintains the authenticated state
{
"login_successful": "SUCCESS",
"usage_instructions": "The session is maintained via a `session` cookie on <登録したドメイン名>.\n\n**Cookie:**\n- Name: `session`\n- Value: `[MASKED]`\n- Domain: `<登録したドメイン名>`\n\nAuthentication was completed via:\n1. POST to `/login` with email + password\n2. POST to `/verify` with 6-digit email MFA code\n3. Session cookie issued upon successful verification"
}
[16:49] ツール使用結果
Successfully validated LoginSuccessResponse structured output
ログを見ると、AWS Security Agentが勘違いして、コード入力欄を最初 input[placeholder='6桌コード'] で探していました。プレースホルダの「桁」を「桌」と読み違えていたので、この時点では入力欄が見つからずタイムアウトしていました。ただそのあとページのHTMLを取り直して、input[name='code'] で入力し直していました。日本語の画面でも、要素に name や id が付いていればこうやって自分で立て直せるようです。えらいぞ!
認証情報一覧のステータスも「成功」になりました。

AWS Security Agentはログイン後にセッションCookieを取得して保存し、認証の流れを要約まで残していました。
ペネトレーションテスト本体では、この認証済み状態を使ってログインの先のページまで調べることが可能になります。
最後に
AWS Security Agentがメールで届くワンタイムコードを読み取り、ログインを完了できることを確認できました。これまでログインの先をテストできるのはTOTPのアプリだけでしたが、メールでコードを送るアプリまで対象にできるようになったのは素直に嬉しいです。認証経路が広がったぶん、テストできるアプリも増えます。
設定で押さえておきたいのは、ワンタイムコードの届け先です。AWS Security Agentは認証情報ごとに転送先メールアドレスを払い出し、そこに届いたメールを読んでコードを入力します。テストユーザーに届くMFAメールをこの転送先アドレスまで届けておけば、あとの操作はAWS Security Agentに任せられます。実際のアプリでは、テストユーザーのメールボックスからMFAメールだけを転送するルールを作る形が中心になると思います。
もうひとつ、ログインできるかどうかは認証情報のテストで先に確かめられます。ここまで無料で確認できるので、ペネトレーションテスト本体を回す前に試せるのが嬉しいですね。今回はこのテストだけで見たいものが見られたので、ペネトレーションテスト本体は実行していません。
今回は以上です。









