メインコンテンツへスキップ
友田 陽大
認証・認可
AWS
Cognito
SAML
OIDC
SSO
認証
パスワードレス
Passkey
Azure AD
Okta
Terraform
セキュリティ

AWS Cognito で企業SSOを実装する完全ガイド:SAML/OIDC連携(Azure AD・Okta・Google)とUSER_AUTH選択式認証

AWS Cognito User Poolsで企業SSO(Azure AD/Okta/Google)とパスワードレス認証(パスキー/OTP)を実装する2026年最新ガイド。SAML/OIDC連携、USER_AUTH選択式認証、カスタム認証フローのLambdaトリガー、マルチテナント設計、JWT検証まで公式ドキュメント準拠の実コード付きで解説します。

公開日
読了時間
25分
著者
友田 陽大
シェア
目次

「エンタープライズ顧客から『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月時点)に忠実 に、かつ「どの場面で・どう使うか」を実コード付きで解説します。各セクションには参照した公式ドキュメントのリンクを明記しているので、自分の環境で一次情報を確認しながら実装を進められます。

この記事で扱う範囲

  1. 2026年のCognito全体像(料金プラン・マネージドログイン)
  2. 認証方式の選び方(フェデレーション / パスワードレス / カスタムフロー)
  3. SAML(Azure AD)・OIDC(Google / Okta)連携の実装
  4. カスタム認証フロー(3つのLambdaトリガー)
  5. USER_AUTH 選択式・パスワードレス認証(パスキー / OTP)
  6. マルチテナント設計とトークンへのテナント注入(Pre Token Generation V2.0)
  7. 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の標準解
PlusEssentials + 脅威防御(threat protection):漏洩認証情報の検知・アダプティブ認証・ログエクスポート高セキュリティ要件・規制業種

ポイント(公式の仕様):

  • アクセストークンのクレーム拡張は Essentials 以上。IDトークンのクレーム拡張は全プランで可能。
  • パスキー / OTPパスワードレス / メールMFA は Essentials 以上(パスキーは Lite 以外で利用可)。
  • 旧称「高度なセキュリティ機能(Advanced Security Features)」は 「脅威防御(threat protection)」へ製品名が変更。ただし API のenumは従来どおり AdvancedSecurityModeOFF/AUDIT/ENFORCED のままです。ENFORCEDを設定するとプランは自動的に PLUS へ引き上げられます。

出典: Understanding feature plans / Threat protection

マネージドログイン vs クラシック Hosted UI

マネージドログイン(新)クラシック Hosted UI(旧・第一世代)
ブランディングノーコードのビジュアルエディタ(ロゴ・配色・角丸・ライト/ダーク)ロゴ + CSS ファイルのみ
パスキーサインイン✅ 対応❌ 非対応
必要プランEssentials / PlusLite でも可
多言語化

エンドポイントのパス(/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_AUTHEMAIL_OTP/SMS_OTP/WEB_AUTHN)が公式機能として提供され、Lambdaの自前実装が不要になりました。CUSTOM_AUTH は「公式機能で表現できない独自チャレンジ」専用と考えるべきです。
  • USER_AUTHCUSTOM_AUTH を廃止しません。 両者は共存します。置き換わったのは「OTP/パスキーを CUSTOM_AUTH で手組みする旧来のユースケース」だけです。
  • B2B SaaSの現実解は 「フェデレーション(企業顧客)+ パスワードレス or パスワード(個人・小規模顧客)」のハイブリッド。本記事の後半で、ログイン画面でこれを出し分ける設計を示します。

SAML vs OIDC:どちらで連携するか

項目SAML 2.0OIDC (OpenID Connect)
仕様XMLベースJSON + OAuth 2.0
複雑性高い低い
モバイル対応困難容易
主な採用Azure AD(エンタープライズアプリ), Okta, ADFSGoogle, Okta(OIDC App), 各種モダンIdP
Cognito対応ProviderType: SAMLProviderType: 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
サインオンURLhttps://<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 / ActiveEncryptionCertificateDescribe(取得)時のレスポンス専用フィールド であり、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_issuerhttps:// 始まりで、末尾スラッシュは付けない
  • エンドポイントは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_AUTHEMAIL_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_flowsALLOW_USER_AUTH を追加
ユーザープールの sign_in_policy.allowed_first_auth_factorsPASSWORD / 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",
  ]
}

フローの仕組み

  1. InitiateAuth(AuthFlow=USER_AUTH)USERNAME 付きで呼ぶ。
  2. PREFERRED_CHALLENGE を指定しない場合、Cognitoは ChallengeName: SELECT_CHALLENGEAvailableChallenges(利用可能な方式の配列) を返す。
  3. クライアントは SELECT_CHALLENGE に対し、選んだ方式を ANSWER に入れて応答。
  4. 以降は方式ごとのチャレンジ(EMAIL_OTP なら EMAIL_OTP_CODEWEB_AUTHN なら CREDENTIAL)に答える。
方式チャレンジ名回答キー
パスキー(WebAuthn)WEB_AUTHNCREDENTIAL(W3C AuthenticationResponseJSON
メールOTPEMAIL_OTPEMAIL_OTP_CODE
SMS OTPSMS_OTPSMS_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>
  );
}

アクセシビリティの要点:labelinputhtmlFor/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_0IDトークンのみ全プラン(Liteも可)
V2_0ID + アクセストークン(ユーザーID)Essentials / Plus
V3_0ID + アクセス(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名前が異なります

出典: Pre token generation Lambda trigger


JWT検証:公式推奨の aws-jwt-verify を使う

CognitoのJWTは RS256 で署名され、検証では以下を必ずチェックします。手書きのJWKSパースは事故の温床なので、AWS公式が推奨する aws-jwt-verify を使う のが2026年の標準です。

検証項目
isshttps://cognito-idp.<region>.amazonaws.com/<userPoolId>
対象クライアントIDトークンは aud、アクセストークンは client_id(どちらもApp Client ID)
token_use"id" または "access"(用途に合致しているか)
exp失効していないか
alg / kidRS256 / 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}")

出典: Verifying a JSON Web Token


トラブルシューティング

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 SLOIDPSignout=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_AUTHUSER_AUTH は何が違う?どちらを使う? A. USER_AUTH はパスキー/OTP/パスワードを「選ばせる」公式のパスワードレス基盤(Essentials以上)。CUSTOM_AUTH は3つのLambdaで独自チャレンジを組む仕組み。OTP・パスキー目的なら USER_AUTH、公式機能に無い独自ロジック(CAPTCHA・外部リスク照合)なら CUSTOM_AUTH。両者は共存可能で、USER_AUTHCUSTOM_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: {...} } }) 形式です。


まとめ:エンタープライズ認証基盤の設計指針

  1. まずプランを決める:B2B SaaSの標準は Essentials。これでマネージドログイン・パスワードレス・アクセストークン拡張が揃う。
  2. 方式を選び違えない:企業顧客はフェデレーション(SAML/OIDC)、個人はパスワードレス(USER_AUTH)、独自要件のみ CUSTOM_AUTH
  3. テナント分離はトークンに載せる:Pre Token Generation V2.0 で tenant_id をアクセストークンへ注入し、バックエンドの認可で一貫して使う。
  4. 検証は aws-jwt-verify に任せるiss/audorclient_id/token_use/署名を自前で書かない。
  5. 公式ドキュメントを一次情報にする:Cognitoは更新が速い。本記事の各リンクから最新を確認する習慣を。

設計・運用で得た実感(木材流通DXプロジェクト)

経済産業大臣賞を受賞したB2BサブスクリプションSaaSでは、Cognitoを認証基盤として採用し、RS256でのJWT検証・マルチテナント前提の認可・複数IdPの受け入れを前提に設計しました。経験上、つまずきの大半は「属性マッピングの食い違い」と「プラン/トークン種別の理解不足」 に集約されます。本記事の早見表とチェックリストは、その実地の知見を反映したものです。


次のステップ

AWS CognitoでのエンタープライズSSO・パスワードレス認証・マルチテナント認可の設計でお悩みなら、お気軽にご相談ください。SAML/OIDC統合からトークン設計、本番運用の可観測性まで、一気通貫で支援します。

お問い合わせはこちら


参考(AWS公式ドキュメント):

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

お困りごとはありませんか?

設計から実装・運用まで、一人 × 生成AI で伴走します

この記事のような実装を、要件定義から本番運用まで一気通貫で。まずは30分の無料技術相談から、状況をお聞かせください。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。

あわせて読みたい