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

AWS Cognito Enterprise SSO Guide 2026 | SAML/OIDC & Passwordless

Implement enterprise SSO (Azure AD/Okta/Google SAML·OIDC federation) and passwordless auth with AWS Cognito — 2026 edition. Feature plans, multi-tenant, JWT verification, SAML/OIDC attack defense, and build-vs-buy, with real code and official-docs citations.

Published
Last updated
Updated
Reading time
29 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

The Principle and History of Enterprise SSO: Why SAML and OIDC Still Coexist

Before diving into implementation, spend ten minutes on "why these two standards exist and why they aren't unified into one." Do that, and every single setting lands as a necessity rather than an arbitrary ritual. Skip it, and you'll freeze the moment you hit a snag in attribute mapping or signature verification.

The core of federation is "delegating trust"

The essence of enterprise SSO is that your company (the Service Provider, SP) delegates the judgment of "who logged in" to the customer's identity platform (the Identity Provider, IdP). Your SaaS holds none of the customer's passwords. Instead, the IdP declares — with a signature — that "this user is indeed authenticated" (an assertion), and the SP verifies that signature to trust it.

This single point — "a signed assertion is the basis of trust" — governs everything about SSO security design. The reason every countermeasure later boils down to "don't let an assertion be forged, swapped, or replayed" is exactly this.

SAML 2.0 — why the enterprise can't let go (2005–)

SAML (Security Assertion Markup Language) 2.0 is an XML-based federation standard that OASIS standardized in 2005 (formally approved March 2005). It unified the lineage of earlier SAML 1.x (1.0 = 2002 / 1.1 = 2003), Liberty Alliance's ID-FF, and Shibboleth, establishing the Web Browser SSO Profile (carrying assertions over the HTTP-Redirect / HTTP-POST — and HTTP-Artifact — bindings). It uses XML Signature (XML-DSig) for signing and XML Encryption for confidentiality.

SAML is still current in enterprises 20 years on because of its affinity with existing assets like Active Directory / ADFS / Microsoft Entra ID (formerly Azure AD) and a governance culture that strictly registers and audits apps as "enterprise applications." When a large company's IT department says "come in over SAML," it's because their control processes are designed around SAML.

OIDC — the answer for the mobile-and-API era (2014–)

OpenID Connect (OIDC) 1.0, published by the OpenID Foundation in 2014 (the Final spec is February 2014), is a thin identity layer on top of OAuth 2.0 (RFC 6749). It uses JSON/JWT rather than XML, and can auto-discover endpoints via discovery at .well-known/openid-configuration. It has high affinity with mobile apps, SPAs, and API integration, and is lightweight to implement — which is why modern IdPs (Google, Okta's OIDC apps, various CIAMs) choose OIDC.

The right answer is "receive both," not "either/or"

What history shows is that SAML and OIDC are not a generational handoff but a division of labor. Large enterprises carrying legacy come in over SAML; modern customers over OIDC. That's precisely why a design like Cognito's — hosting multiple IdPs (SAML/OIDC mixed) in a single user pool and switching per customer — is the realistic answer for B2B SaaS. The implementations from the next chapter on are built on the premise of "receiving both."


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

The Attack Surface and Defense of SAML/OIDC: What Cognito Guards, and What You Must Guard

All of federation's trust rides on the "signed assertion." Conversely, if an assertion can be forged, swapped, or replayed, an impersonation login succeeds. Here we organize the representative attack surface along the OWASP cheat sheets (SAML Security / XXE Prevention / OAuth), and separate "what AWS handles when you use Cognito" from "what still remains your responsibility." Misread the responsibility boundary and you'll over-trust "it's managed, so it's safe" and leave residual risk in place.

Representative SAML-side attacks

AttackWhat happensDefense principle
XML Signature Wrapping (XSW)Leaving the legitimate signed element in place, a forged Assertion is "wrapped" and injected elsewhere in the XML, exploiting the gap where the signature is valid over the legitimate element yet the processor reads the forged oneProcess only "the very element" whose signature you verified. Use a hardened SAML library + schema validation. Hand-rolled XML parsing is forbidden
XXE (XML External Entity injection)Expanding external entities during SAML XML parsing to induce file reads or SSRFDisable DTDs / external entities in the XML parser (OWASP XXE Prevention Cheat Sheet)
Trusting an Assertion outside the signature scope (insufficient coverage)If you trust an Assertion the signature does not cover, it can be swapped for a different (forged) one — the essence of XSW/substitutionSign each Assertion or the whole Response, and verify the signature's <ds:Reference URI> actually covers the <saml:Assertion> you consume. Even when signing only the Response, confirm that signature covers the consumed Assertion
ReplayReusing an intercepted legitimate assertionStrictly evaluate the Conditions NotBefore/NotOnOrAfter window and make the assertion ID one-time-use (InResponseTo validation, ID cache)
Wrong Audience / Recipient confusionReusing an assertion meant for another SPVerify AudienceRestriction matches your SP entity ID (urn:amazon:cognito:sp:<poolId>). Also confirm the ACS (Recipient) match

The responsibility boundary when using Cognito as the SP: the "SP-side assertion processing" — XSW, XXE, signature verification, and Conditions/Audience evaluation — is implemented and operated by AWS (Cognito). You do not hand-write a SAML library. What still remains your responsibility is the following, and this is where the real-world action is:

  • Run with MetadataURL so you auto-track the IdP's signing-certificate rotation (manual MetadataFile is a hotbed of expiry incidents).
  • Map NameID to an immutable, unique identifier (Azure AD's objectid). Mapping it to a mutable email splits the account when the email changes and becomes a hotbed of account takeover.
  • A mapped email is unverified by default. Handle with care in flows that require verification.
  • If you use IDPSignout / SLO, verify the behavior down to the sessionIndex match.

Representative OIDC / OAuth2-side attacks

AttackWhat happensDefense principle
CSRF (no state)Binding the attacker's code to the victim's sessionAttach state to the authorization request and check the match on callback (RFC 6749)
ID-token replayReusing an ID token obtained from a different authorizationInclude a nonce in the authorization request and reconcile it with the ID token's nonce (OIDC Core)
Authorization-code interception (esp. SPA/mobile)Intercepting the code via the redirect and exchanging itUse PKCE (RFC 7636) (code_challenge/code_verifier)
Mix-up attack (multiple IdPs)Mistaking which IdP (authorization server) respondedStrictly identify the responding issuer (compare RFC 9207's iss response parameter to the expected value)
Token substitution / audience misuseReusing a token meant for another clientVerify aud/client_id, token_use, iss, exp, and the signature (RS256/kid) (delegate to aws-jwt-verify on Node.js, a reputable JWT library elsewhere)

The responsibility boundary when using Cognito: Cognito provides the authorization code flow (code) and supports state/nonce/PKCE. ID and access tokens are signed with RS256. What remains your responsibility is:

  • On the app↔Cognito OAuth leg, use the authorization code flow + PKCE, and do not use the Implicit flow.
  • Validate state on callback (many SDKs do this automatically; mandatory if you hand-roll).
  • For API authorization, verify the access token (aws-jwt-verify on Node.js, a reputable JWT library elsewhere) and always reconcile token_use/client_id (the code from the previous chapter).
  • Prevent user enumeration with PreventUserExistenceErrors=ENABLED (the default is LEGACY = disabled when created via API/IaC, so enable it explicitly).
  • Keep token lifetimes short and set token_validity_units explicitly.

The core in one line: a managed service takes on "spec-level attacks (XSW/XXE/signature forgery)," but "mistakes in configuration and identifier design (mutable NameID, allowing unsigned, Implicit, missing state, over-long token lifetimes) remain your responsibility." Most breaches come from the latter, not the former.


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


Build vs. Specialist SaaS vs. Cognito: Deciding on Your Auth Foundation

"Should we implement it ourselves on Cognito, or buy a specialist like Auth0 or WorkOS?" — a fork you'll always be asked about on enterprise engagements. Because auth is a foundation whose replacement cost is extremely high once built, this decision holds for years. Decide not by technical superiority but by fitting your situation to five axes.

Decision axisCognito (AWS-native, in-house) wins when…Specialist SaaS (Auth0/Okta, WorkOS, etc.) wins when…
Cost at scaleThe per-unit advantage grows as MAU risesYou buy launch speed at small–mid scale
AWS affinity / IaCYou're already AWS/Terraform-centric and want it contiguous with VPC/IAM/LambdaYou want it cloud-agnostic
Enterprise featuresStandard features meet the requirementYou need SCIM auto-provisioning or a management UI / directory sync to add many customer IdP connections fast
Team familiarityYou can handle Cognito's quirks (plan tiers, triggers, attribute mapping)You'd rather not spend people on auth and lean on a good abstraction
Time-to-marketYou have time to build the design outYou need it "by next month's enterprise deal"

Practical judgment:

  • Cognito excels at AWS-native cost efficiency and IaC affinity, but inbound SCIM auto-provisioning from an external IdP (auto-reflecting onboarding/offboarding) has no native user-pool feature, so enterprise requirements sometimes need extra design (API Gateway + Lambda, etc.). This is often the fork toward "buy."
  • A specialist that "sells SSO/SCIM as an API," like WorkOS, hits the B2B need to "be enterprise-ready fastest." Conversely, if your workload already leans on AWS and you value long-run cost and control, in-house Cognito pays off.
  • The right answer isn't "which" but "when and in what order." Standing up quickly with a fast option early, then leaning toward AWS-native once scale and per-unit cost matter — deciding with that migration path included minimizes rework cost.

This decision is only made once you put the requirements (number and type of customer IdPs, SCIM need, regulation, existing stack) onto a single sheet. Enter implementation with the wrong call and you pay that "replacement cost" in full. When in doubt, slipping in a third-party design review before implementation is the cheapest insurance.


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: Move Your Enterprise SSO Forward with Confidence

An enterprise customer's "please support SSO" is a signal of expanding revenue — and, if you get the design wrong, the entrance to technical debt you'll drag for years. Selecting SAML/OIDC, designing NameID and attribute mapping, designing the multi-tenant token, and deciding "build or buy" — how correctly you assemble this at the start greatly changes your later operating cost.

In an economic-ministry-award-winning B2B subscription SaaS (lumber-distribution DX), I designed and operated a Cognito auth foundation on the premise of RS256 JWT verification, multi-tenant authorization, and accepting multiple IdPs. I'll support how to clear the same wall, fitted to your product's context, end-to-end.

  • Enterprise-SSO design review / technical advisory: I organize SAML/OIDC selection, IdP integration, tenant design, and the build-vs-buy decision — before implementation.
  • Implementation & migration support: from migrating existing password auth to federation, through taking multi-tenant authorization to production.
  • Authorization / RLS security review: going all the way into how the backend uses issued tokens for authorization (verifying tenant separation).

If you'd like to organize "for your IdP requirements (Azure AD/Okta/Google/SCIM), whether in-house Cognito or a specialist SaaS is optimal," feel free to reach out. After hearing your requirements, I'll return a first opinion along the decision framework.

Frequently asked questions

What's the difference between CUSTOM_AUTH and USER_AUTH? Which to use?
USER_AUTH is the official passwordless foundation that lets users choose passkeys/OTP/password (Essentials or higher). CUSTOM_AUTH assembles a bespoke challenge with 3 Lambdas. Use USER_AUTH for OTP/passkeys, and CUSTOM_AUTH only for bespoke logic absent from official features (reCAPTCHA, external risk check). They coexist; USER_AUTH did not abolish CUSTOM_AUTH.
I want the tenant ID in the access token, but the claim isn't reflected.
Access-token claim extension requires Essentials or higher plus a Pre Token Generation V2_0 trigger. On Lite or V1_0, only the ID token can be extended. Reserved claims (sub/iss/token_use) can't be added, changed, or suppressed.
Where is the SAML SP metadata URL?
Cognito does not publish an SP metadata URL. Manually register the Entity ID urn:amazon:cognito:sp:<userPoolId> and the ACS URL https://<domain>/saml2/idpresponse at the IdP. Searching for a /saml2/metadata URL only wastes your time.
The ID token or the access token — which for API authorization?
Use the access token for API authorization (it carries scope/cognito:groups/client_id). The ID token conveys user attributes and carries aud. Always confirm the token_use match (access vs id) at verification.
Should I build auth on Cognito, or buy Auth0 / WorkOS?
Cognito is strong if you want AWS-native cost efficiency, full IaC control, and a favorable per-unit cost at scale. A specialist SaaS (Auth0/Okta, WorkOS) can be faster if you need SCIM auto-provisioning or a management UI to add customer IdP connections quickly. The five axes are cost at scale, AWS affinity, enterprise features (SCIM, etc.), team familiarity, and time-to-market — organized in the decision framework below.
How long does enterprise SSO support take?
Getting the first customer (e.g. an Azure AD SAML integration) to production takes the most time — design, implementation, and IdP-side coordination. The second customer onward is mostly horizontal reuse of attribute mapping and tenant detection, so it's faster. Timelines depend on the number and type of customer IdPs (SAML/OIDC mix), multi-tenant requirements, and whether you're migrating off existing password auth — best estimated after reviewing requirements.

References

友田

友田 陽大

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.

Stuck on your auth platform or enterprise SSO design?

Auth platform & enterprise SSO (SAML/OIDC) technology selection & design review

Choosing among Cognito / Auth0 / Clerk / Supabase, SAML/OIDC federation with Azure AD, Okta, and Google, JWT (RS256) verification, and multi-tenant token design. With experience designing and running a Cognito auth foundation (RS256 JWT verification, multi-tenant authorization, multiple IdPs) on an award-winning B2B SaaS, I help you lock down the design before implementation, as a technical advisor.

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

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Also worth reading