"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
- The overall picture of Cognito in 2026 (pricing plans, managed login)
- Choosing an auth method (federation / passwordless / custom flow)
- Implementing SAML (Azure AD) and OIDC (Google / Okta) integration
- Custom authentication flow (the 3 Lambda triggers)
USER_AUTHchoice-based / passwordless authentication (passkeys / OTP)- Multi-tenant design and tenant injection into tokens (Pre Token Generation V2.0)
- 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. SettingENFORCEDautomatically raises the plan toPLUS.
Sources: Understanding feature plans / Threat protection
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
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_AUTHanymore. 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 ofCUSTOM_AUTHas exclusively for "bespoke challenges that official features can't express." USER_AUTHdoes not abolishCUSTOM_AUTH. They coexist. What was replaced is only "the legacy use case of hand-assembling OTP/passkeys withCUSTOM_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
[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)
# --- 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/ActiveEncryptionCertificateare response-only fields at Describe (retrieval) time and can't be specified inCreateIdentityProvider's input. There are old articles writing these inprovider_details, but they cause an API error. Sources: CreateIdentityProvider API / SAML IdP
Attribute-Mapping Pitfalls (Official-Compliant)
NameIDis 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_postand does not supportclient_secret_basic. Match the client-authentication method on the IdP side.oidc_issuerstarts withhttps://and has no trailing slash.- Endpoints are HTTPS, ports 80/443 only.
sub → usernameis 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 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 |
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
- Call
InitiateAuth(AuthFlow=USER_AUTH)withUSERNAME. - If you don't specify
PREFERRED_CHALLENGE, Cognito returnsChallengeName: SELECT_CHALLENGEandAvailableChallenges(an array of available methods). - The client responds to
SELECT_CHALLENGEwith the chosen method inANSWER. - From there, answer the per-method challenge (
EMAIL_OTP_CODEforEMAIL_OTP,CREDENTIALforWEB_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)
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
labelandinputwithhtmlFor/id, and addingautoComplete="email webauthn"makes supporting browsers offer passkeys as autofill candidates. Receiving submission via<form>'sonSubmitalso 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).
| 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 |
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
audto the access token, but its value must matchevent.callerContext.clientId.- Make
groupOverrideDetailsempty/null andcognito:groupsis suppressed (disappears), so when overriding, state all groups explicitly.- The V1.0 container name is
claimsOverrideDetails; V2.0 and later isclaimsAndScopeOverrideDetails— the names differ.
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 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 unknownkidarrives.aws-jwt-verifydoes 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-runterraform applyto refresh to the latest. With manual-metadata (MetadataFile) operation, swap the certificate. Strongly recommendMetadataURLoperation (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.../emailaddressby default, but depending on settings it can be.../name.
Redirect Loop
- Cause: a mismatch between
callback_urlsand the front's settings. - Remedy: make
redirectSignInan 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_unitsexplicitly. - Global logout:
signOut({ global: true })revokes the refresh tokens of all devices. - SAML SLO: when
IDPSignout=trueis set, a signedSAMLRequestflies to the IdP'sSingleLogoutService, and asessionIndexmatch 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
- First decide the plan: the B2B SaaS standard is Essentials. This gives you managed login, passwordless, and access-token extension.
- Don't mis-choose the method: enterprise customers use federation (SAML/OIDC), individuals passwordless (
USER_AUTH), and bespoke requirements onlyCUSTOM_AUTH. - Put tenant separation in the token: inject
tenant_idinto the access token with Pre Token Generation V2.0 and use it consistently in backend authorization. - Leave verification to
aws-jwt-verify: don't hand-writeiss/audorclient_id/token_use/signature. - 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.
References (AWS Official Docs):