「エンタープライズ顧客から『Azure AD(Microsoft Entra ID)でのSSOに対応してほしい』と要望が来たが、実装方法が分からない」
「AWS CognitoでSAML/OIDC統合を試みたが、設定項目が多すぎて挫折した」
「マルチテナントSaaSで、顧客ごとに異なるIdentity Providerを統合する方法が知りたい」
「2024年以降に出た『パスキー』『選択式認証』『料金プラン(Lite/Essentials/Plus)』が、古い記事と食い違っていて混乱している」
B2B SaaS開発者が直面するこれらの課題。私自身、経済産業大臣賞を受賞したB2Bサブスクリプション SaaS(木材流通DX)の開発で、Cognitoによる認証基盤をRS256のJWT検証込みで設計・運用しました。同じ壁にぶつかった経験から、この記事を書いています。
この記事は AWS公式ドキュメント(2026年6月時点)に忠実 に、かつ「どの場面で・どう使うか」を実コード付きで解説します。各セクションには参照した公式ドキュメントのリンクを明記しているので、自分の環境で一次情報を確認しながら実装を進められます。
この記事で扱う範囲
- 2026年のCognito全体像(料金プラン・マネージドログイン)
- 認証方式の選び方(フェデレーション / パスワードレス / カスタムフロー)
- SAML(Azure AD)・OIDC(Google / Okta)連携の実装
- カスタム認証フロー(3つのLambdaトリガー)
USER_AUTH選択式・パスワードレス認証(パスキー / OTP)- マルチテナント設計とトークンへのテナント注入(Pre Token Generation V2.0)
- JWT検証(
aws-jwt-verify)とトラブルシューティング
企業SSOの原理と歴史:なぜ SAML と OIDC が今も併存するのか
実装に入る前に、10分だけ「なぜこの2つの規格が存在し、なぜ片方に統一されないのか」を押さえておくと、設定項目の一つひとつが「作法」ではなく「必然」として腑に落ちます。ここを飛ばすと、属性マッピングや署名検証で詰まったときに手が止まります。
フェデレーションの中核は「信頼の委譲」
企業SSOの本質は、自社(Service Provider=SP)が「誰がログインしたか」の判断を、顧客企業のID基盤(Identity Provider=IdP)に委譲することです。あなたのSaaSは顧客のパスワードを一切持ちません。代わりに、IdPが「このユーザーは確かに認証済みだ」と**署名付きで宣言(アサーション)**し、SPはその署名を検証して信頼します。
この「署名されたアサーションを信頼の根拠にする」という一点が、SSOのセキュリティ設計のすべてを規定します。後半の攻撃対策がすべて「アサーションを偽造・すり替え・再利用させない」に集約されるのは、このためです。
SAML 2.0 — エンタープライズが手放せない理由(2005〜)
SAML(Security Assertion Markup Language)2.0 は、OASIS が2005年(正式承認 2005年3月)に標準化したXMLベースのフェデレーション規格です。前身の SAML 1.x(1.0=2002 / 1.1=2003)、Liberty Alliance の ID-FF、Shibboleth といった実装の系譜を統合し、Web Browser SSO Profile(HTTP-Redirect / HTTP-POST(および HTTP-Artifact)バインディングでアサーションを運ぶ)を確立しました。署名には XML Signature(XML-DSig)、機密化には XML Encryption を用います。
SAMLが20年経った今も企業で現役なのは、Active Directory / ADFS / Microsoft Entra ID(旧 Azure AD)といった既存資産との相性と、「エンタープライズアプリケーション」として厳格に登録・監査するガバナンス文化に深く根を張っているからです。大企業の情シスが「SAMLで来てくれ」と言うのは、彼らの統制プロセスがSAMLを前提に設計されているからです。
OIDC — モバイルとAPIの時代の答え(2014〜)
OpenID Connect(OIDC)1.0 は、OpenID Foundation が2014年(Final 仕様は2014年2月)に公開した、OAuth 2.0(RFC 6749)の上に薄いID層を載せた規格です。XMLではなくJSON/JWTを使い、.well-known/openid-configuration によるディスカバリでエンドポイントを自動発見できます。モバイルアプリ・SPA・API連携との親和性が高く、実装が軽い——これがモダンなIdP(Google、Okta のOIDCアプリ、各種CIAM)がOIDCを選ぶ理由です。
「どちらか」ではなく「両方受けられる」が正解
歴史が示すのは、SAMLとOIDCは世代交代ではなく棲み分けだという事実です。レガシーを抱える大企業はSAML、モダンな顧客はOIDCで来ます。だからこそ Cognito のように1つのユーザープールに複数IdP(SAML/OIDC混在)を同居させ、顧客ごとに出し分けられる設計が、B2B SaaSの現実解になります。次章以降の実装は、この「両方受ける」を前提に組み立てます。
まず押さえる:2026年のCognitoは「料金プラン」で機能が変わる
2024年11月、Cognito User Poolsに 機能プラン(feature plans) が導入され、「どの認証機能が使えるか」がプランで決まるようになりました。ここを理解せずに古い記事のコードをコピーすると「APIは通るのに機能が有効化されない」事故が起きます。 最初に全体像を押さえましょう。
| プラン | 主な対象機能 | 想定ユース |
|---|---|---|
| Lite | 基本認証 + 旧 Hosted UI(クラシック) | 最小構成・コスト最優先。パスキー/アクセストークンのカスタマイズ不可 |
| Essentials(新規プールの既定) | 選択式サインイン(USER_AUTH)、パスワードレス(パスキー/OTP)、メールMFA、マネージドログイン、アクセストークンのクレーム拡張 | 大多数のB2B/B2C SaaSの標準解 |
| Plus | Essentials + 脅威防御(threat protection):漏洩認証情報の検知・アダプティブ認証・ログエクスポート | 高セキュリティ要件・規制業種 |
ポイント(公式の仕様):
- アクセストークンのクレーム拡張は Essentials 以上。IDトークンのクレーム拡張は全プランで可能。
- パスキー / OTPパスワードレス / メールMFA は Essentials 以上(パスキーは Lite 以外で利用可)。
- 旧称「高度なセキュリティ機能(Advanced Security Features)」は 「脅威防御(threat protection)」へ製品名が変更。ただし API のenumは従来どおり
AdvancedSecurityMode(OFF/AUDIT/ENFORCED) のままです。ENFORCEDを設定するとプランは自動的にPLUSへ引き上げられます。
マネージドログイン vs クラシック Hosted UI
| マネージドログイン(新) | クラシック Hosted UI(旧・第一世代) | |
|---|---|---|
| ブランディング | ノーコードのビジュアルエディタ(ロゴ・配色・角丸・ライト/ダーク) | ロゴ + CSS ファイルのみ |
| パスキーサインイン | ✅ 対応 | ❌ 非対応 |
| 必要プラン | Essentials / Plus | Lite でも可 |
| 多言語化 | ✅ | ❌ |
エンドポイントのパス(/oauth2/authorize、/oauth2/idpresponse、/saml2/idpresponse、/saml2/logout、/logout)は両者で共通です。新規プールは既定でマネージドログインになります。なお公式はクラシック Hosted UI を「非推奨(deprecated)」とは明言していませんが、「第一世代」と位置づけ、パスキー等の新機能は提供されません。新規実装はマネージドログイン一択です。
出典: Managed login
認証方式の選び方:3つの軸で判断する
Cognitoの認証は大きく3系統あります。まずここを選び違えないことが最重要 です。要望別の早見表を用意しました。
| 要望・場面 | 選ぶべき方式 | Cognitoの実装手段 |
|---|---|---|
| 顧客企業のIdP(Azure AD/Okta/ADFS)に認証を委譲したい | フェデレーション | SAML / OIDC IdP連携 |
| 自社でユーザーを持ち、パスワードを廃したい(パスキー・OTP) | パスワードレス | USER_AUTH(選択式サインイン) |
| 独自のチャレンジ(CAPTCHA・秘密の質問・外部リスクエンジン照合)を挟みたい | カスタム認証フロー | CUSTOM_AUTH + 3つのLambdaトリガー |
重要な判断ポイント:
- OTPやパスキーが目的なら、もう
CUSTOM_AUTHを自作しない。 2024年以降はUSER_AUTH(EMAIL_OTP/SMS_OTP/WEB_AUTHN)が公式機能として提供され、Lambdaの自前実装が不要になりました。CUSTOM_AUTHは「公式機能で表現できない独自チャレンジ」専用と考えるべきです。 USER_AUTHはCUSTOM_AUTHを廃止しません。 両者は共存します。置き換わったのは「OTP/パスキーをCUSTOM_AUTHで手組みする旧来のユースケース」だけです。- B2B SaaSの現実解は 「フェデレーション(企業顧客)+ パスワードレス or パスワード(個人・小規模顧客)」のハイブリッド。本記事の後半で、ログイン画面でこれを出し分ける設計を示します。
SAML vs OIDC:どちらで連携するか
| 項目 | SAML 2.0 | OIDC (OpenID Connect) |
|---|---|---|
| 仕様 | XMLベース | JSON + OAuth 2.0 |
| 複雑性 | 高い | 低い |
| モバイル対応 | 困難 | 容易 |
| 主な採用 | Azure AD(エンタープライズアプリ), Okta, ADFS | Google, Okta(OIDC App), 各種モダンIdP |
| Cognito対応 | ✅ ProviderType: SAML | ✅ ProviderType: OIDC(汎用)/ Google 等 |
判断基準:
- SAML:顧客がレガシー(ADFS等)を使う、またはAzure ADで「エンタープライズアプリケーション」として登録するポリシーがある場合。
- OIDC:モダンIdP、モバイル対応、実装のシンプルさ重視。Oktaは両対応なので、どちらでも可。
- 現実:顧客のIdP環境に合わせて両方サポートできる設計にしておくのが理想(Cognitoは1つのユーザープールに複数IdPを同居できます)。
実装例1:Azure AD / Microsoft Entra ID(SAML)統合
システム全体像
[ユーザー] → [SaaSフロント(Next.js/Amplify)]
→ マネージドログインへリダイレクト
→ [Cognito User Pool] が SAMLRequest 生成
→ [顧客IdP: Azure AD] で認証 + SAML Assertion
→ [Cognito] が ACS (/saml2/idpresponse) で受領 → ユーザー自動作成 → JWT発行
→ [SaaSバック] が JWT を aws-jwt-verify で検証 → テナントID抽出 → API認可
ステップ1:Cognito側の「SP情報」をIdPに登録する
Azure AD側で必要になるCognitoの値は、公式に決まった2つのパターン です。Cognitoは独自のSPメタデータURLを公開していません ので、以下を手動で登録します(ここを誤解して /saml2/metadata のようなURLを探すと時間を溶かします)。
| Azure ADの項目 | 設定値 |
|---|---|
| 識別子(エンティティID / Audience) | urn:amazon:cognito:sp:<userPoolId> |
| 応答URL(Assertion Consumer Service) | https://<your-domain>.auth.<region>.amazoncognito.com/saml2/idpresponse |
| サインオンURL | https://<your-app>/login |
| ログアウトURL(SLO使用時) | https://<your-domain>.auth.<region>.amazoncognito.com/saml2/logout |
Azure ADの「アプリのフェデレーション メタデータ URL」(https://login.microsoftonline.com/<tenant-id>/federationmetadata/2007-06/federationmetadata.xml)をコピーしておきます。これをCognitoに渡します。
ステップ2:Cognito User Pool(Terraform / 2026年版)
# --- User Pool 本体(料金プランを明示)---
resource "aws_cognito_user_pool" "main" {
name = "my-saas-user-pool"
# 2024/11 導入: 機能プラン。アクセストークン拡張・パスワードレスを使うなら ESSENTIALS 以上。
user_pool_tier = "ESSENTIALS"
# マルチテナント用カスタム属性(IdPから受け取るテナントID)
schema {
name = "tenant_id"
attribute_data_type = "String"
mutable = true # IdP マッピング属性は mutable 必須
string_attribute_constraints {
min_length = 1
max_length = 256
}
}
auto_verified_attributes = ["email"]
# SSO主体でもローカルユーザー用にポリシーは定義しておく
password_policy {
minimum_length = 12
require_lowercase = true
require_uppercase = true
require_numbers = true
require_symbols = true
}
account_recovery_setting {
recovery_mechanism {
name = "verified_email"
priority = 1
}
}
}
# --- SAML Identity Provider(Azure AD)---
resource "aws_cognito_identity_provider" "azure_ad_saml" {
user_pool_id = aws_cognito_user_pool.main.id
provider_name = "AzureAD" # supported_identity_providers と app側の指定で使う名前
provider_type = "SAML"
provider_details = {
MetadataURL = "https://login.microsoftonline.com/<tenant-id>/federationmetadata/2007-06/federationmetadata.xml"
# シングルログアウト(IdP起点・SP起点)を有効化する場合
IDPSignout = "true"
# Cognito が送る SAMLRequest の署名アルゴリズム(推奨)
RequestSigningAlgorithm = "rsa-sha256"
}
# SAMLクレーム → Cognito属性 のマッピング
attribute_mapping = {
email = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
given_name = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname"
family_name = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname"
"custom:tenant_id" = "http://schemas.microsoft.com/identity/claims/tenantid"
}
}
# --- ドメイン(マネージドログイン)---
resource "aws_cognito_user_pool_domain" "main" {
domain = "my-saas-domain"
user_pool_id = aws_cognito_user_pool.main.id
}
# --- App Client ---
resource "aws_cognito_user_pool_client" "web_client" {
name = "web-client"
user_pool_id = aws_cognito_user_pool.main.id
allowed_oauth_flows = ["code"] # 認可コードフロー(推奨)
allowed_oauth_scopes = ["openid", "email", "profile"]
allowed_oauth_flows_user_pool_client = true
callback_urls = ["https://my-app.com/auth/callback"]
logout_urls = ["https://my-app.com/logout"]
supported_identity_providers = ["AzureAD", "COGNITO"]
# 認証フロー(後述の USER_AUTH を使う場合は ALLOW_USER_AUTH を追加)
explicit_auth_flows = ["ALLOW_REFRESH_TOKEN_AUTH", "ALLOW_USER_SRP_AUTH"]
# トークン有効期限(単位を必ず明示する。既定は hours だが意図を明確に)
access_token_validity = 1
id_token_validity = 1
refresh_token_validity = 30
token_validity_units {
access_token = "hours"
id_token = "hours"
refresh_token = "days"
}
}
公式ドキュメントとの整合(重要):
SLORedirectBindingURI/SSORedirectBindingURI/ActiveEncryptionCertificateは Describe(取得)時のレスポンス専用フィールド であり、CreateIdentityProviderの入力には指定できません。古い記事でこれらをprovider_detailsに書いている例がありますが、APIエラーになります。 出典: CreateIdentityProvider API / SAML IdP
属性マッピングの落とし穴(公式準拠)
NameIDは「一意かつ大文字小文字を区別する」フェデレーション識別子 です。可変なemailではなく、安定した不変属性(Azure ADならobjectid/http://schemas.microsoft.com/identity/claims/objectidentifier)から導出するのが鉄則。emailにマッピングすると、ユーザーのメール変更でアカウントが分裂します。- マッピングしたemailは既定で「未検証(unverified)」扱い。検証必須のフローでは注意。
- カスタム属性は mutable かつ writable でないとマッピングできません。
実装例2:OIDC統合(Google / Okta)
Google(専用プロバイダ)
resource "aws_cognito_identity_provider" "google_oidc" {
user_pool_id = aws_cognito_user_pool.main.id
provider_name = "Google"
provider_type = "Google"
provider_details = {
client_id = var.google_oauth_client_id
client_secret = var.google_oauth_client_secret
authorize_scopes = "openid email profile" # openid は必須
}
attribute_mapping = {
email = "email"
given_name = "given_name"
family_name = "family_name"
username = "sub"
}
}
Google Cloud Console側の「承認済みリダイレクトURI」には次を登録します:
https://<your-domain>.auth.<region>.amazoncognito.com/oauth2/idpresponse
汎用OIDC(Okta など)— issuer自動探索を使う
汎用OIDCプロバイダ(OktaのOIDCアプリ等)は、oidc_issuer を渡せば .well-known/openid-configuration から authorize/token/userInfo/jwks の各エンドポイントが自動探索 されます。エンドポイントを一つずつ書く必要はありません。
resource "aws_cognito_identity_provider" "okta_oidc" {
user_pool_id = aws_cognito_user_pool.main.id
provider_name = "Okta"
provider_type = "OIDC"
provider_details = {
client_id = var.okta_client_id
client_secret = var.okta_client_secret
authorize_scopes = "openid email profile" # openid 必須
oidc_issuer = "https://<your-org>.okta.com"
attributes_request_method = "GET" # userInfo の取得メソッド
}
attribute_mapping = {
email = "email"
given_name = "given_name"
family_name = "family_name"
username = "sub"
}
}
公式準拠の注意点(多くの記事が省略):
- Cognitoは
client_secret_postを要求し、client_secret_basicには非対応。IdP側のクライアント認証方式を合わせてください。oidc_issuerはhttps://始まりで、末尾スラッシュは付けない。- エンドポイントはHTTPS、ポートは 80/443 のみ。
sub → usernameは自動マッピング(usernameにプロバイダ名が前置されます)。出典: OIDC IdP
カスタム認証フロー(CUSTOM_AUTH):3つのLambdaトリガー
タイトルの核心、カスタム認証フロー です。「CAPTCHA」「秘密の質問」「自社の不正検知エンジンへの照合」など、Cognitoの標準機能で表現できない独自チャレンジ を挟みたいときに使います。
仕組みは 3つのLambdaトリガーが連携してチャレンジのループを回す だけです。InitiateAuth(AuthFlow=CUSTOM_AUTH) で開始し、RespondToAuthChallenge で回答していきます。
InitiateAuth(CUSTOM_AUTH)
│
▼
DefineAuthChallenge ──「次は何のチャレンジ?/合否は?」を判断(司令塔)
│ challengeName = CUSTOM_CHALLENGE
▼
CreateAuthChallenge ──チャレンジの中身を生成(OTPコード生成&送信など)
│ publicChallengeParameters(クライアントへ)/ privateChallengeParameters(答え)
▼
(クライアントが RespondToAuthChallenge で ANSWER を送信)
│
▼
VerifyAuthChallengeResponse ──答え合わせ(answerCorrect: true/false)
│
▼
DefineAuthChallenge ──結果を見て「トークン発行」or「もう一度」or「失敗」を決定
① DefineAuthChallenge(司令塔)
event.request.session に過去のチャレンジ結果(ChallengeResult)の配列が入ります。これを見て次を決めます。issueTokens=true で成功(JWT発行)、failAuthentication=true で失敗、両方falseで継続です。
# define_auth_challenge.py
def lambda_handler(event, context):
session = event["request"]["session"]
if len(session) == 0:
# 初回: 独自チャレンジを提示
event["response"]["challengeName"] = "CUSTOM_CHALLENGE"
event["response"]["issueTokens"] = False
event["response"]["failAuthentication"] = False
elif (
len(session) == 1
and session[0]["challengeName"] == "CUSTOM_CHALLENGE"
and session[0]["challengeResult"] is True
):
# 1回正解したらトークン発行
event["response"]["issueTokens"] = True
event["response"]["failAuthentication"] = False
else:
# それ以外は失敗(無限ループ・総当たり防止)
event["response"]["issueTokens"] = False
event["response"]["failAuthentication"] = True
return event
② CreateAuthChallenge(チャレンジ生成)
challengeName === "CUSTOM_CHALLENGE" のときだけ呼ばれます。publicChallengeParameters はクライアントに渡る公開情報、privateChallengeParameters は検証用の答え(クライアントには渡らない) です。
# create_auth_challenge.py
import secrets
import boto3
ses = boto3.client("ses")
def lambda_handler(event, context):
if event["request"]["challengeName"] != "CUSTOM_CHALLENGE":
return event
# 例: 6桁のワンタイムコードを生成して送信(独自のCAPTCHA等でも同様)
code = f"{secrets.randbelow(1_000_000):06d}"
email = event["request"]["userAttributes"]["email"]
ses.send_email(
Source="no-reply@my-saas.com",
Destination={"ToAddresses": [email]},
Message={
"Subject": {"Data": "ログイン確認コード"},
"Body": {"Text": {"Data": f"確認コード: {code}(5分間有効)"}},
},
)
# クライアントに見せてよい情報のみ public へ
event["response"]["publicChallengeParameters"] = {"medium": "EMAIL"}
# 答えは private(応答には絶対に含めない)
event["response"]["privateChallengeParameters"] = {"answer": code}
event["response"]["challengeMetadata"] = "EMAIL_ONE_TIME_CODE"
return event
③ VerifyAuthChallengeResponse(答え合わせ)
# verify_auth_challenge.py
def lambda_handler(event, context):
expected = event["request"]["privateChallengeParameters"]["answer"]
provided = event["request"]["challengeAnswer"]
event["response"]["answerCorrect"] = secrets_equal(expected, provided)
return event
def secrets_equal(a: str, b: str) -> bool:
# タイミング攻撃を避けるため定数時間比較
import hmac
return hmac.compare_digest(a, b)
設計上の判断:上記は「メールOTP」を例にしていますが、単なるメール/SMSのOTPなら自作せず後述の
USER_AUTH(EMAIL_OTP/SMS_OTP)を使うべき です。CUSTOM_AUTHの出番は「reCAPTCHA検証」「自社リスクスコアAPI照合」「デバイス信頼度チェック」など、公式機能に無いロジックを挟むときに限定しましょう。 出典: Custom authentication challenge Lambda triggers
USER_AUTH:選択式・パスワードレス認証(パスキー / OTP)
2024年後半に追加された USER_AUTH は、「パスワード」「ワンタイムコード(メール/SMS)」「パスキー(WebAuthn)」を ユーザーに選ばせる(choice-based) 公式フローです。OTPやパスキーのためにLambdaを書く時代は終わりました。
必要な設定
| 設定箇所 | 値 |
|---|---|
| ユーザープールのプラン | Essentials 以上 |
App Client の explicit_auth_flows | ALLOW_USER_AUTH を追加 |
ユーザープールの sign_in_policy.allowed_first_auth_factors | PASSWORD / EMAIL_OTP / SMS_OTP / WEB_AUTHN から選択 |
resource "aws_cognito_user_pool" "main" {
# ... 既出の設定 ...
user_pool_tier = "ESSENTIALS"
# 第一認証要素として許可する方式(WEB_AUTHN は他要素と併用が必須)
sign_in_policy {
allowed_first_auth_factors = ["PASSWORD", "EMAIL_OTP", "WEB_AUTHN"]
}
}
resource "aws_cognito_user_pool_client" "web_client" {
# ... 既出の設定 ...
explicit_auth_flows = [
"ALLOW_USER_AUTH", # 選択式サインインを有効化
"ALLOW_REFRESH_TOKEN_AUTH",
]
}
フローの仕組み
InitiateAuth(AuthFlow=USER_AUTH)をUSERNAME付きで呼ぶ。PREFERRED_CHALLENGEを指定しない場合、CognitoはChallengeName: SELECT_CHALLENGEとAvailableChallenges(利用可能な方式の配列) を返す。- クライアントは
SELECT_CHALLENGEに対し、選んだ方式をANSWERに入れて応答。 - 以降は方式ごとのチャレンジ(
EMAIL_OTPならEMAIL_OTP_CODE、WEB_AUTHNならCREDENTIAL)に答える。
| 方式 | チャレンジ名 | 回答キー |
|---|---|---|
| パスキー(WebAuthn) | WEB_AUTHN | CREDENTIAL(W3C AuthenticationResponseJSON) |
| メールOTP | EMAIL_OTP | EMAIL_OTP_CODE |
| SMS OTP | SMS_OTP | SMS_OTP_CODE |
| パスワード | PASSWORD / PASSWORD_SRP | — |
補足:パスキーは ES256 + RS256 に対応し、1ユーザーあたり最大20個まで登録可。登録状況は
GetUserAuthFactorsで確認できます。第一要素のOTP(EMAIL_OTP/SMS_OTP)は、第二要素のMFAコード(SMS_MFA/EMAIL_MFA/SOFTWARE_TOKEN_MFA)とは 別物 である点に注意。 出典: Authentication flows (USER_AUTH / choice-based)
フロントエンド(AWS Amplify v6 / Gen 2)
Amplifyはv6で名前空間APIに刷新されました(旧 Auth.signIn 等は廃止)。USER_AUTH を使うパスワードレスはこう書きます。
// パスワードレス・サインイン(メールOTPを選好)
import { signIn, confirmSignIn } from "aws-amplify/auth";
async function startPasswordless(username: string) {
const { nextStep } = await signIn({
username,
options: { authFlowType: "USER_AUTH", preferredChallenge: "EMAIL_OTP" },
});
if (nextStep.signInStep === "CONFIRM_SIGN_IN_WITH_EMAIL_CODE") {
// ユーザーがメールで受け取ったコードを入力 → confirmSignIn で送信
}
}
async function submitOtp(code: string) {
const { isSignedIn } = await confirmSignIn({ challengeResponse: code });
return isSignedIn;
}
// パスキー(WebAuthn)の登録(ログイン後に呼ぶ)
import { associateWebAuthnCredential } from "aws-amplify/auth";
async function registerPasskey() {
// ブラウザのWebAuthn APIをAmplifyが内部で起動し、資格情報をCognitoに登録
await associateWebAuthnCredential();
}
マルチテナント設計:顧客ごとにIdPを出し分ける
課題
- 顧客A → Azure AD(SAML)、顧客B → Okta(OIDC)、顧客C → Google、個人ユーザー → パスワードレス。
- 1つのログイン画面で、入力メールに応じて適切な認証方式に誘導したい。
解決策:メールドメインでテナント判定 → IdPへ誘導
// LoginPage.tsx(Amplify v6 / Next.js)
import { signInWithRedirect, signIn } from "aws-amplify/auth";
import { useState } from "react";
type Tenant = { id: string; name: string; idp: "AzureAD" | "Google" | "Okta" | null };
export function LoginPage() {
const [email, setEmail] = useState("");
const [tenant, setTenant] = useState<Tenant | null>(null);
// メールドメインからテナントを判定
async function detectTenant(value: string) {
const res = await fetch("/api/detect-tenant", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: value }),
});
setTenant((await res.json()).tenant);
}
// 企業SSO(フェデレーション)へ
async function loginWithSSO() {
if (!tenant?.idp) return;
await signInWithRedirect({ provider: { custom: tenant.idp } });
}
// 個人ユーザーはパスワードレスへ
async function loginPasswordless() {
await signIn({
username: email,
options: { authFlowType: "USER_AUTH", preferredChallenge: "EMAIL_OTP" },
});
}
return (
<form
onSubmit={(e) => {
e.preventDefault();
tenant?.idp ? loginWithSSO() : loginPasswordless();
}}
>
<label htmlFor="email">メールアドレス</label>
<input
id="email"
type="email"
autoComplete="email webauthn"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={() => detectTenant(email)}
/>
<button type="submit">
{tenant?.idp ? `${tenant.name} のSSOでログイン` : "確認コードでログイン"}
</button>
</form>
);
}
アクセシビリティの要点:
labelとinputをhtmlFor/idで関連付け、autoComplete="email webauthn"を付けると、対応ブラウザがパスキーをオートフィル候補に出します。送信は<form>のonSubmitで受けると Enter キー操作にも対応できます。
テナント検出API(FastAPI)
# api/detect_tenant.py
from fastapi import APIRouter
from pydantic import BaseModel, EmailStr
import boto3
router = APIRouter()
tenants = boto3.resource("dynamodb").Table("tenants")
class Req(BaseModel):
email: EmailStr
@router.post("/api/detect-tenant")
async def detect_tenant(req: Req):
domain = req.email.split("@")[1]
res = tenants.query(
IndexName="domain-index",
KeyConditionExpression="#d = :d",
ExpressionAttributeNames={"#d": "domain"},
ExpressionAttributeValues={":d": domain},
)
if not res["Items"]:
return {"tenant": None} # 未登録 → 個人ユーザー扱い
t = res["Items"][0]
return {"tenant": {"id": t["tenant_id"], "name": t["company_name"], "idp": t.get("idp")}}
セキュリティ補足:テナント検出APIは「このドメインにIdPがあるか」を返すだけに留め、ユーザーの存在有無を漏らさない(user enumeration対策)。Cognito側も
PreventUserExistenceErrors=ENABLEDを設定しておきます。
トークンへテナントを注入する:Pre Token Generation V2.0
マルチテナントの肝は 「アクセストークンに tenant_id を載せ、バックエンドの認可で使う」 ことです。フェデレーションで来たユーザーは属性マッピングで custom:tenant_id を持ちますが、アクセストークンのクレーム拡張は Pre Token Generation トリガーの V2.0 以降(Essentials以上) が必要です。
| バージョン | 対象トークン | 必要プラン |
|---|---|---|
V1_0 | IDトークンのみ | 全プラン(Liteも可) |
V2_0 | ID + アクセストークン(ユーザーID) | Essentials / Plus |
V3_0 | ID + アクセス(M2M クライアントクレデンシャル含む) | Essentials / Plus |
resource "aws_cognito_user_pool" "main" {
# ... 既出の設定 ...
lambda_config {
pre_token_generation_config {
lambda_arn = aws_lambda_function.pre_token.arn
lambda_version = "V2_0" # アクセストークンに載せるなら V2_0 以上
}
}
}
# pre_token_generation.py (V2_0)
def lambda_handler(event, context):
attrs = event["request"]["userAttributes"]
tenant_id = attrs.get("custom:tenant_id", "")
event["response"]["claimsAndScopeOverrideDetails"] = {
# アクセストークンにテナントIDとスコープを注入
"accessTokenGeneration": {
"claimsToAddOrOverride": {"tenant_id": tenant_id},
"scopesToAdd": [f"tenant/{tenant_id}"],
},
# 必要ならIDトークンにも
"idTokenGeneration": {
"claimsToAddOrOverride": {"tenant_id": tenant_id},
},
# cognito:groups を上書きしてテナント別ロールを反映することも可能
# "groupOverrideDetails": {"groupsToOverride": [f"tenant-{tenant_id}-member"]},
}
return event
公式の制約(事故りやすい点):
sub/iss/token_use/cognito:*等の 予約クレームは追加・変更・抑制できません。- 例外的に、アクセストークンへ
audを追加することは可能ですが、その値はevent.callerContext.clientIdと一致している必要があります。groupOverrideDetailsを空/nullにするとcognito:groupsが 抑制(消える) されるので、上書き時は全グループを明示すること。- V1.0コンテナ名は
claimsOverrideDetails、V2.0以降はclaimsAndScopeOverrideDetailsと 名前が異なります。
JWT検証:公式推奨の aws-jwt-verify を使う
CognitoのJWTは RS256 で署名され、検証では以下を必ずチェックします。手書きのJWKSパースは事故の温床なので、AWS公式が推奨する aws-jwt-verify を使う のが2026年の標準です。
| 検証項目 | 値 |
|---|---|
iss | https://cognito-idp.<region>.amazonaws.com/<userPoolId> |
| 対象クライアント | IDトークンは aud、アクセストークンは client_id(どちらもApp Client ID) |
token_use | "id" または "access"(用途に合致しているか) |
exp | 失効していないか |
alg / kid | RS256 / JWKSの鍵IDと一致 |
注:Cognitoは1プールにつき RSA鍵ペアを2つ(アクセス用・ID用)持ちます。
kidでキャッシュし、未知のkidが来たらJWKSを再取得する設計に。aws-jwt-verifyはこれを自動で行います。
TypeScript(バックエンド / Next.js Route Handler や Lambda)
import { CognitoJwtVerifier } from "aws-jwt-verify";
// アクセストークン用検証器(client_id と token_use を自動チェック)
const verifier = CognitoJwtVerifier.create({
userPoolId: process.env.COGNITO_USER_POOL_ID!,
tokenUse: "access",
clientId: process.env.COGNITO_CLIENT_ID!,
});
export async function authorize(authHeader: string | null) {
const token = authHeader?.replace(/^Bearer\s+/i, "");
if (!token) throw new Response("Unauthorized", { status: 401 });
try {
const payload = await verifier.verify(token); // 署名 + iss + exp + client_id + token_use
return {
userId: payload.sub,
tenantId: payload["tenant_id"] as string | undefined, // Pre Token Gen で注入
groups: (payload["cognito:groups"] as string[] | undefined) ?? [],
};
} catch {
throw new Response("Invalid token", { status: 401 });
}
}
Python(FastAPI など)
# middleware/auth.py
from fastapi import HTTPException, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import jwt
from jwt import PyJWKClient
import os
security = HTTPBearer()
REGION = os.environ["COGNITO_REGION"]
POOL_ID = os.environ["COGNITO_USER_POOL_ID"]
CLIENT_ID = os.environ["COGNITO_CLIENT_ID"]
ISSUER = f"https://cognito-idp.{REGION}.amazonaws.com/{POOL_ID}"
jwks = PyJWKClient(f"{ISSUER}/.well-known/jwks.json")
def verify_access_token(creds: HTTPAuthorizationCredentials = Security(security)) -> dict:
token = creds.credentials
try:
key = jwks.get_signing_key_from_jwt(token).key
payload = jwt.decode(token, key, algorithms=["RS256"], issuer=ISSUER)
# アクセストークンは aud を持たないため client_id / token_use を手動検証
if payload.get("token_use") != "access" or payload.get("client_id") != CLIENT_ID:
raise jwt.InvalidTokenError("token_use / client_id mismatch")
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token expired")
except jwt.InvalidTokenError as e:
raise HTTPException(status_code=401, detail=f"Invalid token: {e}")
トラブルシューティング
SAML Assertion 署名検証失敗
Invalid SAML response: Signature validation failed
- 原因:IdP側の署名証明書がローテーションされた/CognitoのメタデータURLが古い。
- 対処:
MetadataURLを使っていれば再terraform applyで最新化。手動メタデータ(MetadataFile)運用なら証明書を差し替える。MetadataURL運用を強く推奨(証明書の自動追従)。
属性が空 / email が無いと言われる
InvalidParameterException: User does not have a valid email
- 原因:SAML AssertionにマッピングしたクレームURIが実際の送信内容と違う。
- デバッグ:ブラウザ拡張「SAML-tracer」で実Assertionを確認 → どのクレーム名で来ているかを見て
attribute_mappingを修正。Azure ADはデフォルトで.../emailaddressを使うが、設定により.../nameのこともある。
リダイレクトループ
- 原因:
callback_urlsとフロントの設定が不一致。 - 対処:Amplify v6の設定で
redirectSignInを 完全一致 させる。
import { Amplify } from "aws-amplify";
Amplify.configure({
Auth: {
Cognito: {
userPoolId: "ap-northeast-1_XXXXXXXXX",
userPoolClientId: "xxxxxxxxxxxxxxxxxxxx",
loginWith: {
oauth: {
domain: "my-saas-domain.auth.ap-northeast-1.amazoncognito.com",
scopes: ["openid", "email", "profile"],
redirectSignIn: ["https://my-app.com/auth/callback"], // 末尾スラッシュ含め完全一致
redirectSignOut: ["https://my-app.com/logout"],
responseType: "code",
},
},
},
},
});
SAML/OIDC の攻撃面と防御:Cognito は何を守り、あなたは何を守るか
フェデレーションの信頼はすべて「署名付きアサーション」に乗っています。裏を返せば、アサーションを偽造・すり替え・再利用できれば、なりすましログインが成立します。ここでは代表的な攻撃面を、OWASPのチートシート(SAML Security / XXE Prevention / OAuth)に沿って整理し、**「Cognitoを使う場合にAWSが担う部分」と「それでもあなたに残る責任」**を切り分けます。責任分界を誤解すると、「マネージドだから安全」と過信して残存リスクを放置することになります。
SAML 側の代表的な攻撃
| 攻撃 | 何が起きるか | 防御の原則 |
|---|---|---|
| XML Signature Wrapping(XSW) | 正規の署名付き要素は残したまま、偽の Assertion を XML の別位置に「包んで」注入。署名は正規要素に対して有効なのに、処理系が偽要素を読むズレを突く | 署名を検証した「まさにその要素」だけを処理する。堅牢なSAMLライブラリ+スキーマ検証。手組みのXMLパースは厳禁 |
| XXE(XML外部実体注入) | SAMLのXML解析時に外部実体を展開させ、ファイル読み出しやSSRFを誘発 | XMLパーサでDTD・外部実体を無効化する(OWASP XXE Prevention Cheat Sheet) |
| 署名スコープ外のAssertionを信頼(署名の被覆不足) | 署名が「信頼するその Assertion」を覆っていないのに信頼すると、別の(偽の)Assertion にすり替えられる(XSW/置換の本質) | 各 Assertion または Response 全体を署名し、署名の <ds:Reference URI> が消費する <saml:Assertion> を確かに覆っていることを検証。Response のみ署名する場合も、その署名が消費 Assertion を覆うことを確認 |
| リプレイ(再利用) | 傍受した正規アサーションを使い回す | Conditions の NotBefore/NotOnOrAfter を厳格に評価し、アサーションIDを一度きりに(InResponseTo 検証・ID キャッシュ) |
| Audience 誤り / Recipient 混同 | 別SP向けのアサーションを転用 | AudienceRestriction が自SPのエンティティID(urn:amazon:cognito:sp:<poolId>)と一致することを検証。ACS(Recipient) の一致も確認 |
Cognitoを SP として使う場合の責任分界:XSW・XXE・署名検証・Conditions/Audience の評価といった**「SP側のアサーション処理」はAWS(Cognito)が実装・運用**します。あなたがSAMLライブラリを手で書くわけではありません。それでもあなたに残る責任は次のとおりで、ここが実務の勘所です。
MetadataURL運用で IdP の署名証明書ローテーションに自動追従する(手動MetadataFileは失効事故の温床)。NameIDは不変・一意な識別子(Azure ADならobjectid)にマッピングする。可変なemailにするとメール変更でアカウントが分裂し、アカウント乗っ取りの温床になる。- マッピングした email は既定で未検証。検証必須のフローでは扱いに注意。
IDPSignout/ SLO を使うならsessionIndexの一致まで含めて挙動を検証する。
OIDC / OAuth2 側の代表的な攻撃
| 攻撃 | 何が起きるか | 防御の原則 |
|---|---|---|
| CSRF(stateなし) | 攻撃者のコードを被害者のセッションに紐付け | 認可リクエストに**state** を付与し、コールバックで一致検証(RFC 6749) |
| IDトークン・リプレイ | 別の認可で得たIDトークンを流用 | nonce を認可リクエストに含め、IDトークンの nonce と突合(OIDC Core) |
| 認可コード横取り(特にSPA/モバイル) | リダイレクト経由でコードを傍受し交換 | PKCE(RFC 7636) を使う(code_challenge/code_verifier) |
| Mix-Up 攻撃(複数IdP構成) | どのIdP(認可サーバ)が応答したかを取り違え | 応答元 issuer を厳格に識別(RFC 9207 の iss 応答パラメータを期待値と突合) |
| トークン置換 / audience 誤用 | 別クライアント向けトークンを転用 | aud/client_id・token_use・iss・exp・署名(RS256/kid)を検証(Node.jsは aws-jwt-verify、他言語は信頼できるJWTライブラリに委譲) |
Cognitoを使う場合の責任分界:Cognito は**認可コードフロー(code)**を提供し、state/nonce/PKCE をサポートします。IDトークン・アクセストークンは RS256 で署名されます。あなたに残る責任は、
- アプリ↔Cognito の OAuth レグで 認可コードフロー + PKCE を使い、Implicit フローは使わない。
- コールバックで
stateを検証(多くのSDKは自動だが、自前実装なら必須)。 - API 認可ではアクセストークンを検証し(Node.jsは
aws-jwt-verify、他言語は信頼できるJWTライブラリ)、token_use/client_idを必ず突合する(前章のコード)。 PreventUserExistenceErrors=ENABLEDでユーザー列挙を防ぐ(API/IaC 作成時の既定はLEGACY=無効なので、明示的に有効化する)。- トークン有効期限を短く保ち、
token_validity_unitsを明示する。
核心の一言:マネージドサービスは「規格レベルの攻撃(XSW/XXE/署名偽造)」を肩代わりしてくれますが、「設定と識別子設計のミス(可変NameID・未署名許容・Implicit・state欠落・長すぎるトークン寿命)」はあなたの責任のまま残ります。侵入の大半は前者ではなく後者から起きます。
セキュリティ・運用のチェックリスト
- 認可コードフロー(
code)+ PKCE を使う(Implicitは使わない)。 PreventUserExistenceErrors=ENABLED:ユーザー列挙攻撃を防ぐ。- 脅威防御(threat protection / Plus):漏洩認証情報の検知・アダプティブ認証。API上は
AdvancedSecurityMode=ENFORCED(設定するとプランは自動でPLUS)。 - トークン有効期限:アクセス/IDは短く(例:1時間)、リフレッシュは要件に応じて。
token_validity_unitsを必ず明示。 - グローバルログアウト:
signOut({ global: true })で全デバイスのリフレッシュトークンを失効。 - SAML SLO:
IDPSignout=true設定時、IdPのSingleLogoutServiceへ署名付きSAMLRequestが飛び、sessionIndexの一致が必要。
コスト試算:2026年の料金プラン前提で考える
旧来の「最初の50,000 MAUは無料/Advanced Securityは+$0.05/MAU」という説明は 現在のプラン制では不正確 です。現在は 選んだプラン(Lite/Essentials/Plus)× MAU で課金されます(Lite < Essentials < Plus)。
判断の指針:
- 個人/小規模中心、UIは最小で良い → Lite。ただしパスキー・パスワードレス・アクセストークン拡張は不可。
- B2B SaaSの標準(マネージドログイン・パスワードレス・テナント注入が欲しい) → Essentials。本記事の構成はこれが前提。
- 規制業種・高セキュリティ(漏洩検知・アダプティブ認証・ログ輸出) → Plus。
正確な単価はリージョン・時期で変わるため、必ず公式の料金ページで最新値を確認 してください。 出典: Amazon Cognito Pricing
Lambdaトリガー(PreSignUp / Pre Token Generation / カスタム認証3種)は通常、無料枠〜ごく少額に収まります(ログインのたびに数十〜百ms程度の実行)。
内製 vs 専業SaaS vs Cognito:認証基盤の意思決定フレーム
「そもそも Cognito で自前実装すべきか、Auth0 や WorkOS のような専業を買うべきか」——エンタープライズ案件で必ず問われる分岐です。認証は一度作ると差し替えコストが極めて高い基盤なので、ここでの判断が数年効きます。技術的な優劣ではなく、5つの軸で自社の状況に当てはめて決めます。
| 判断軸 | Cognito(AWSネイティブで内製)が有利 | 専業SaaS(Auth0/Okta・WorkOS 等)が有利 |
|---|---|---|
| スケール時のコスト | MAUが増えるほど単価優位を出しやすい | 小〜中規模で立ち上げ速度を買う |
| AWS親和性・IaC | 既にAWS/Terraform中心。VPC・IAM・Lambdaと地続きにしたい | クラウド非依存で使いたい |
| エンタープライズ機能 | 標準機能で足りる要件 | SCIM による自動ユーザープロビジョニングや、顧客IdP接続を大量・高速に増やす管理UI/ディレクトリ同期が要る |
| チームの習熟 | Cognitoの癖(プラン制・トリガー・属性マッピング)を扱える | 認証に人手を割かず、DXの良い抽象に任せたい |
| 市場投入速度 | 設計を作り込む時間がある | 「来月のエンタープライズ商談まで」に間に合わせたい |
実務での勘所:
- Cognito は AWS ネイティブでコスト効率と IaC 親和性に優れる一方、外部IdPからの SCIM による自動ユーザープロビジョニング(入社・退職の自動反映)はユーザープールにネイティブ機能が無く、大企業の要件では追加設計(API Gateway + Lambda 等)が要ることがあります。ここが「買う」判断の分岐点になりやすいポイントです。
- WorkOS のような**「SSO/SCIM をAPIで売る」専業**は、B2Bの「エンタープライズ対応を最短で」というニーズに刺さります。逆に、既にワークロードがAWSに寄っていて長期のコストと統制を重視するなら Cognito の内製が効きます。
- **正解は「どちらか」ではなく「いつ・どの順で」**です。PoC〜初期は素早い選択肢で立ち上げ、スケールと単価が効く段階で AWS ネイティブへ寄せる、という移行設計まで含めて決めるのが、やり直しコストを最小化します。
この意思決定は、要件(顧客IdPの数と種類・SCIM要否・規制・既存スタック)を一枚の表に落として初めて決まります。判断を誤ったまま実装に入ると、前述の「差し替えコスト」を丸ごと払うことになります。迷ったら、実装前に第三者の設計レビューを一度挟むのが最も安い保険です。
まとめ:エンタープライズ認証基盤の設計指針
- まずプランを決める:B2B SaaSの標準は Essentials。これでマネージドログイン・パスワードレス・アクセストークン拡張が揃う。
- 方式を選び違えない:企業顧客はフェデレーション(SAML/OIDC)、個人はパスワードレス(
USER_AUTH)、独自要件のみCUSTOM_AUTH。 - テナント分離はトークンに載せる:Pre Token Generation V2.0 で
tenant_idをアクセストークンへ注入し、バックエンドの認可で一貫して使う。 - 検証は
aws-jwt-verifyに任せる:iss/audorclient_id/token_use/署名を自前で書かない。 - 公式ドキュメントを一次情報にする:Cognitoは更新が速い。本記事の各リンクから最新を確認する習慣を。
設計・運用で得た実感(木材流通DXプロジェクト)
経済産業大臣賞を受賞したB2BサブスクリプションSaaSでは、Cognitoを認証基盤として採用し、RS256でのJWT検証・マルチテナント前提の認可・複数IdPの受け入れを前提に設計しました。経験上、つまずきの大半は「属性マッピングの食い違い」と「プラン/トークン種別の理解不足」 に集約されます。本記事の早見表とチェックリストは、その実地の知見を反映したものです。
次のステップ:企業SSO対応を、確実に前に進める
エンタープライズ顧客からの「SSO対応してほしい」は、受注拡大の合図であると同時に、設計を誤ると数年ひきずる技術的負債の入口でもあります。SAML/OIDCの選定、NameID と属性マッピングの設計、マルチテナントのトークン設計、そして「内製か・専業を買うか」の判断——ここを最初に正しく組めるかで、その後の運用コストが大きく変わります。
私は経済産業大臣賞を受賞したB2BサブスクリプションSaaS(木材流通DX)で、Cognitoによる認証基盤を RS256のJWT検証・マルチテナント前提の認可・複数IdP受け入れを前提に設計・運用しました。同じ壁の越え方を、あなたのプロダクトの文脈に合わせて一気通貫で支援します。
- 企業SSO導入の設計レビュー / 技術顧問:SAML・OIDCの選定、IdP連携、テナント設計、内製 vs 専業の意思決定を、実装前に整理します。
- 実装・移行の伴走:既存のパスワード認証からフェデレーションへの移行、マルチテナント認可の本番投入まで。
- 認可・RLSのセキュリティレビュー:発行後のトークンをバックエンドでどう認可に使うか(テナント分離の検証)まで踏み込みます。
「自社のIdP要件(Azure AD/Okta/Google/SCIM)で、Cognito 内製と専業SaaS のどちらが最適か」を整理したい方は、お気軽にご相談ください。要件を伺った上で、意思決定フレームに沿って一次見解をお返しします。