Streamlit × Cognito でカスタム OTP ログインを実装する
はじめに
こんにちは、コンサルティング部のぐっさんです。寒いですね。
本記事では、CDK を使って Cognito のカスタム認証用Lambdaを設定し、認証に「ワンタイムパスワード(OTP)」を使用する簡単なデモアプリケーションの実装を試します。
フロントエンドに Streamlit、ホスティングに ECS/Fargate + ALB を使用し、開発言語はすべて Python で統一した構成にしてみました。
作るもの
こんな画面のイメージです。

- ユーザー登録(メール + パスワード)
- ログイン時にパスワード認証後、メールで OTP を送信
- OTP を入力すればログイン完了
なぜ Cognito 標準の MFA ではなく、カスタム OTP?
Cognito には標準で MFA 機能がありますが、カスタム認証を使うことで以下のようなカスタマイズや仕組み理解が出来るためです。
- メール本文を自由にカスタマイズ可能
- OTP の有効期限、試行回数制限などを柔軟に設定可能
- 認証フローの仕組みを理解できる(学習目的)
構成図
簡単な構成図です。
一部省略していますが、ECSとCognito間の通信はNAT Gateway経由にしています。

前提条件
- Python 3.12+
- Node.js 18+ (CDK CLI 用)
- AWS CLI 設定済み
- Docker
- SES で検証済みのメールアドレス(サンドボックスモードの場合)
プロジェクト構成
cdk-streamlit-cognito/
├── app.py # CDK エントリポイント
├── cdk.json
├── requirements.txt # CDK用
│
├── stacks/
│ └── main_stack.py # CDK スタック定義
│
├── lambda/ # カスタム認証 Lambda群
│ ├── define_auth/
│ │ └── index.py
│ ├── create_auth/
│ │ └── index.py
│ └── verify_auth/
│ └── index.py
│
└── streamlit/
├── app.py # Streamlit アプリ
├── Dockerfile
└── requirements.txt
Cognito カスタム認証の仕組み
Cognito のカスタム認証チャレンジは、3つの Lambda トリガーで構成されます。
認証フロー
- ログイン開始 - ユーザーがメールアドレスとパスワードを入力
- パスワード検証 - Cognito が SRP プロトコルでパスワードを検証
- OTP 送信 - パスワードが正しければ、Lambda が OTP を生成してメール送信
- OTP 入力 - ユーザーが届いた 6 桁のコードを入力
- OTP 検証 - Lambda が OTP を検証、正しければトークン発行
[ユーザー] ──(1)メール+パスワード──> [Cognito] ──(2)パスワード検証
│
▼
[ユーザー] <──(3)OTPメール────────── [Lambda] ──> [SES]
│
[ユーザー] ──(4)OTP入力──────────────> [Cognito]
│
▼
[ユーザー] <──(5)トークン発行──────── [Lambda] ──(OTP検証)
SRP(Secure Remote Password)とは?
SRP はパスワードをネットワークに送信せずに認証できる暗号プロトコルです。
クライアントとサーバーが数学的な計算を交換することで、パスワードが正しいかを検証します。
Cognito は標準で SRP をサポートしており、今回は pycognito ライブラリがこの処理を担当します。
3つの Lambda の役割
| Lambda | 役割 |
|---|---|
| DefineAuthChallenge | 認証フローの制御(次に何をするか決定) |
| CreateAuthChallenge | OTP を生成して SES でメール送信 |
| VerifyAuthChallenge | ユーザーが入力した OTP が正しいか検証 |
CDK スタック実装
プロジェクト初期化
mkdir cdk-streamlit-cognito
cd cdk-streamlit-cognito
# 仮想環境作成 & アクティベート
python -m venv .venv
source .venv/bin/activate
# 依存パッケージインストール
pip install -r requirements.txt
requirements.txt (CDK用)
aws-cdk-lib>=2.170.0
constructs>=10.0.0
stacks/main_stack.py
from aws_cdk import (
Stack,
CfnOutput,
aws_cognito as cognito,
aws_lambda as lambda_,
aws_ec2 as ec2,
aws_ecs as ecs,
aws_ecs_patterns as ecs_patterns,
aws_ecr_assets as ecr_assets,
aws_iam as iam,
Duration,
RemovalPolicy,
)
from constructs import Construct
class MainStack(Stack):
def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
super().__init__(scope, construct_id, **kwargs)
# ===================
# Lambda Functions
# ===================
define_auth_fn = lambda_.Function(
self,
"DefineAuthChallenge",
runtime=lambda_.Runtime.PYTHON_3_12,
handler="index.handler",
code=lambda_.Code.from_asset("lambda/define_auth"),
timeout=Duration.seconds(30),
)
create_auth_fn = lambda_.Function(
self,
"CreateAuthChallenge",
runtime=lambda_.Runtime.PYTHON_3_12,
handler="index.handler",
code=lambda_.Code.from_asset("lambda/create_auth"),
timeout=Duration.seconds(30),
)
verify_auth_fn = lambda_.Function(
self,
"VerifyAuthChallenge",
runtime=lambda_.Runtime.PYTHON_3_12,
handler="index.handler",
code=lambda_.Code.from_asset("lambda/verify_auth"),
timeout=Duration.seconds(30),
)
# ===================
# Cognito User Pool
# ===================
user_pool = cognito.UserPool(
self,
"UserPool",
user_pool_name="custom-otp-pool",
self_sign_up_enabled=True,
sign_in_aliases=cognito.SignInAliases(email=True),
auto_verify=cognito.AutoVerifiedAttrs(email=True),
# Lambda トリガー
lambda_triggers=cognito.UserPoolTriggers(
define_auth_challenge=define_auth_fn,
create_auth_challenge=create_auth_fn,
verify_auth_challenge_response=verify_auth_fn,
),
removal_policy=RemovalPolicy.DESTROY,
)
# CreateAuthChallenge Lambda に AdminGetUser と SES 権限を付与
create_auth_fn.add_to_role_policy(
iam.PolicyStatement(
actions=["cognito-idp:AdminGetUser"],
resources=[
f"arn:aws:cognito-idp:{self.region}:{self.account}:userpool/*"
],
)
)
create_auth_fn.add_to_role_policy(
iam.PolicyStatement(
actions=["ses:SendEmail"],
resources=["*"],
)
)
# User Pool Client
user_pool_client = user_pool.add_client(
"AppClient",
auth_flows=cognito.AuthFlow(
custom=True, # カスタム認証を有効化
user_srp=True, # SRP認証も有効化
),
generate_secret=False,
)
# ===================
# VPC
# ===================
vpc = ec2.Vpc(
self,
"Vpc",
max_azs=2,
nat_gateways=1,
)
# ===================
# ECS Cluster
# ===================
cluster = ecs.Cluster(
self,
"Cluster",
vpc=vpc,
)
# ===================
# Docker Image
# ===================
image_asset = ecr_assets.DockerImageAsset(
self,
"StreamlitImage",
directory="./streamlit",
)
# ===================
# Fargate Service with ALB
# ===================
fargate_service = ecs_patterns.ApplicationLoadBalancedFargateService(
self,
"StreamlitService",
cluster=cluster,
cpu=256,
memory_limit_mib=512,
desired_count=1,
task_image_options=ecs_patterns.ApplicationLoadBalancedTaskImageOptions(
image=ecs.ContainerImage.from_docker_image_asset(image_asset),
container_port=8501,
environment={
"USER_POOL_ID": user_pool.user_pool_id,
"CLIENT_ID": user_pool_client.user_pool_client_id,
"AWS_REGION": self.region,
},
),
public_load_balancer=True,
)
# Cognito へのアクセス権限
fargate_service.task_definition.task_role.add_to_policy(
iam.PolicyStatement(
actions=[
"cognito-idp:InitiateAuth",
"cognito-idp:RespondToAuthChallenge",
"cognito-idp:SignUp",
"cognito-idp:ConfirmSignUp",
],
resources=[
f"arn:aws:cognito-idp:{self.region}:{self.account}:userpool/*"
],
)
)
# ヘルスチェック設定
fargate_service.target_group.configure_health_check(
path="/_stcore/health",
healthy_http_codes="200",
)
# ===================
# Outputs
# ===================
CfnOutput(
self,
"UserPoolId",
value=user_pool.user_pool_id,
description="Cognito User Pool ID",
)
CfnOutput(
self,
"UserPoolClientId",
value=user_pool_client.user_pool_client_id,
description="Cognito User Pool Client ID",
)
CfnOutput(
self,
"ServiceUrl",
value=f"http://{fargate_service.load_balancer.load_balancer_dns_name}",
description="ALB URL",
)
Lambda 実装
lambda/define_auth/index.py
認証フローを制御する Lambda です。セッションの状態に応じて次のチャレンジを決定します。
import logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def handler(event, context):
"""
認証フローの次のステップを決定する
"""
username = event.get("userName", "unknown")
session = event["request"]["session"]
session_length = len(session)
logger.info(f"User: {username}, Session step: {session_length}")
# 空セッション → PASSWORD_VERIFIER を開始
if session_length == 0:
challenge = "PASSWORD_VERIFIER"
event["response"]["challengeName"] = challenge
event["response"]["issueTokens"] = False
event["response"]["failAuthentication"] = False
logger.info(f"Next challenge: {challenge}")
# PASSWORD_VERIFIER 完了後 → OTP チャレンジ
elif (
session_length == 1
and session[-1]["challengeName"] == "PASSWORD_VERIFIER"
and session[-1]["challengeResult"]
):
challenge = "CUSTOM_CHALLENGE"
event["response"]["challengeName"] = challenge
event["response"]["issueTokens"] = False
event["response"]["failAuthentication"] = False
logger.info(f"Password verified. Next challenge: {challenge}")
# OTP 正解 → トークン発行
elif (
session_length == 2
and session[-1]["challengeName"] == "CUSTOM_CHALLENGE"
and session[-1]["challengeResult"]
):
event["response"]["issueTokens"] = True
event["response"]["failAuthentication"] = False
logger.info("OTP verified. Issuing tokens.")
# 失敗
else:
event["response"]["issueTokens"] = False
event["response"]["failAuthentication"] = True
logger.warning(f"Authentication failed. Session: {session}")
return event
lambda/create_auth/index.py
OTP を生成して SES でメール送信する Lambda です。
import logging
import random
import boto3
logger = logging.getLogger()
logger.setLevel(logging.INFO)
ses_client = boto3.client("ses")
cognito_client = boto3.client("cognito-idp")
def handler(event, context):
"""
OTP チャレンジを作成し、メールで送信する
"""
user_pool_id = event["userPoolId"]
username = event["userName"]
# ユーザーのメールアドレスを取得
user = cognito_client.admin_get_user(UserPoolId=user_pool_id, Username=username)
email = ""
for attr in user["UserAttributes"]:
if attr["Name"] == "email":
email = attr["Value"]
break
# 6桁のOTPを生成
otp = str(random.randint(100000, 999999))
logger.info(f"User: {username}, Email: {email[:3]}***")
# メール送信
if email:
try:
ses_client.send_email(
Source=email, # SESで検証済みのメールアドレス
Destination={"ToAddresses": [email]},
Message={
"Subject": {"Data": "ログイン認証コード", "Charset": "UTF-8"},
"Body": {
"Text": {
"Data": f"あなたの認証コードは {otp} です。\n\n有効期限は3分間です。",
"Charset": "UTF-8",
}
},
},
)
logger.info("OTP email sent successfully")
except Exception as e:
logger.error(f"Failed to send OTP email: {type(e).__name__}: {str(e)}")
# 公開パラメータ(クライアントに送る)
event["response"]["publicChallengeParameters"] = {
"email": email[:3] + "***" + email[email.index("@"):] if email else ""
}
# 非公開パラメータ(検証用、クライアントには送らない)
event["response"]["privateChallengeParameters"] = {"otp": otp}
event["response"]["challengeMetadata"] = "OTP_CHALLENGE"
return event
lambda/verify_auth/index.py
ユーザーが入力した OTP を検証する Lambda です。
import logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def handler(event, context):
"""
OTP を検証する
"""
username = event.get("userName", "unknown")
expected_otp = event["request"]["privateChallengeParameters"]["otp"]
user_otp = event["request"]["challengeAnswer"]
# OTP が一致するか確認
if user_otp == expected_otp:
event["response"]["answerCorrect"] = True
logger.info(f"User: {username}, OTP verification: SUCCESS")
else:
event["response"]["answerCorrect"] = False
logger.warning(f"User: {username}, OTP verification: FAILED")
return event
Streamlit 実装
streamlit/requirements.txt
streamlit>=1.40.0
boto3>=1.35.0
pycognito>=2024.5.1
streamlit/app.py
import os
import boto3
import streamlit as st
from botocore.exceptions import ClientError
from pycognito import AWSSRP
# 環境変数から取得
USER_POOL_ID = os.environ.get("USER_POOL_ID", "")
CLIENT_ID = os.environ.get("CLIENT_ID", "")
REGION = os.environ.get("AWS_REGION", "ap-northeast-1")
# Cognito クライアント
cognito_client = boto3.client("cognito-idp", region_name=REGION)
def main():
st.title("カスタム OTP 認証デモ")
# セッション状態の初期化
if "authenticated" not in st.session_state:
st.session_state.authenticated = False
if "auth_session" not in st.session_state:
st.session_state.auth_session = None
if "challenge" not in st.session_state:
st.session_state.challenge = None
if "username" not in st.session_state:
st.session_state.username = None
if "confirmation_required" not in st.session_state:
st.session_state.confirmation_required = False
# 認証済みの場合
if st.session_state.authenticated:
show_dashboard()
# メール確認待ち
elif st.session_state.confirmation_required:
show_confirmation()
# OTP チャレンジ中
elif st.session_state.challenge:
show_challenge()
# 未認証の場合
else:
show_login()
def show_login():
"""ログイン画面"""
tab1, tab2 = st.tabs(["ログイン", "新規登録"])
with tab1:
st.subheader("ログイン")
email = st.text_input("メールアドレス", key="login_email")
password = st.text_input("パスワード", type="password", key="login_password")
if st.button("ログイン"):
if not email or not password:
st.error("メールアドレスとパスワードを入力してください")
return
try:
# SRP 認証を使用してカスタムチャレンジを取得
aws_srp = AWSSRP(
username=email,
password=password,
pool_id=USER_POOL_ID,
client_id=CLIENT_ID,
client=cognito_client,
)
# CUSTOM_AUTH フローで SRP 認証を実行
auth_params = aws_srp.get_auth_params()
# Step 1: CUSTOM_AUTH 開始 → PASSWORD_VERIFIER が返ってくる
response = cognito_client.initiate_auth(
ClientId=CLIENT_ID,
AuthFlow="CUSTOM_AUTH",
AuthParameters=auth_params,
)
# Step 2: PASSWORD_VERIFIER に応答 → CUSTOM_CHALLENGE が返ってくる
if response.get("ChallengeName") == "PASSWORD_VERIFIER":
challenge_response = aws_srp.process_challenge(
response["ChallengeParameters"], auth_params
)
response = cognito_client.respond_to_auth_challenge(
ClientId=CLIENT_ID,
ChallengeName="PASSWORD_VERIFIER",
Session=response["Session"],
ChallengeResponses=challenge_response,
)
else:
st.warning(f"予期しないチャレンジ: {response.get('ChallengeName')}")
return
# カスタムチャレンジが返ってきた場合
if response.get("ChallengeName") == "CUSTOM_CHALLENGE":
st.session_state.challenge = response
st.session_state.auth_session = response["Session"]
st.session_state.username = email
st.rerun()
# 認証成功(チャレンジなし)
elif "AuthenticationResult" in response:
st.session_state.authenticated = True
st.session_state.tokens = response["AuthenticationResult"]
st.rerun()
else:
st.warning(f"予期しないレスポンス: {response.get('ChallengeName')}")
except ClientError as e:
st.error(f"ログインエラー: {e.response['Error']['Message']}")
except Exception as e:
st.error(f"エラー: {type(e).__name__}: {str(e)}")
with tab2:
st.subheader("新規登録")
new_email = st.text_input("メールアドレス", key="signup_email")
new_password = st.text_input(
"パスワード (8文字以上、大文字・小文字・数字・記号含む)", type="password", key="signup_password"
)
if st.button("登録"):
if not new_email or not new_password:
st.error("メールアドレスとパスワードを入力してください")
return
try:
# ユーザー登録
response = cognito_client.sign_up(
ClientId=CLIENT_ID,
Username=new_email,
Password=new_password,
UserAttributes=[
{"Name": "email", "Value": new_email},
],
)
st.session_state.username = new_email
st.session_state.confirmation_required = True
st.success("登録メールを送信しました。確認コードを入力してください。")
st.rerun()
except ClientError as e:
st.error(f"登録エラー: {e.response['Error']['Message']}")
def show_confirmation():
"""メール確認画面"""
st.subheader("メール確認")
st.write(f"**{st.session_state.username}** に確認コードを送信しました。")
code = st.text_input("確認コード")
if st.button("確認"):
try:
cognito_client.confirm_sign_up(
ClientId=CLIENT_ID,
Username=st.session_state.username,
ConfirmationCode=code,
)
st.success("登録完了!ログインしてください。")
st.session_state.confirmation_required = False
st.rerun()
except ClientError as e:
st.error(f"確認エラー: {e.response['Error']['Message']}")
if st.button("確認コードを再送信"):
try:
cognito_client.resend_confirmation_code(
ClientId=CLIENT_ID, Username=st.session_state.username
)
st.success("確認コードを再送信しました")
except ClientError as e:
st.error(f"再送信エラー: {e.response['Error']['Message']}")
def show_challenge():
"""OTP チャレンジ画面"""
st.subheader("認証コード入力")
challenge = st.session_state.challenge
masked_email = challenge.get("ChallengeParameters", {}).get("email", "")
st.info(f"**{masked_email}** に認証コードを送信しました。")
otp = st.text_input("6桁の認証コードを入力してください", max_chars=6)
col1, col2 = st.columns(2)
with col1:
if st.button("認証"):
try:
response = cognito_client.respond_to_auth_challenge(
ClientId=CLIENT_ID,
ChallengeName="CUSTOM_CHALLENGE",
Session=st.session_state.auth_session,
ChallengeResponses={
"USERNAME": st.session_state.username,
"ANSWER": otp,
},
)
# 認証成功
if "AuthenticationResult" in response:
st.session_state.authenticated = True
st.session_state.tokens = response["AuthenticationResult"]
st.session_state.challenge = None
st.rerun()
# さらにチャレンジがある場合
elif "ChallengeName" in response:
st.session_state.challenge = response
st.session_state.auth_session = response["Session"]
st.rerun()
except ClientError as e:
st.error(f"認証エラー: {e.response['Error']['Message']}")
with col2:
if st.button("キャンセル"):
st.session_state.challenge = None
st.session_state.auth_session = None
st.session_state.username = None
st.rerun()
def show_dashboard():
"""認証後のダッシュボード"""
# ヘッダー
col1, col2 = st.columns([3, 1])
with col1:
st.title("ダッシュボード")
with col2:
if st.button("ログアウト", type="secondary"):
st.session_state.authenticated = False
st.session_state.tokens = None
st.session_state.challenge = None
st.session_state.auth_session = None
st.session_state.username = None
st.rerun()
st.divider()
# ユーザー情報カード
st.subheader("ユーザー情報")
col1, col2 = st.columns(2)
with col1:
st.metric(label="ログイン中のユーザー", value=st.session_state.username or "不明")
with col2:
st.metric(label="認証方式", value="OTP (Email)")
st.divider()
# 認証成功メッセージ
st.success("カスタム OTP による多要素認証が完了しました!")
# 機能説明
st.subheader("このデモについて")
st.markdown("""
このアプリは **Amazon Cognito** のカスタム認証フローを使用して、
以下の多要素認証を実装しています:
1. **パスワード認証** - SRP プロトコルによる安全なパスワード検証
2. **OTP 認証** - メールで送信される6桁のワンタイムパスワード
すべて **Python** で構築されています:
- インフラ: AWS CDK (Python)
- バックエンド: AWS Lambda (Python)
- フロントエンド: Streamlit (Python)
""")
# トークン情報の表示(デバッグ用)
with st.expander("トークン情報(デバッグ用)"):
if "tokens" in st.session_state:
st.json(
{
"AccessToken": st.session_state.tokens.get("AccessToken", "")[:50]
+ "...",
"IdToken": st.session_state.tokens.get("IdToken", "")[:50] + "...",
"ExpiresIn": st.session_state.tokens.get("ExpiresIn"),
"TokenType": st.session_state.tokens.get("TokenType"),
}
)
if __name__ == "__main__":
main()
streamlit/Dockerfile
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8501
CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0", "--server.headless=true", "--server.enableCORS=false", "--server.enableXsrfProtection=false", "--browser.gatherUsageStats=false"]
デプロイ
# 仮想環境アクティベート
source .venv/bin/activate
# ブートストラップ(初回のみ)
cdk bootstrap
# デプロイ
cdk deploy
動作確認
- デプロイ後に出力される ALB の URL にアクセス

- 新規登録でメールアドレスとパスワードを入力

- Cognito から届いた確認コードを入力(サインアップ確認)



- この時点で新規ユーザーがCognitoに登録されたことを確認

- ログインでメールアドレスとパスワードを入力

- SES から OTP がメールで届く(※SESがサンドボックス環境の場合、迷惑メールフォルダに入る可能性が高いです)


- OTP を入力

- 以下のようなダッシュボードが表示されればOTPを使用したカスタム認証成功!

お片付け
CDKでデプロイしたCFnをコンソールから削除するか、以下のcdkコマンドで環境を削除します。
cdk destroy
ハマった部分など
1. App Runner は WebSocket 非対応
当初 App Runner でフロント構築しようとしましたが、Streamlit は WebSocket を使用するため動作しませんでした。。。
ECS/Fargate + ALB であれば WebSocket が正常に動作します。
このissueに辿り着いて泣く泣く諦めました。サポートしてもらえたらとても嬉しいです・・・!
2. SES サンドボックス
SES はデフォルトでサンドボックスモードです。
送信元・送信先ともに検証済みメールアドレスが必要です。
aws ses verify-email-identity --email-address your-email@example.com
また、サンドボックスモードではメールが迷惑メールフォルダに振り分けられやすいです。
私も最初、迷惑メールフォルダに届いて少々時間を取られました。
OTP メールが届かない場合は、迷惑メールフォルダを確認してみてください。
3. カスタム認証フローの有効化
User Pool Client で auth_flows.custom=True を忘れずに設定しましょう。
4. DefineAuthChallenge の役割
DefineAuthChallenge Lambda は認証の各ステップで呼ばれ、「次に何をするか」を決定します。
セッション(session)に過去のチャレンジ結果が蓄積されていくので、その長さと結果を見て判断します。
| セッション状態 | 返すチャレンジ | 意味 |
|---|---|---|
| 空(初回) | PASSWORD_VERIFIER |
パスワード検証を開始 |
| パスワード検証成功後 | CUSTOM_CHALLENGE |
OTP チャレンジを発行 |
| OTP 検証成功後 | issueTokens = True |
トークン発行(認証完了) |
実装時、セッションが空で返ってきてエラーになることが多かったです。
デバッグでちょこちょこレスポンスの中身を見ながら進めると良さそうでした。
5. Docker イメージのプラットフォーム
Apple Silicon Mac でビルドする場合、--platform=linux/amd64 を指定しないと
Fargate で実行エラーになりますのでご注意。
まとめ
Cognito のカスタム認証チャレンジで「ワンタイムパスワード(OTP)」を実装してみました。
OTP送信時のメール文面(Body)にLambda側で好きなメッセージを入れられるため、ある程度カスタムできて面白かったです。
認証に役割違いのLambdaが3つ必要な点も勉強になりました。
アプリケーションにオリジナルの認証を付与したい時はぜひ試してみてください!
最後までお読み頂きありがとうございました。







