「エンタープライズ顧客から『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)とトラブルシューティング
まず押さえる: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",
},
},
},
},
});
セキュリティ・運用のチェックリスト
- 認可コードフロー(
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程度の実行)。
よくある質問(FAQ)
Q. CUSTOM_AUTH と USER_AUTH は何が違う?どちらを使う?
A. USER_AUTH はパスキー/OTP/パスワードを「選ばせる」公式のパスワードレス基盤(Essentials以上)。CUSTOM_AUTH は3つのLambdaで独自チャレンジを組む仕組み。OTP・パスキー目的なら USER_AUTH、公式機能に無い独自ロジック(CAPTCHA・外部リスク照合)なら CUSTOM_AUTH。両者は共存可能で、USER_AUTH が CUSTOM_AUTH を廃止したわけではありません。
Q. アクセストークンにテナントIDを入れたいのにクレームが反映されない。
A. アクセストークンのクレーム拡張は Essentials 以上 + Pre Token Generation V2_0 が必要です。Liteや V1_0 ではIDトークンしか拡張できません。
Q. SAMLのSPメタデータURLはどこ?
A. Cognitoは SPメタデータURLを公開していません。エンティティID urn:amazon:cognito:sp:<userPoolId> とACS https://<domain>/saml2/idpresponse を手動でIdPに登録します。
Q. IDトークンとアクセストークン、どちらをAPI認可に使う?
A. API認可はアクセストークン(scope / cognito:groups / client_id を持つ)。IDトークンはユーザー属性の伝達用で、aud を持ちます。検証時は token_use の一致を必ず確認。
Q. Amplifyの Auth.signIn が動かない。
A. Amplify v6で名前空間APIに変更されました。import { signIn } from "aws-amplify/auth" を使い、設定は Amplify.configure({ Auth: { Cognito: {...} } }) 形式です。
まとめ:エンタープライズ認証基盤の設計指針
- まずプランを決める: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の受け入れを前提に設計しました。経験上、つまずきの大半は「属性マッピングの食い違い」と「プラン/トークン種別の理解不足」 に集約されます。本記事の早見表とチェックリストは、その実地の知見を反映したものです。
次のステップ
AWS CognitoでのエンタープライズSSO・パスワードレス認証・マルチテナント認可の設計でお悩みなら、お気軽にご相談ください。SAML/OIDC統合からトークン設計、本番運用の可観測性まで、一気通貫で支援します。
参考(AWS公式ドキュメント):