Skip to main content
友田 陽大
Authentication & authorization
AWS
Cognito
SAML
OIDC
SSO
認証
パスワードレス
Passkey
Azure AD
Okta
Terraform
セキュリティ

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
Reading time
22 min read
Author
友田 陽大
Share
Contents

"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.

PlanMain target featuresAssumed use
LiteBasic 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 extensionThe standard answer for the vast majority of B2B/B2C SaaS
PlusEssentials + threat protection: compromised-credential detection, adaptive authentication, log exportHigh-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 / Threat protection

Managed Login vs. Classic Hosted UI

Managed login (new)Classic Hosted UI (old, first generation)
BrandingNo-code visual editor (logo, color, corner radius, light/dark)Logo + a CSS file only
Passkey sign-in✅ Supported❌ Unsupported
Required planEssentials / PlusOK 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


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 / sceneThe method to chooseCognito's implementation means
Delegate auth to a customer company's IdP (Azure AD/Okta/ADFS)FederationSAML / OIDC IdP integration
Hold users yourself and abolish passwords (passkeys, OTP)PasswordlessUSER_AUTH (choice-based sign-in)
Insert a bespoke challenge (CAPTCHA, security question, external risk-engine check)Custom authentication flowCUSTOM_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

ItemSAML 2.0OIDC (OpenID Connect)
SpecXML-basedJSON + OAuth 2.0
ComplexityHighLow
Mobile supportDifficultEasy
Main adoptionAzure AD (enterprise apps), Okta, ADFSGoogle, Okta (OIDC App), various modern IdPs
Cognito supportProviderType: SAMLProviderType: 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

[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 itemSetting 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 URLhttps://<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)

# --- 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 / SAML IdP

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)

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.

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


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.

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.

# 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).

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

# 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


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 locationValue
The user pool's planEssentials or higher
The App Client's explicit_auth_flowsAdd ALLOW_USER_AUTH
The user pool's sign_in_policy.allowed_first_auth_factorsChoose from 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",
  ]
}

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).
MethodChallenge nameAnswer key
Passkey (WebAuthn)WEB_AUTHNCREDENTIAL (W3C AuthenticationResponseJSON)
Email OTPEMAIL_OTPEMAIL_OTP_CODE
SMS OTPSMS_OTPSMS_OTP_CODE
PasswordPASSWORD / 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)

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.

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

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

// 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)

# 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).

VersionTarget tokensRequired plan
V1_0ID token onlyAll plans (Lite too)
V2_0ID + access token (user ID)Essentials / Plus
V3_0ID + access (including M2M client credentials)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

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 claimsAndScopeOverrideDetailsthe names differ.

Source: Pre token generation Lambda trigger


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 is the 2026 standard.

Verification itemValue
isshttps://cognito-idp.<region>.amazonaws.com/<userPoolId>
Target clientaud 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?)
expNot expired?
alg / kidRS256 / 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)

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.)

# 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


Troubleshooting

SAML Assertion Signature Verification Failure

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

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.
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

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/audorclient_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


References (AWS Official Docs):

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading