# The Complete Guide to Implementing Enterprise SSO with AWS Cognito: SAML/OIDC Integration (Azure AD, Okta, Google) and USER_AUTH Choice-Based Authentication

> The 2026 latest guide to implementing enterprise SSO (Azure AD/Okta/Google) and passwordless authentication (passkeys/OTP) with AWS Cognito User Pools. SAML/OIDC integration, USER_AUTH choice-based authentication, the Lambda triggers of custom authentication flows, multi-tenant design, and JWT verification — explained with official-docs-compliant real code.

- Published: 2026-06-24
- Author: 友田 陽大
- Tags: AWS, Cognito, SAML, OIDC, SSO, 認証, パスワードレス, Passkey, Azure AD, Okta, Terraform, セキュリティ
- URL: https://tomodahinata.com/en/blog/aws-cognito-saml-oidc-enterprise-sso
- Category: Authentication & authorization
- Pillar guide: https://tomodahinata.com/en/blog/auth-platform-selection-2026-cognito-auth0-clerk-supabase

## Key points

- The feature plans introduced in 2024 (Lite/Essentials/Plus) change which auth features you can use; the B2B SaaS standard is Essentials
- Choose the auth method: enterprise customers = federation, individuals = passwordless, and CUSTOM_AUTH only for bespoke requirements
- For OTP or passkeys, don't hand-build CUSTOM_AUTH — use the official USER_AUTH (choice-based sign-in)
- For multi-tenant, inject tenant_id into the access token with Pre Token Generation V2.0 and use it consistently in backend authorization
- Leave JWT verification to aws-jwt-verify; don't hand-write iss, aud/client_id, token_use, and the signature

---

"A request came in from an enterprise customer: 'support SSO with Azure AD (Microsoft Entra ID),' but I don't know how to implement it."

"I tried SAML/OIDC integration with AWS Cognito, but there were too many settings and I gave up."

"In a multi-tenant SaaS, I want to know how to integrate a different Identity Provider per customer."

"The 'passkeys,' 'choice-based authentication,' and 'pricing plans (Lite/Essentials/Plus)' that came out after 2024 conflict with old articles, and I'm confused."

These are challenges B2B SaaS developers face. I myself designed and operated an authentication foundation with Cognito — including RS256 JWT verification — in the development of an economic-ministry-award-winning B2B subscription SaaS (lumber-distribution DX). I'm writing this article from the experience of hitting the same wall.

This article is **faithful to the AWS official docs (as of June 2026)** and explains "in which scene, how to use it" with real code. Each section explicitly notes the official-doc link referenced, so you can proceed with implementation while confirming the primary source in your own environment.

> **The scope of this article**
> 1. The overall picture of Cognito in 2026 (pricing plans, managed login)
> 2. Choosing an auth method (federation / passwordless / custom flow)
> 3. Implementing SAML (Azure AD) and OIDC (Google / Okta) integration
> 4. Custom authentication flow (the 3 Lambda triggers)
> 5. `USER_AUTH` choice-based / passwordless authentication (passkeys / OTP)
> 6. Multi-tenant design and tenant injection into tokens (Pre Token Generation V2.0)
> 7. JWT verification (`aws-jwt-verify`) and troubleshooting

---

## First, Nail This Down: In 2026, Cognito's Features Change by "Pricing Plan"

In November 2024, **feature plans** were introduced to Cognito User Pools, and "which auth features you can use" came to be decided by the plan. **Copy code from an old article without understanding this and you get the accident of "the API passes but the feature isn't enabled."** Let's first nail down the overall picture.

| Plan | Main target features | Assumed use |
|--------|------------|-----------|
| **Lite** | Basic auth + old Hosted UI (classic) | Minimal configuration, cost-first. No passkeys / access-token customization |
| **Essentials** (default for new pools) | Choice-based sign-in (`USER_AUTH`), passwordless (passkeys/OTP), email MFA, **managed login**, access-token claim extension | The standard answer for the vast majority of B2B/B2C SaaS |
| **Plus** | Essentials + **threat protection**: compromised-credential detection, adaptive authentication, log export | High-security requirements, regulated industries |

The points (official spec):

- **Access-token claim extension is Essentials or higher.** ID-token claim extension is possible on all plans.
- **Passkeys / OTP passwordless / email MFA are Essentials or higher** (passkeys are usable on all but Lite).
- The former "Advanced Security Features" had its **product name changed to "threat protection."** However, the **API enum is still `AdvancedSecurityMode` (`OFF`/`AUDIT`/`ENFORCED`)** as before. Setting `ENFORCED` automatically raises the plan to `PLUS`.

> Sources: [Understanding feature plans](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-sign-in-feature-plans.html) / [Threat protection](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pool-settings-threat-protection.html)

### Managed Login vs. Classic Hosted UI

| | Managed login (new) | Classic Hosted UI (old, first generation) |
|--|------------------------|--------------------------------|
| Branding | No-code visual editor (logo, color, corner radius, light/dark) | Logo + a CSS file only |
| Passkey sign-in | ✅ Supported | ❌ Unsupported |
| Required plan | Essentials / Plus | OK even on Lite |
| Localization | ✅ | ❌ |

The endpoint paths (`/oauth2/authorize`, `/oauth2/idpresponse`, `/saml2/idpresponse`, `/saml2/logout`, `/logout`) are common to both. **A new pool defaults to managed login.** The official docs don't explicitly call classic Hosted UI "deprecated," but position it as "first generation," and new features like passkeys aren't provided. For a new implementation, managed login is the only choice.

> Source: [Managed login](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-managed-login.html)

---

## Choosing an Auth Method: Decide on 3 Axes

Cognito's auth has roughly three families. **Not mis-choosing here first is the most important thing.** I've prepared a quick-reference by request.

| Request / scene | The method to choose | Cognito's implementation means |
|-----------|------------|------------------|
| Delegate auth to a customer company's IdP (Azure AD/Okta/ADFS) | **Federation** | SAML / OIDC IdP integration |
| Hold users yourself and abolish passwords (passkeys, OTP) | **Passwordless** | `USER_AUTH` (choice-based sign-in) |
| Insert a bespoke challenge (CAPTCHA, security question, external risk-engine check) | **Custom authentication flow** | `CUSTOM_AUTH` + 3 Lambda triggers |

Important judgment points:

- **If OTP or passkeys are the goal, don't hand-build `CUSTOM_AUTH` anymore.** Since 2024, `USER_AUTH` (`EMAIL_OTP`/`SMS_OTP`/`WEB_AUTHN`) is provided as an official feature, eliminating the need for your own Lambda implementation. Think of `CUSTOM_AUTH` as exclusively for "bespoke challenges that official features can't express."
- **`USER_AUTH` does not abolish `CUSTOM_AUTH`.** They coexist. What was replaced is only "the legacy use case of hand-assembling OTP/passkeys with `CUSTOM_AUTH`."
- The realistic answer for B2B SaaS is a **hybrid of "federation (enterprise customers) + passwordless or password (individual/small customers)."** In the latter half of this article, I show a design that switches between these on the login screen.

---

## SAML vs. OIDC: Which to Integrate With

| Item | SAML 2.0 | OIDC (OpenID Connect) |
|-----|---------|----------------------|
| Spec | XML-based | JSON + OAuth 2.0 |
| Complexity | High | Low |
| Mobile support | Difficult | Easy |
| Main adoption | Azure AD (enterprise apps), Okta, ADFS | Google, Okta (OIDC App), various modern IdPs |
| Cognito support | ✅ `ProviderType: SAML` | ✅ `ProviderType: OIDC` (generic) / `Google` etc. |

**Criteria**:

- **SAML**: when the customer uses legacy (ADFS, etc.), or has a policy of registering as an "enterprise application" in Azure AD.
- **OIDC**: modern IdP, mobile support, prioritizing implementation simplicity. Okta supports both, so either is fine.
- **Reality**: the ideal is a design that can support both, matching the customer's IdP environment (Cognito can co-locate multiple IdPs in one user pool).

---

## Implementation Example 1: Azure AD / Microsoft Entra ID (SAML) Integration

### The Overall System Picture

```text
[User] → [SaaS front (Next.js/Amplify)]
   → redirect to managed login
   → [Cognito User Pool] generates a SAMLRequest
   → authenticate at [customer IdP: Azure AD] + SAML Assertion
   → [Cognito] receives at the ACS (/saml2/idpresponse) → auto-creates user → issues JWT
   → [SaaS back] verifies the JWT with aws-jwt-verify → extracts the tenant ID → API authorization
```

### Step 1: Register Cognito's "SP Info" at the IdP

The Cognito values needed on the Azure AD side are **two officially-fixed patterns**. **Cognito does not publish its own SP metadata URL**, so register the following manually (misunderstand this and hunt for a URL like `/saml2/metadata` and you'll melt time).

| Azure AD item | Setting value |
|---------------|--------|
| Identifier (Entity ID / Audience) | `urn:amazon:cognito:sp:<userPoolId>` |
| Reply URL (Assertion Consumer Service) | `https://<your-domain>.auth.<region>.amazoncognito.com/saml2/idpresponse` |
| Sign-on URL | `https://<your-app>/login` |
| Logout URL (when using SLO) | `https://<your-domain>.auth.<region>.amazoncognito.com/saml2/logout` |

Copy Azure AD's "App Federation Metadata URL" (`https://login.microsoftonline.com/<tenant-id>/federationmetadata/2007-06/federationmetadata.xml`). You pass this to Cognito.

### Step 2: The Cognito User Pool (Terraform / 2026 Edition)

```hcl
# --- 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"
  }
}
```

> **Consistency with the official docs (important)**: `SLORedirectBindingURI` / `SSORedirectBindingURI` / `ActiveEncryptionCertificate` are **response-only fields at Describe (retrieval) time** and can't be specified in `CreateIdentityProvider`'s input. There are old articles writing these in `provider_details`, but they cause an API error.
> Sources: [CreateIdentityProvider API](https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_CreateIdentityProvider.html) / [SAML IdP](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-saml-idp.html)

### Attribute-Mapping Pitfalls (Official-Compliant)

- **`NameID` is a "unique and case-sensitive" federation identifier.** The iron rule is to derive it from a stable, immutable attribute (for Azure AD, `objectid`/`http://schemas.microsoft.com/identity/claims/objectidentifier`), not the mutable email. Map it to email and the account splits when the user changes their email.
- **A mapped email is treated as "unverified" by default.** Be careful in flows that require verification.
- A custom attribute can't be mapped unless it's **mutable and writable**.

---

## Implementation Example 2: OIDC Integration (Google / Okta)

### Google (Dedicated Provider)

```hcl
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"
  }
}
```

In the Google Cloud Console, register the following under "Authorized redirect URIs":
`https://<your-domain>.auth.<region>.amazoncognito.com/oauth2/idpresponse`

### Generic OIDC (Okta, etc.) — Use Issuer Auto-Discovery

For a generic OIDC provider (Okta's OIDC app, etc.), pass `oidc_issuer` and the **authorize/token/userInfo/jwks endpoints are auto-discovered** from `.well-known/openid-configuration`. You don't need to write the endpoints one by one.

```hcl
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"
  }
}
```

> **Official-compliant notes** (omitted by many articles):
> - Cognito requires **`client_secret_post`** and **does not support `client_secret_basic`**. Match the client-authentication method on the IdP side.
> - `oidc_issuer` starts with `https://` and **has no trailing slash**.
> - Endpoints are HTTPS, ports 80/443 only.
> - `sub → username` is auto-mapped (the provider name is prepended to the username).
>
> Source: [OIDC IdP](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-oidc-idp.html)

---

## Custom Authentication Flow (CUSTOM_AUTH): The 3 Lambda Triggers

The core of the title, the **custom authentication flow**. Use it when you want to insert a **bespoke challenge that Cognito's standard features can't express** — "CAPTCHA," "security question," "checking against your own fraud-detection engine."

The mechanism is simply that **3 Lambda triggers cooperate to spin a challenge loop**. Start with `InitiateAuth(AuthFlow=CUSTOM_AUTH)` and answer with `RespondToAuthChallenge`.

```text
InitiateAuth(CUSTOM_AUTH)
        │
        ▼
DefineAuthChallenge ── decides "what challenge next? / pass or fail?" (the command center)
        │  challengeName = CUSTOM_CHALLENGE
        ▼
CreateAuthChallenge ── generates the challenge content (OTP code generation & sending, etc.)
        │  publicChallengeParameters (to the client) / privateChallengeParameters (the answer)
        ▼
(the client sends ANSWER with RespondToAuthChallenge)
        │
        ▼
VerifyAuthChallengeResponse ── checks the answer (answerCorrect: true/false)
        │
        ▼
DefineAuthChallenge ── looks at the result and decides "issue tokens" or "again" or "fail"
```

### ① DefineAuthChallenge (the Command Center)

`event.request.session` contains an array of past challenge results (`ChallengeResult`). It looks at this and decides the next step. `issueTokens=true` for success (issue JWT), `failAuthentication=true` for failure, both false to continue.

```python
# 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 (Challenge Generation)

Called only when `challengeName === "CUSTOM_CHALLENGE"`. `publicChallengeParameters` is public info passed to the client; `privateChallengeParameters` is **the answer for verification (not passed to the client)**.

```python
# 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 (Checking the Answer)

```python
# 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)
```

> **A design judgment**: the above uses "email OTP" as an example, but **for a plain email/SMS OTP, you should not hand-build it — use the `USER_AUTH` (`EMAIL_OTP`/`SMS_OTP`) discussed later**. `CUSTOM_AUTH`'s turn is limited to inserting logic absent from official features, like "reCAPTCHA verification," "checking against your own risk-score API," and "device-trust checks."
> Source: [Custom authentication challenge Lambda triggers](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-challenge.html)

---

## USER_AUTH: Choice-Based / Passwordless Authentication (Passkeys / OTP)

Added in late 2024, **`USER_AUTH`** is the official **choice-based** flow that lets the **user choose** among "password," "one-time code (email/SMS)," and "passkey (WebAuthn)." **The era of writing Lambdas for OTP and passkeys is over.**

### The Required Settings

| Setting location | Value |
|---------|----|
| The user pool's plan | **Essentials or higher** |
| The App Client's `explicit_auth_flows` | Add `ALLOW_USER_AUTH` |
| The user pool's `sign_in_policy.allowed_first_auth_factors` | Choose from `PASSWORD` / `EMAIL_OTP` / `SMS_OTP` / `WEB_AUTHN` |

```hcl
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",
  ]
}
```

### How the Flow Works

1. Call `InitiateAuth(AuthFlow=USER_AUTH)` with `USERNAME`.
2. If you don't specify `PREFERRED_CHALLENGE`, Cognito returns `ChallengeName: SELECT_CHALLENGE` and **`AvailableChallenges` (an array of available methods)**.
3. The client responds to `SELECT_CHALLENGE` with the chosen method in `ANSWER`.
4. From there, answer the per-method challenge (`EMAIL_OTP_CODE` for `EMAIL_OTP`, `CREDENTIAL` for `WEB_AUTHN`).

| Method | Challenge name | Answer key |
|------|------------|---------|
| Passkey (WebAuthn) | `WEB_AUTHN` | `CREDENTIAL` (W3C `AuthenticationResponseJSON`) |
| Email OTP | `EMAIL_OTP` | `EMAIL_OTP_CODE` |
| SMS OTP | `SMS_OTP` | `SMS_OTP_CODE` |
| Password | `PASSWORD` / `PASSWORD_SRP` | — |

> Note: passkeys support ES256 + RS256, and up to 20 can be registered per user. Registration status can be confirmed with `GetUserAuthFactors`. Note that the first-factor OTP (`EMAIL_OTP`/`SMS_OTP`) is **a different thing** from the second-factor MFA code (`SMS_MFA`/`EMAIL_MFA`/`SOFTWARE_TOKEN_MFA`).
> Source: [Authentication flows (USER_AUTH / choice-based)](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-authentication-flow-methods.html)

### The Frontend (AWS Amplify v6 / Gen 2)

Amplify was renewed to a namespace API in v6 (the old `Auth.signIn` etc. were abolished). Passwordless using `USER_AUTH` is written like this.

```ts
// パスワードレス・サインイン（メール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;
}
```

```ts
// パスキー（WebAuthn）の登録（ログイン後に呼ぶ）
import { associateWebAuthnCredential } from "aws-amplify/auth";

async function registerPasskey() {
  // ブラウザのWebAuthn APIをAmplifyが内部で起動し、資格情報をCognitoに登録
  await associateWebAuthnCredential();
}
```

---

## Multi-Tenant Design: Switching IdPs per Customer

### The Challenge

- Customer A → Azure AD (SAML), Customer B → Okta (OIDC), Customer C → Google, individual users → passwordless.
- On one login screen, you want to guide to the appropriate auth method based on the entered email.

### The Solution: Determine the Tenant by Email Domain → Guide to the IdP

```tsx
// 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>
  );
}
```

> The accessibility crux: associate `label` and `input` with `htmlFor`/`id`, and adding `autoComplete="email webauthn"` makes supporting browsers offer passkeys as autofill candidates. Receiving submission via `<form>`'s `onSubmit` also handles the Enter key.

### The Tenant-Detection API (FastAPI)

```python
# 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")}}
```

> Security note: the tenant-detection API should only return "is there an IdP for this domain," and **must not leak whether the user exists** (user-enumeration countermeasure). On the Cognito side too, set `PreventUserExistenceErrors=ENABLED`.

---

## Injecting the Tenant into the Token: Pre Token Generation V2.0

The crux of multi-tenancy is **"put `tenant_id` in the access token and use it in the backend's authorization."** A federated user has `custom:tenant_id` via attribute mapping, but **access-token claim extension requires the Pre Token Generation trigger's V2.0 or higher (Essentials or higher)**.

| Version | Target tokens | Required plan |
|-----------|------------|-----------|
| `V1_0` | ID token only | All plans (Lite too) |
| `V2_0` | ID + **access token** (user ID) | Essentials / Plus |
| `V3_0` | ID + access (**including M2M client credentials**) | Essentials / Plus |

```hcl
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 以上
    }
  }
}
```

```python
# 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
```

> **Official constraints** (easy to trip on):
> - **Reserved claims** like `sub` / `iss` / `token_use` / `cognito:*` **cannot be added, changed, or suppressed**.
> - As an exception, you can add `aud` to the access token, but its value must match `event.callerContext.clientId`.
> - Make `groupOverrideDetails` empty/null and `cognito:groups` is **suppressed (disappears)**, so when overriding, state all groups explicitly.
> - The V1.0 container name is `claimsOverrideDetails`; V2.0 and later is `claimsAndScopeOverrideDetails` — **the names differ**.
>
> Source: [Pre token generation Lambda trigger](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-pre-token-generation.html)

---

## JWT Verification: Use the Officially-Recommended `aws-jwt-verify`

Cognito's JWT is signed with **RS256**, and verification must always check the following. Hand-written JWKS parsing is a breeding ground for accidents, so **using AWS's officially-recommended [`aws-jwt-verify`](https://github.com/awslabs/aws-jwt-verify)** is the 2026 standard.

| Verification item | Value |
|---------|----|
| `iss` | `https://cognito-idp.<region>.amazonaws.com/<userPoolId>` |
| Target client | **`aud` for the ID token, `client_id` for the access token** (both the App Client ID) |
| `token_use` | `"id"` or `"access"` (does it match the use?) |
| `exp` | Not expired? |
| `alg` / `kid` | `RS256` / matches the JWKS key ID |

> Note: Cognito holds **two RSA key pairs per pool** (for access, for ID). Cache by `kid`, and re-fetch the JWKS when an unknown `kid` arrives. `aws-jwt-verify` does this automatically.

### TypeScript (Backend / Next.js Route Handler or Lambda)

```ts
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, etc.)

```python
# 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}")
```

> Source: [Verifying a JSON Web Token](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html)

---

## Troubleshooting

### SAML Assertion Signature Verification Failure

```text
Invalid SAML response: Signature validation failed
```

- **Cause**: the IdP-side signing certificate was rotated / Cognito's metadata URL is stale.
- **Remedy**: if you use `MetadataURL`, re-run `terraform apply` to refresh to the latest. With manual-metadata (`MetadataFile`) operation, swap the certificate. **Strongly recommend `MetadataURL` operation** (auto-follows the certificate).

### Attributes Empty / Told `email` Is Missing

```text
InvalidParameterException: User does not have a valid email
```

- **Cause**: the claim URI mapped to the SAML Assertion differs from what's actually sent.
- **Debug**: confirm the actual Assertion with the browser extension "SAML-tracer" → see which claim name it comes under and fix `attribute_mapping`. Azure AD uses `.../emailaddress` by default, but depending on settings it can be `.../name`.

### Redirect Loop

- **Cause**: a mismatch between `callback_urls` and the front's settings.
- **Remedy**: make `redirectSignIn` an **exact match** in the Amplify v6 settings.

```ts
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",
        },
      },
    },
  },
});
```

---

## A Security / Operations Checklist

- Use the **authorization code flow (`code`) + PKCE** (don't use Implicit).
- **`PreventUserExistenceErrors=ENABLED`**: prevents user-enumeration attacks.
- **Threat protection (threat protection / Plus)**: compromised-credential detection, adaptive authentication. On the API it's `AdvancedSecurityMode=ENFORCED` (setting it auto-raises the plan to PLUS).
- **Token lifetimes**: keep access/ID short (e.g., 1 hour), refresh per requirements. Always state `token_validity_units` explicitly.
- **Global logout**: `signOut({ global: true })` revokes the refresh tokens of all devices.
- **SAML SLO**: when `IDPSignout=true` is set, a signed `SAMLRequest` flies to the IdP's `SingleLogoutService`, and a `sessionIndex` match is required.

---

## Cost Estimate: Think on the Premise of the 2026 Pricing Plans

The conventional explanation "the first 50,000 MAU are free / Advanced Security is +$0.05/MAU" is **inaccurate under the current plan system**. Now you're billed by **the chosen plan (Lite/Essentials/Plus) × MAU** (Lite < Essentials < Plus).

The decision guideline:

- **Individual/small-scale-centric, minimal UI is fine** → Lite. But passkeys, passwordless, and access-token extension are unavailable.
- **The B2B SaaS standard** (you want managed login, passwordless, tenant injection) → **Essentials**. This article's configuration assumes this.
- **Regulated industries, high security** (compromise detection, adaptive auth, log export) → Plus.

Since the exact unit price changes by region and time, **always confirm the latest values on the official pricing page**.
Source: [Amazon Cognito Pricing](https://aws.amazon.com/cognito/pricing/)

Lambda triggers (PreSignUp / Pre Token Generation / the 3 custom-auth ones) usually fit within the free tier to a very small amount (a few tens to a hundred ms of execution per login).

---

## FAQ

**Q. What's the difference between `CUSTOM_AUTH` and `USER_AUTH`? Which to use?**
A. `USER_AUTH` is the official passwordless foundation that "lets users choose" passkeys/OTP/password (Essentials or higher). `CUSTOM_AUTH` is a mechanism to assemble a bespoke challenge with 3 Lambdas. **For OTP/passkeys, `USER_AUTH`; for bespoke logic absent from official features (CAPTCHA, external risk check), `CUSTOM_AUTH`.** They can coexist; `USER_AUTH` did not abolish `CUSTOM_AUTH`.

**Q. I want to put the tenant ID in the access token, but the claim isn't reflected.**
A. **Access-token claim extension requires Essentials or higher + Pre Token Generation `V2_0`.** On Lite or `V1_0`, only the ID token can be extended.

**Q. Where is the SAML SP metadata URL?**
A. Cognito **does not publish an SP metadata URL.** Manually register the Entity ID `urn:amazon:cognito:sp:<userPoolId>` and the ACS `https://<domain>/saml2/idpresponse` at the IdP.

**Q. The ID token or the access token — which to use for API authorization?**
A. **API authorization uses the access token** (it has `scope` / `cognito:groups` / `client_id`). The ID token is for conveying user attributes and has `aud`. Always confirm the `token_use` match at verification.

**Q. Amplify's `Auth.signIn` doesn't work.**
A. Amplify v6 changed to a namespace API. Use `import { signIn } from "aws-amplify/auth"`, and the config is the `Amplify.configure({ Auth: { Cognito: {...} } })` form.

---

## Summary: Design Guidelines for an Enterprise Auth Foundation

1. **First decide the plan**: the B2B SaaS standard is Essentials. This gives you managed login, passwordless, and access-token extension.
2. **Don't mis-choose the method**: enterprise customers use federation (SAML/OIDC), individuals passwordless (`USER_AUTH`), and bespoke requirements only `CUSTOM_AUTH`.
3. **Put tenant separation in the token**: inject `tenant_id` into the access token with Pre Token Generation V2.0 and use it consistently in backend authorization.
4. **Leave verification to `aws-jwt-verify`**: don't hand-write `iss`/`aud`or`client_id`/`token_use`/signature.
5. **Make the official docs the primary source**: Cognito updates fast. Build a habit of confirming the latest from this article's links.

### What I Learned in Design & Operation (the Lumber-Distribution DX Project)

In the economic-ministry-award-winning B2B subscription SaaS, I adopted Cognito as the auth foundation and designed it on the premise of RS256 JWT verification, multi-tenant authorization, and accepting multiple IdPs. In my experience, **most of the stumbles boil down to "attribute-mapping mismatches" and "insufficient understanding of plans/token types."** This article's quick-reference and checklist reflect that on-the-ground knowledge.

---

## Next Steps

If you're struggling with the design of enterprise SSO, passwordless authentication, or multi-tenant authorization with AWS Cognito, feel free to reach out. From SAML/OIDC integration to token design to production-operation observability, I support you end-to-end.

[Contact me here](/contact)

---

**References (AWS Official Docs)**:
- [Understanding feature plans (pricing plans)](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-sign-in-feature-plans.html)
- [Managed login](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-managed-login.html)
- [Authentication flows (USER_AUTH / choice-based)](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-authentication-flow-methods.html)
- [Custom authentication challenge Lambda triggers](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-challenge.html)
- [SAML IdP](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-saml-idp.html) / [OIDC IdP](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-oidc-idp.html)
- [Pre token generation Lambda trigger](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-pre-token-generation.html)
- [Verifying a JWT](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html) / [aws-jwt-verify](https://github.com/awslabs/aws-jwt-verify)
