Amazon Cognito Federation Complete Implementation Guide - Google, Apple, Microsoft, OIDC, and SAML

First Published:
Last Updated:

This guide is a step-by-step reference for engineers running Amazon Cognito User Pool in production who need to integrate external identity providers — Google, Sign in with Apple, Microsoft Entra ID, generic OIDC providers, and SAML 2.0 IdPs — without getting stuck on the parts that the AWS console does not explain. It covers the OAuth 2.0 Authorization Code Grant + PKCE flow with the Cognito Hosted UI, IdP-by-IdP setup in the provider console, attribute mapping, custom claim injection through Pre Token Generation Lambda triggers, sign-up gating with Pre Sign-up triggers, refresh and revoke semantics, and the typical errors that block the first end-to-end sign-in.

The article assumes you are already comfortable with the basics of Cognito User Pool but want a single reference that covers the federation surface end to end with concrete parameter names, endpoint paths, and Lambda event shapes.

For background on how Amazon Cognito itself has evolved, see AWS History and Timeline regarding Amazon Cognito.

1. Introduction — User Pool vs Identity Pool

Amazon Cognito has two distinct services that are often confused. Understanding the split is the prerequisite for everything that follows.

User Pool is an OIDC-compliant identity provider and user directory. It handles sign-up, sign-in, MFA, federation, and issues three JWTs to your application: an ID token (who the user is), an access token (what the user can do, with scopes), and a refresh token. This is the right surface for application authentication and for protecting your own APIs through API Gateway authorizers.

Identity Pool (also called Federated Identities) is an AWS credential broker. It exchanges an external token — typically a User Pool ID token, but also raw Google, Apple, Microsoft, or SAML assertions — for short-lived AWS credentials via STS AssumeRoleWithWebIdentity. This is the right surface when the browser or mobile app needs to call AWS services (S3, DynamoDB, Kinesis) directly under an IAM role.

The canonical pattern is to chain them: User Pool authenticates the user and issues tokens, then the app sends the ID token to an Identity Pool to obtain AWS credentials. This article focuses on User Pool federation; Identity Pool is mentioned only where it is part of the same flow.

2. Architecture — Authorization Code Grant + PKCE Flow

The Hosted UI (now also branded "Managed Login") is the front door for federation. Your app redirects the user to the Cognito-hosted authorization endpoint, the user is bounced once to the upstream IdP, the IdP redirects back to Cognito's /oauth2/idpresponse, and Cognito then redirects back to your app's callback URL with an authorization code. The app exchanges the code for tokens at the token endpoint.

The full sequence with PKCE looks like this:
1. App generates code_verifier and code_challenge = BASE64URL(SHA256(code_verifier))
   |
   v
2. App redirects browser to:
   GET https://<domain>/oauth2/authorize
       ?response_type=code
       &client_id=<app_client_id>
       &redirect_uri=https://app.example.com/callback
       &scope=openid+email+profile
       &code_challenge_method=S256
       &code_challenge=<challenge>
       &identity_provider=Google   (optional - skip Hosted UI chooser)
   |
   v
3. Cognito redirects to upstream IdP (Google / Apple / Microsoft / OIDC / SAML)
   |
   v
4. User authenticates at the IdP
   |
   v
5. IdP redirects to https://<domain>/oauth2/idpresponse with its own code or assertion
   |
   v
6. Cognito redirects to app:
   GET https://app.example.com/callback?code=<auth_code>&state=<state>
   |
   v
7. App backend exchanges code for tokens:
   POST https://<domain>/oauth2/token
   Content-Type: application/x-www-form-urlencoded
   grant_type=authorization_code
   &code=<auth_code>
   &client_id=<app_client_id>
   &redirect_uri=https://app.example.com/callback
   &code_verifier=<original_verifier>
   |
   v
8. Response: { access_token, id_token, refresh_token, expires_in, token_type }

The five Hosted UI endpoints worth memorizing are:
  • GET /oauth2/authorize — start the flow, accepts response_type, client_id, redirect_uri, scope, code_challenge, code_challenge_method, optionally identity_provider.
  • POST /oauth2/token — exchange authorization_code, refresh_token, or client_credentials for tokens.
  • GET /oauth2/userInfo — return claims for the bearer of an access token.
  • POST /oauth2/revoke — revoke a refresh token (and any tokens derived from it).
  • GET /logout — clear the Cognito session and optionally redirect to logout_uri or redirect_uri.

PKCE is mandatory for public clients (SPAs, mobile apps) and recommended for confidential clients. Cognito only accepts code_challenge_method=S256plain is not supported.

3. Google Sign-In Integration

3.1 Google Cloud Console steps

In the Google Cloud Console for your project:
  1. Open APIs & ServicesOAuth consent screen. Add amazoncognito.com (and your custom domain root, if you use one) to Authorized domains.
  2. Open APIs & ServicesCredentialsCreate CredentialsOAuth client ID. Choose Web application.
  3. Authorized JavaScript origins: https://<domain>.auth.<region>.amazoncognito.com.
  4. Authorized redirect URIs: https://<domain>.auth.<region>.amazoncognito.com/oauth2/idpresponse (this is the Cognito callback, not your application's callback).
  5. Save and copy the Client ID and Client secret.

The redirect URI string here is the single most common cause of redirect_uri_mismatch errors. Match it character-for-character with your Cognito domain — including the region segment.

3.2 Cognito side

In the Cognito console under your User Pool, go to Sign-in experienceFederated identity provider sign-inAdd identity providerGoogle:
  • Client ID / Client secret: paste the Google values.
  • Authorized scopes: profile email openid (space-separated).
  • Attribute mapping: at minimum, map Google email to Cognito email, and Google sub to a username attribute. The Google sub is automatically mapped to the Cognito username field as Google_<sub>.

Then, on each app client, enable Google under Identity providers so it appears on the Hosted UI.

3.3 Triggering Google directly

To skip the Hosted UI provider chooser and send the user straight to Google:
GET https://<domain>/oauth2/authorize
    ?response_type=code
    &client_id=<app_client_id>
    &redirect_uri=<app_callback>
    &scope=openid+email+profile
    &identity_provider=Google
    &code_challenge_method=S256
    &code_challenge=<challenge>

4. Sign in with Apple Integration

Apple's flow has more moving parts than the others because Apple does not issue a permanent client secret. You must create three artifacts in the Apple Developer portal and let Cognito assemble a signed JWT secret on every token call.

4.1 Apple Developer portal artifacts

  1. App ID: under Certificates, Identifiers & ProfilesIdentifiersApp IDs, enable the Sign in with Apple capability for your bundle ID.
  2. Services ID: create a new identifier of type Services IDs. This Services ID is the value Cognito sends to Apple as the OIDC client_id. Under its Sign in with Apple configuration, set Return URLs to https://<domain>.auth.<region>.amazoncognito.com/oauth2/idpresponse.
  3. Sign in with Apple key: under Keys, create a new key, enable Sign in with Apple, configure the primary App ID, then download the .p8 private key file. The file is downloadable only once — store it immediately. Note the Key ID shown next to the key.
  4. Team ID: the 10-character identifier shown at the top right of your Apple Developer account.

4.2 Cognito side

In Cognito add a Sign in with Apple identity provider with these fields:
  • Services ID: from step 2 above.
  • Team ID: from step 4.
  • Key ID: from step 3.
  • Private key: paste the contents of the .p8 file (including the -----BEGIN PRIVATE KEY----- headers).
  • Authorized scopes: email name.

4.3 The "name only on first authorization" caveat

Apple returns the name claim only on the very first authorization for a given Apple ID. If your app calls Apple a second time (for example because you deleted the Cognito user and the user signs in again, or because you added the name scope after the user already authorized your app), Apple will not re-send the name. There is a separate path on the user's iCloud settings page where they can revoke the app, after which the name is returned again on next sign-in.

The practical implication: persist the name, given_name, and family_name claims into your Cognito user attributes on first sign-in via the Pre Sign-up trigger or a post-confirmation lookup. Do not rely on Apple to re-deliver them.

5. Microsoft / Azure AD (Entra ID) Integration

Entra ID is registered as a generic OIDC provider in Cognito. The Microsoft identity platform v2 endpoints are well-known and stable.

5.1 Entra ID app registration

In the Entra admin center under App registrationsNew registration:
  • Redirect URI (Web platform): https://<domain>.auth.<region>.amazoncognito.com/oauth2/idpresponse.
  • After registration, under Certificates & secrets, create a new client secret and copy its value.
  • Under API permissions, add Microsoft Graph delegated permissions: openid, email, profile.

5.2 Cognito side — generic OIDC

In Cognito, add an identity provider of type OpenID Connect (OIDC):
  • Provider name: a unique label, e.g., Microsoft.
  • Client ID: the Application (client) ID from Entra.
  • Client secret: the secret value created above.
  • Authorized scopes: openid email profile.
  • Attributes request method: GET (Microsoft Graph userInfo accepts both, but GET is fine).
  • Setup method: Auto fill through issuer URL.
  • Issuer URL: https://login.microsoftonline.com/<tenant-id>/v2.0.

The four endpoints Cognito will auto-fill from the discovery document are:
  • Authorize: https://login.microsoftonline.com/<tenant>/oauth2/v2.0/authorize
  • Token: https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token
  • UserInfo: https://graph.microsoft.com/oidc/userinfo
  • JWKs: https://login.microsoftonline.com/<tenant>/discovery/v2.0/keys

Use the actual tenant GUID for the issuer URL when you want to scope to a single tenant. Use common to allow both work/school accounts and personal Microsoft accounts. Use organizations to allow any work/school tenant.

5.3 Microsoft claim notes

The claim names that matter for attribute mapping:
  • oid (GUID): the user's immutable object ID within the tenant. This is the stable identifier. Map it to a Cognito user attribute as the canonical user ID.
  • tid (GUID): the tenant ID. The value 9188040d-6c67-4c5b-b112-36a304b66dad indicates a personal Microsoft account.
  • preferred_username: human-readable, typically email-shaped, but mutable. Do not use it for authorization decisions.
  • sub: pairwise per-application identifier; differs across applications even for the same user.

Cognito requires the OIDC IdP to support client_secret_post token endpoint authentication, which Microsoft does.

6. Generic OIDC Provider Integration

Any OIDC-compliant IdP — Auth0, Okta, Keycloak, your in-house Hydra deployment — can be registered the same way as Microsoft. The required Cognito fields are:
FieldDescription
Provider nameUnique label shown in the Hosted UI and used as identity_provider=<name> on /oauth2/authorize.
Client IDOAuth 2.0 client ID issued by the IdP.
Client secretCorresponding client secret. Cognito stores it encrypted.
Authorized scopesSpace-separated scopes Cognito requests. openid is mandatory.
Attributes request methodGET or POST for the userInfo call. Some IdPs require POST.
Issuer URL (Auto)HTTPS URL with no trailing slash. Cognito appends /.well-known/openid-configuration.
Authorize / Token / UserInfo / JWKs URI (Manual)Required only if auto-discovery is unavailable. All HTTPS, no trailing slash.

Cognito requires the IdP to:
  • Sign ID tokens with HMAC-SHA, ECDSA, or RSA algorithms. Cognito may not surface mismatches by reading the IdP's id_token_signing_alg_values_supported claim at the discovery endpoint, so independently confirm the IdP signs with one of these algorithm families before going live.
  • Publish kid values in the JWKs and include kid in token headers.
  • Support client_secret_post client authentication. Cognito has historically used client_secret_post exclusively for external OIDC IdPs and does not negotiate client_secret_basic automatically; verify the current Cognito Developer Guide for your region before relying on any other auth method.

The OIDC sub claim is automatically mapped to the Cognito username, formatted as <provider_name>_<sub>.

7. SAML 2.0 Integration

SAML federation is preferred for enterprise IdPs (ADFS, Shibboleth, PingFederate, Okta SAML, Entra ID Enterprise SAML). Cognito supports SAML 2.0 only — SAML 1.1 is not accepted.

7.1 IdP-side configuration

Provide the IdP with two values:
  • SP Entity ID / Audience URN: urn:amazon:cognito:sp:<userPoolId>. Example: urn:amazon:cognito:sp:us-east-1_EXAMPLE123.
  • Assertion Consumer Service (ACS) URL: https://<domain>.auth.<region>.amazoncognito.com/saml2/idpresponse. The /saml2/idpresponse path is identical for both Cognito-prefixed domains and custom domains.

The IdP issues a metadata XML document. Either upload that XML to Cognito, or supply a metadata URL that Cognito will fetch.

7.2 NameID stability

The SAML NameID element must be case-consistent and stable across sign-ins. Map it to a persistent identifier in your IdP — typically objectGUID for ADFS or oid for Entra. Do not map NameID to email address: if a user changes email, their NameID changes, and Cognito will not be able to match them to their existing profile. Recovering from this requires deleting the user and recreating it.

7.3 SAML attribute mapping

In Cognito, map SAML assertion attribute names (case-sensitive) to User Pool attributes. A typical mapping:
<saml:Attribute Name="email">           ->  email
<saml:Attribute Name="givenName">       ->  given_name
<saml:Attribute Name="surname">         ->  family_name
<saml:Attribute Name="department">      ->  custom:department

8. Attribute Mapping — sub, email, name

Cognito attribute mapping has three layers worth keeping straight:
  1. Reserved standard attributes: OIDC standard claims (email, email_verified, phone_number, name, given_name, family_name, locale, picture, address). These map directly between IdP claims and User Pool attributes.
  2. Custom attributes: prefixed with custom: in the User Pool. They must be declared on the User Pool before mapping.
  3. The sub claim: the IdP's sub is not copied to the Cognito sub. Cognito always generates its own sub (a UUID) for each user profile. The federated IdP's sub is captured in the Cognito username field as <provider_name>_<idpsub> and in the identities array claim of the ID token.

This last point is what makes account-linking tricky. If a user signs in with Google one day and Microsoft the next, by default they end up as two separate Cognito profiles: Google_117... and Microsoft_b8e..., each with a different Cognito sub. Linking them requires the AdminLinkProviderForUser API (covered in §10).

Note: mark email_verified as required-and-mapped only if the upstream IdP actually sets it. Google does. Apple sets it but only because Apple verifies all Apple IDs by definition. Some self-hosted OIDC providers omit it, in which case mapping it without a default leads to sign-in failures.

9. Pre Token Generation Trigger — Custom Claim Injection

The Pre Token Generation (PreTGT) Lambda trigger is the clean way to add tenant IDs, role names, feature flags, or anything else your application needs in the JWT without round-tripping to a database on every API call.

There are three event versions:
  • V1_0: ID token only. String values only. Available on all tiers.
  • V2_0: Adds access token customization for human user flows. Supports complex types (objects, arrays, numbers, booleans). Requires the Essentials or Plus tier. (Some pools configured under the pre-November 2024 pricing model may retain V2_0 access depending on their existing configuration; for any new pool, select Essentials or higher to enable V2_0.)
  • V3_0: Adds the TokenGeneration_ClientCredentials trigger source for M2M flows. Essentials or Plus tier only — not available on Lite, even with Advanced Security.

V2_0 is what most production apps want — the access token is what API Gateway and resource servers actually verify.

9.1 V2_0 event shape

{
  "version": "2",
  "triggerSource": "TokenGeneration_HostedAuth",
  "userPoolId": "us-east-1_EXAMPLE",
  "userName": "Google_1078431...",
  "request": {
    "userAttributes": {
      "email": "user@example.com",
      "custom:tenant_id": "tenant-42"
    },
    "groupConfiguration": {
      "groupsToOverride": ["admins"],
      "iamRolesToOverride": [],
      "preferredRole": null
    },
    "scopes": ["openid", "email", "profile"]
  },
  "response": {}
}

The event also carries an optional request.clientMetadata map when the client passes ClientMetadata into InitiateAuth / RespondToAuthChallenge. Use it to feed per-request context (correlation IDs, tenant hints) into the Lambda without persisting them on the user record.

9.2 Sample Lambda — adding a tenant claim to the access token

def lambda_handler(event, context):
    tenant_id = event["request"]["userAttributes"].get("custom:tenant_id", "default")
    roles = ["reader"]
    # On the request side, request.groupConfiguration.groupsToOverride contains the user's
    # currently resolved Cognito group memberships at trigger time -- the same field name is
    # reused on the response side to *override* group membership for the issued tokens.
    # Treat the request value as read-only group membership; do not confuse it with the
    # response field of the same name.
    if "admins" in event["request"]["groupConfiguration"].get("groupsToOverride", []):
        roles.append("admin")

    event["response"] = {
        "claimsAndScopeOverrideDetails": {
            "idTokenGeneration": {
                "claimsToAddOrOverride": {
                    "tenant_id": tenant_id,
                    "roles": roles
                }
            },
            "accessTokenGeneration": {
                "claimsToAddOrOverride": {
                    "tenant_id": tenant_id,
                    "roles": roles
                },
                "scopesToAdd": ["custom-resource-server/read"]
            }
        }
    }
    return event

Trigger sources you should handle:
  • TokenGeneration_HostedAuth — Hosted UI / Managed Login sign-in.
  • TokenGeneration_Authentication — API-driven InitiateAuth flows.
  • TokenGeneration_RefreshTokens — every refresh. Custom claims must be re-added on refresh, otherwise they vanish.
  • TokenGeneration_NewPasswordChallenge — admin-created users on first sign-in.
  • TokenGeneration_AuthenticateDevice — end of a device authentication flow (when device tracking is enabled on the User Pool).
  • TokenGeneration_ClientCredentials — V3_0 only, M2M flows.

Reserved claims (sub, iss, aud, exp, iat, auth_time, jti, token_use, cognito:username, client_id) cannot be modified or suppressed.

10. Pre Sign-up Trigger — Auto-Confirm and Domain Allow-List

The Pre Sign-up trigger fires for self-service SignUp, first federated sign-in via an external IdP (this is the surprising one), and AdminCreateUser.

For federated sign-ins, this is your hook to:
  1. Auto-confirm the user.
  2. Restrict by domain (corporate allow-list).
  3. Link a fresh federated identity to a pre-existing native Cognito profile, avoiding the "two profiles for the same human" problem.

10.1 Auto-confirm + domain allow-list

ALLOWED_DOMAINS = {"example.com", "example.co.jp"}

def lambda_handler(event, context):
    email = event["request"]["userAttributes"].get("email", "")
    domain = email.split("@")[-1].lower() if "@" in email else ""
    if domain not in ALLOWED_DOMAINS:
        raise Exception("Sign-up not permitted from this email domain")

    if event["triggerSource"] == "PreSignUp_ExternalProvider":
        event["response"]["autoConfirmUser"] = True
        event["response"]["autoVerifyEmail"] = True

    return event

10.2 Linking a federated user to an existing native account

When a user previously signed up with email/password and later tries to federate with the same email through Google, Cognito by default creates a second profile (Google_<sub>). To merge them, call AdminLinkProviderForUser from the Pre Sign-up trigger:
import boto3

cognito = boto3.client("cognito-idp")

def lambda_handler(event, context):
    if event["triggerSource"] != "PreSignUp_ExternalProvider":
        return event

    email = event["request"]["userAttributes"]["email"]
    # Cognito sets userName = "<provider_name>_<idp_sub>" for federated sign-ins.
    # Use rsplit so a provider name that contains underscores (e.g., "Corp_OIDC") still
    # parses correctly, on the assumption that the IdP sub itself does not contain "_".
    # If you allow OIDC IdPs whose sub may contain underscores, look the value up in
    # event["request"]["userAttributes"]["identities"] (a JSON-encoded string holding
    # an array of provider/userId pairs -- json.loads() it before lookup) instead of
    # parsing userName.
    provider_name, provider_sub = event["userName"].rsplit("_", 1)

    # Cognito Filter values are wrapped in double quotes; if your IdP can deliver an
    # email containing a literal '"', escape (or reject) it before interpolation to
    # avoid both filter-syntax breakage and injection-style confusion.
    existing = cognito.list_users(
        UserPoolId=event["userPoolId"],
        Filter=f'email = "{email}"',
        Limit=1
    )["Users"]
    if existing:
        cognito.admin_link_provider_for_user(
            UserPoolId=event["userPoolId"],
            DestinationUser={
                "ProviderName": "Cognito",
                "ProviderAttributeValue": existing[0]["Username"]
            },
            SourceUser={
                "ProviderName": provider_name,
                "ProviderAttributeName": "Cognito_Subject",
                "ProviderAttributeValue": provider_sub
            }
        )
        event["response"]["autoConfirmUser"] = True
        event["response"]["autoVerifyEmail"] = True

    return event

A few details about the call worth flagging:
  • DestinationUser.ProviderName="Cognito" identifies the existing native Cognito user as the merge target. ProviderAttributeValue must be the Cognito username (the UUID-shaped value), not the email.
  • SourceUser.ProviderAttributeName="Cognito_Subject" is a fixed magic string that tells Cognito to match the source identity by its OIDC sub (or SAML NameID). Other values like email are not accepted here.
  • Up to 5 federated identities can be linked to one user profile. Each link counts as 1 MAU when used.
  • The link is recorded in the identities array claim of the resulting ID token, so downstream services can see which IdP the current session originated from.

11. User Migration Trigger — Onboarding Existing Users from a Legacy System

The User Migration Lambda trigger is the path for cutting over from an existing authentication system (Active Directory, an in-house user table, Auth0, a homegrown JWT issuer) without forcing every user to re-register. When a user signs in to Cognito with a username that does not yet exist in the pool, Cognito invokes this trigger; if it returns successfully, Cognito creates the user, sets the supplied attributes, and issues tokens in the same call.

Two trigger sources:
  • UserMigration_Authentication — fires on a sign-in attempt for an unknown username. The user-supplied password is available in event.request.password — validate it against your legacy system.
  • UserMigration_ForgotPassword — fires on the forgot-password flow for an unknown username. No password is supplied; you typically just confirm that the user exists in the legacy system and let Cognito handle the verification code.

11.1 Sample Lambda — authenticating against a legacy DB

def lambda_handler(event, context):
    username = event["userName"]
    password = event["request"].get("password", "")

    if event["triggerSource"] == "UserMigration_Authentication":
        profile = legacy_authenticate(username, password)  # returns dict or None
        if profile is None:
            raise Exception("Bad credentials")
        event["response"] = {
            "userAttributes": {
                "email": profile["email"],
                "email_verified": "true",
                "given_name": profile["given_name"],
                "family_name": profile["family_name"],
            },
            "finalUserStatus": "CONFIRMED",   # skip the e-mail verification step
            "messageAction": "SUPPRESS",      # do not send a welcome message
        }
    elif event["triggerSource"] == "UserMigration_ForgotPassword":
        profile = legacy_lookup(username)
        if profile is None:
            raise Exception("User not found")
        event["response"] = {
            "userAttributes": {
                "email": profile["email"],
                "email_verified": "true",
            },
            "messageAction": "SUPPRESS",
        }
    return event

11.2 Constraints worth knowing up front

  • The Lambda must respond within Cognito's short synchronous-trigger window (commonly observed at around 5 seconds and well below the general Lambda 15-minute limit). If your legacy lookup is slow, front it with a cache (DynamoDB, ElastiCache) or run the validation against a fast read replica. Treat any cold-start risk as a real failure mode and budget for it with provisioned concurrency or warm-keep traffic.
  • The app client must have ALLOW_USER_PASSWORD_AUTH (or ALLOW_ADMIN_USER_PASSWORD_AUTH for admin-driven flows) enabled. With ALLOW_USER_SRP_AUTH only, Cognito performs SRP first, decides the user is unknown, and never invokes the trigger.
  • The trigger fires once per user. After migration, the user is in the pool and subsequent sign-ins go through the normal SRP/password path without re-invoking the migration Lambda.
  • Returning finalUserStatus: "CONFIRMED" skips the post-sign-up confirmation step. Use "RESET_REQUIRED" instead if you want the user to set a new Cognito-managed password on first sign-in — useful when you cannot or do not want to verify legacy passwords directly.
  • Federation users do not hit this trigger — federated sign-ups go through Pre Sign-up (§10) instead.

12. Token Refresh and Revoke

12.1 Refresh

POST https://<domain>/oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=<refresh_token>
&client_id=<app_client_id>

Without refresh token rotation enabled, the response contains a fresh access_token and id_token but no new refresh_token. With rotation enabled, a new refresh token is issued and the old one is immediately revoked.

Validity defaults and ranges:
TokenDefaultMinMax
Access token1 hour5 minutes24 hours
ID token1 hour5 minutes24 hours
Refresh token30 days60 minutes10 years

12.2 Revoke

POST https://<domain>/oauth2/revoke
Content-Type: application/x-www-form-urlencoded
Authorization: Basic BASE64(client_id:client_secret)   (only for confidential clients)

token=<refresh_token>
&client_id=<app_client_id>

Submitting an access token returns HTTP 400 unsupported_token_type. Successful revocation returns HTTP 200 with an empty body. The endpoint is idempotent — revoking an already-revoked token also returns 200.

12.3 The revocation caveat every team gets wrong

When you revoke a refresh token, Cognito marks the refresh token and all access/ID tokens derived from it as revoked inside Cognito. However, the JWTs that have already been handed out remain cryptographically valid until they expire naturally. From the AWS docs:
Revoked tokens can't be used with any Amazon Cognito API calls that require a token. However, revoked tokens will still be valid if they are verified using any JWT library that verifies only the signature and expiration of the token.

If your resource servers verify access tokens locally with a JWKs cache and the standard iss / exp / aud checks (the typical pattern), revocation does not stop them from accepting the token until it expires. To enforce revocation in real time you must either call /oauth2/userInfo on every request (expensive) or shorten access token lifetime to a value the business can tolerate. Most teams settle on 5–15 minute access tokens and accept the gap.

For a true global sign-out, use GlobalSignOut (user-side) or AdminUserGlobalSignOut (admin side) — these revoke all refresh tokens for the user across all sessions.

12.4 Local JWT verification with JWKs caching

The pattern below is the production-grade counterpart to §12.3: verify the token's signature against the User Pool's published JWKs, and validate every standard claim that matters. This is what an API Gateway custom authorizer or a service-side middleware should do on every request — subject to the revocation gap noted above.
"""Local Cognito JWT verification.

pip install pyjwt[crypto] requests
"""
from functools import lru_cache
from typing import Any

import jwt
import requests
from jwt.algorithms import RSAAlgorithm

REGION = "us-east-1"
USER_POOL_ID = "us-east-1_EXAMPLE"
APP_CLIENT_ID = "2abc3defgh4ijklmn5opqrstuvwx"
ISSUER = f"https://cognito-idp.{REGION}.amazonaws.com/{USER_POOL_ID}"


@lru_cache(maxsize=1)
def _jwks() -> dict[str, Any]:
    """Fetch the JWKs once per process. Rotate by restarting or by adding a TTL."""
    response = requests.get(f"{ISSUER}/.well-known/jwks.json", timeout=2)
    response.raise_for_status()
    return {key["kid"]: key for key in response.json()["keys"]}


def _public_key_for(token: str):
    headers = jwt.get_unverified_header(token)
    kid = headers["kid"]
    jwk = _jwks().get(kid)
    if jwk is None:
        # The signing key rotated; clear the cache and try once more before failing.
        _jwks.cache_clear()
        jwk = _jwks().get(kid)
        if jwk is None:
            raise jwt.InvalidTokenError(f"Unknown kid: {kid}")
    return RSAAlgorithm.from_jwk(jwk)


def verify_token(token: str, expected_use: str) -> dict[str, Any]:
    """Verify signature + standard claims. expected_use is "id" or "access"."""
    public_key = _public_key_for(token)

    # PyJWT validates exp/iat/nbf and signature for us. We add iss + aud/client_id + token_use.
    options = {"require": ["exp", "iat", "iss", "sub"]}
    decoded = jwt.decode(
        token,
        public_key,
        algorithms=["RS256"],
        issuer=ISSUER,
        # ID tokens carry the app client ID in `aud`; access tokens carry it in `client_id`
        # and have no `aud`, so we verify `aud` only for ID tokens.
        audience=APP_CLIENT_ID if expected_use == "id" else None,
        options=options,
        leeway=30,  # tolerate small clock skew between Cognito and the verifier
    )

    if decoded.get("token_use") != expected_use:
        raise jwt.InvalidTokenError(
            f"token_use mismatch: expected {expected_use}, got {decoded.get('token_use')}"
        )
    if expected_use == "access" and decoded.get("client_id") != APP_CLIENT_ID:
        raise jwt.InvalidTokenError("Access token client_id does not match app client")
    return decoded


# Usage in an API Gateway Lambda authorizer:
def lambda_handler(event, context):
    token = event["authorizationToken"].removeprefix("Bearer ").strip()
    try:
        claims = verify_token(token, expected_use="access")
    except jwt.PyJWTError as e:
        raise Exception("Unauthorized") from e  # API Gateway maps this to 401
    return {
        "principalId": claims["sub"],
        "policyDocument": {
            "Version": "2012-10-17",
            "Statement": [{
                "Action": "execute-api:Invoke",
                "Effect": "Allow",
                "Resource": event["methodArn"],
            }],
        },
        "context": {
            "tenant_id": claims.get("tenant_id", ""),
            "scope": claims.get("scope", ""),
        },
    }

Two operational reminders that flow from §12.3:
  • This verifier does not consult Cognito at request time, so a revoked token continues to pass until exp. Pair short access-token lifetimes (5–15 minutes) with revocation if revocation matters to your threat model.
  • The JWKs cache must survive across requests but must also self-heal on key rotation. The cache_clear() retry above handles the rare case where Cognito has rotated keys since the cache was populated. For higher-volume services, replace lru_cache with a TTL cache (e.g., cachetools.TTLCache with a 1–6 hour TTL) so rotation propagates without restarts.

13. App Client Configuration in Depth

Every Cognito User Pool app client is the trust boundary that governs how tokens are requested, which identity providers are available, what scopes are allowed, and how long tokens live. The Cognito console exposes these as a set of checkboxes and fields; this section maps each one to the underlying behavior and explains when to change the defaults.

13.1 OAuth 2.0 allowed flows

Each app client declares which grant types it accepts on the /oauth2/token endpoint:

FlowWhen to useNotes
Authorization code (code) SPAs, web apps, mobile apps — any flow where a human logs in through the Hosted UI. Mandatory for PKCE. The only flow compatible with federation. Always enable this.
Implicit (token) Legacy SPAs that cannot perform a back-channel code exchange. Tokens are returned directly in the fragment, bypassing PKCE. Deprecated by OAuth 2.1. Use authorization code instead. Do not enable unless interoperating with an existing legacy client you cannot change.
Client credentials (client_credentials) Machine-to-machine (M2M) service accounts: background workers, server-to-server API calls. No user involved. Requires a client secret. Cannot be used with the Hosted UI. The app client must have a resource server scope assigned. Incompatible with federation (there is no user to federate).

13.2 Allowed scopes

Scopes on a Cognito app client fall into four categories:

ScopeWhat it grants
openidRequired to receive an ID token. Without it, /oauth2/token returns only an access token.
emailAdds the email and email_verified claims to the ID token and the /oauth2/userInfo response.
profileAdds standard OIDC profile claims: name, given_name, family_name, picture, locale, updated_at, etc.
phoneAdds phone_number and phone_number_verified.
aws.cognito.signin.user.adminAllows the bearer of the access token to call Cognito API operations that require a user token rather than IAM credentials — GetUser, UpdateUserAttributes, ChangePassword, etc. Enable only for app clients where your application code calls these APIs on behalf of the signed-in user.
<resource-server-identifier>/<scope-name>Custom scopes you define on a resource server. These appear in the access token and are consumed by your API. They are the correct mechanism for M2M client credentials flows.

The set of scopes on the app client is a maximum allowed list, not the set that every token will contain. The actual scopes in a token are the intersection of what the client allowed and what the client requested on /oauth2/authorize.

13.3 Callback URL rules

Cognito enforces strict redirect URI matching. The rules:
  • The URI sent as redirect_uri on /oauth2/authorize must be an exact byte-for-byte match with one entry in the app client's callback URL list. Case matters. Trailing slashes matter.
  • Wildcards are not supported. There is no prefix matching or glob syntax. Each URL must be registered individually.
  • All production URLs must use https://. The single exception: http://localhost (any port) is accepted for local development.
  • Fragment identifiers (#...) in callback URLs are not allowed. PKCE relies on the authorization code being delivered as a query parameter, not a fragment.
  • Custom URI schemes (e.g., myapp://callback) are supported for mobile apps.
  • Up to 100 callback URLs are allowed per app client.

The same rules apply to the sign-out URL list. The GET /logout endpoint accepts a logout_uri (for OIDC-style logout) or a redirect_uri (for the legacy logout endpoint). The supplied value must match one entry in the sign-out URL list exactly. If no match, Cognito redirects to the Hosted UI instead of your URL.

13.4 PKCE and the S256 requirement

For public clients (SPAs and mobile apps), PKCE is the mechanism that prevents authorization code interception. Cognito only accepts code_challenge_method=S256. The plain method is not supported. The PKCE flow produces a code_verifier (a high-entropy random string, 43–128 characters, Base64URL characters only) and a code_challenge = BASE64URL(SHA256(ASCII(code_verifier))).

For confidential clients (server-side web apps with a back-channel), PKCE is optional but still recommended as defense in depth. The app client should have a generated secret, which must be sent as Authorization: Basic BASE64(client_id:client_secret) on each /oauth2/token request.

Deciding whether to generate a client secret:
  • Generate a secret (confidential client): server-side web apps where the secret is never sent to a browser, M2M client credentials clients.
  • No secret (public client): SPAs, native mobile apps. The secret cannot be stored safely in JavaScript running in a browser or in a distributed mobile binary. Use PKCE as the equivalent protection.

13.5 Token validity periods

The table in §12.1 shows the defaults and ranges. Some operational guidance:
  • Set access token lifetime to the shortest duration that users will accept. Five minutes is aggressive but dramatically reduces the revocation gap. Fifteen minutes is a common compromise.
  • ID token lifetime should match access token lifetime unless your app has a specific reason to differ. They are issued together and both carry user identity claims.
  • Refresh token lifetime reflects session length. A web app used during business hours might use 8 hours (so users sign in once per workday). A consumer mobile app might use 30 days. Do not set it longer than the business requires — every active refresh token is a standing session.
  • With refresh token rotation enabled, each refresh_token grant issues a new refresh token and immediately revokes the old one. This gives you a record of all active sessions through your token usage, and a stolen refresh token becomes detectable if both the legitimate client and the attacker try to use it (the second use fails with invalid_grant). Enable rotation for any app that touches sensitive data.

13.6 Read and write attribute permissions

Each app client has an independent set of read and write permissions per User Pool attribute. A common hardening step is to make sensitive attributes — such as custom:admin_role or custom:verified_atread-only from the app client so that a user's own access token cannot be used to self-elevate. Only an administrator using IAM credentials can write those attributes via AdminUpdateUserAttributes.

13.7 Full CloudFormation resource example

Resources:
  AppClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      UserPoolId: !Ref UserPool
      ClientName: my-spa-client
      GenerateSecret: false                   # public client (SPA)
      AllowedOAuthFlows:
        - code
      AllowedOAuthScopes:
        - openid
        - email
        - profile
        - aws.cognito.signin.user.admin
      AllowedOAuthFlowsUserPoolClient: true   # required to enable OAuth 2.0
      CallbackURLs:
        - https://app.example.com/callback
        - http://localhost:3000/callback      # dev only
      LogoutURLs:
        - https://app.example.com/logout
        - http://localhost:3000/logout
      SupportedIdentityProviders:
        - COGNITO
        - Google
        - Microsoft                           # the provider name you used in Cognito
      ExplicitAuthFlows:
        - ALLOW_REFRESH_TOKEN_AUTH
        - ALLOW_USER_SRP_AUTH
        - ALLOW_CUSTOM_AUTH
      PreventUserExistenceErrors: ENABLED  # YAML scalar, parsed as the literal string "ENABLED" by CloudFormation. Quotes are optional.
      EnableTokenRevocation: true
      TokenValidityUnits:
        AccessToken: minutes
        IdToken: minutes
        RefreshToken: days
      AccessTokenValidity: 15
      IdTokenValidity: 15
      RefreshTokenValidity: 30
      ReadAttributes:
        - email
        - email_verified
        - given_name
        - family_name
        - custom:tenant_id
      WriteAttributes:
        - email
        - given_name
        - family_name

AllowedOAuthFlowsUserPoolClient: true is a field that the console sets automatically but that CloudFormation requires you to set explicitly. Omitting it causes the OAuth flow to silently fail.

13.8 Custom domain — serving the Hosted UI from your own domain

By default, the Hosted UI is served from https://<domain-prefix>.auth.<region>.amazoncognito.com. For a production deployment you usually want your own subdomain (auth.example.com) so that users see your brand on the sign-in screen, and so that cookies stay first-party with your application. Cognito supports a custom domain that maps to that prefix domain.

Hard requirements that catch first-time users:
  • The ACM certificate must be in us-east-1 — regardless of which region your User Pool lives in. Cognito custom domains terminate at a CloudFront distribution that AWS provisions on your behalf, and CloudFront only reads ACM certs from us-east-1. If you create the cert in your pool's region, the create_user_pool_domain call fails with InvalidParameterException.
  • The custom domain must be a subdomain — an apex domain (example.com) cannot be used. Pick something like auth.example.com.
  • The parent domain must already have an A or AAAA record in public DNS before you call create_user_pool_domain. Cognito performs an ownership check by resolving the parent at the time the domain is created. If the parent has no record yet, the call fails.
  • You cannot change the domain prefix or the custom domain after creation — you must delete the domain and re-create it. Plan the name carefully.

Wiring it up:
  1. Request or import an ACM certificate for auth.example.com in us-east-1. Validate it via DNS.
  2. Call create_user_pool_domain with Domain="auth.example.com" and CustomDomainConfig={"CertificateArn": "arn:aws:acm:us-east-1:<account>:certificate/..."}. The response includes a CloudFrontDomain like d1abc2def3ghij.cloudfront.net.
  3. Create a Route 53 (or third-party DNS) Alias / CNAME record from auth.example.com to that CloudFront domain.
  4. Update each app client's callback URLs and the IdP redirect URIs (Google, Apple, Microsoft, SAML ACS) to point to the new https://auth.example.com/oauth2/idpresponse — the IdP-side redirect target moves with the domain.

cognito.create_user_pool_domain(
    UserPoolId=user_pool_id,
    Domain="auth.example.com",
    CustomDomainConfig={
        "CertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/abc123...",
    },
)

The Cognito-prefixed domain (<prefix>.auth.<region>.amazoncognito.com) and a custom domain are mutually exclusive on the same User Pool — you can have one or the other, not both. Migrating an existing pool from prefix to custom requires a brief cutover where the prefix domain is deleted, the custom domain is created, and all IdP redirect URIs are updated; plan a maintenance window or run the new domain in parallel in a second pool until cut-over.

The cutover also breaks active Hosted UI sessions. Cognito session cookies are scoped to the domain that issued them, so once the user-facing host changes, every in-flight session is effectively logged out and users are forced to re-authenticate at the new domain. This is benign but visible — communicate the timing and expect a one-time spike of sign-in events.

14. AWS Amplify JS and Custom Authentication Flows

Scope note: The Amplify JS v6 examples below were verified against aws-amplify@6.x (Amplify Gen 2) as of April 2026. Frontend SDKs evolve faster than this article will, so for production code, follow the official Amplify documentation linked in each subsection — the API surface here is illustrative, not authoritative.

14.1 AWS Amplify Auth v6 (Gen 2)

The AWS Amplify library v6 wraps the Cognito endpoint calls behind a typed TypeScript API that handles PKCE, token storage, and refresh automatically. The top-level functions you interact with:

import {
  signIn,
  signInWithRedirect,
  signOut,
  getCurrentUser,
  fetchAuthSession,
} from "aws-amplify/auth";
import { Amplify } from "aws-amplify";

Before calling any Auth function, configure Amplify with your pool identifiers once at application startup:
Amplify.configure({
  Auth: {
    Cognito: {
      userPoolId: "us-east-1_EXAMPLE",
      userPoolClientId: "2abc3defgh4ijklmn5opqrstuvwx",
      loginWith: {
        oauth: {
          domain: "auth.example.com",
          scopes: ["openid", "email", "profile"],
          redirectSignIn: ["https://app.example.com/callback"],
          redirectSignOut: ["https://app.example.com/logout"],
          responseType: "code",           // always use "code" (PKCE)
        },
        google: true,                     // enables signInWithRedirect({ provider: "Google" })
      },
    },
  },
});

14.2 Federated sign-in via Hosted UI / Managed Login

The simplest path: redirect the browser to the Hosted UI so it handles the IdP chooser, then catch the callback.
// Redirect to Cognito Hosted UI; user picks provider there
async function loginWithHostedUI(): Promise<void> {
  await signInWithRedirect();
}

// Redirect directly to Google, skipping the Hosted UI chooser
async function loginWithGoogle(): Promise<void> {
  await signInWithRedirect({ provider: "Google" });
}

// For a custom OIDC provider registered as "Microsoft" in Cognito.
// Always confirm the latest signature in the official Amplify Auth API reference:
// https://docs.amplify.aws/react/build-a-backend/auth/concepts/external-identity-providers/
async function loginWithMicrosoft(): Promise<void> {
  await signInWithRedirect({
    provider: { custom: "Microsoft" },
  });
}

// In your callback route, retrieve the session after redirect
async function handleCallback(): Promise<void> {
  const user = await getCurrentUser();       // { userId, username }
  const session = await fetchAuthSession();  // { tokens, credentials, identityId }
  const idToken = session.tokens?.idToken?.toString();
  const accessToken = session.tokens?.accessToken?.toString();
  console.log("Signed in as", user.username, "tenant:", session.tokens?.idToken?.payload["tenant_id"]);
}

async function logout(): Promise<void> {
  // global: true calls Cognito's GlobalSignOut to revoke all refresh tokens for the user.
  // The browser is redirected to the configured oauth.redirectSignOut only when OAuth is
  // configured on Amplify; without OAuth, signOut just clears the local token store and
  // returns without navigation. Drive your own router after the promise resolves if you
  // need to land the user on a specific page in either case.
  await signOut({ global: true });
}

fetchAuthSession() returns the cached tokens if they are still valid. Pass { forceRefresh: true } to force a refresh token call regardless of expiry. The library stores tokens in the localStorage by default; set Auth.Cognito.tokenProvider to use sessionStorage or an in-memory store if you need stricter isolation.

14.3 API-driven sign-in (custom UI, no Hosted UI redirect)

When you build your own login form instead of using the Hosted UI, call signIn with the username and password directly. The function returns a SignInOutput that describes whether sign-in is complete or requires a next step:
import { signIn, confirmSignIn } from "aws-amplify/auth";
import type { SignInOutput } from "aws-amplify/auth";

async function handleLogin(username: string, password: string): Promise<void> {
  const output: SignInOutput = await signIn({ username, password });

  switch (output.nextStep.signInStep) {
    case "DONE":
      // Sign-in complete; tokens are stored by Amplify
      break;
    case "CONFIRM_SIGN_IN_WITH_SMS_MFA_CODE":
    case "CONFIRM_SIGN_IN_WITH_EMAIL_CODE": {
      const code = await promptUserForCode();  // your UI collects the MFA code
      await confirmSignIn({ challengeResponse: code });
      break;
    }
    case "CONFIRM_SIGN_IN_WITH_TOTP_CODE": {
      const totpCode = await promptUserForTOTP();
      await confirmSignIn({ challengeResponse: totpCode });
      break;
    }
    case "CONFIRM_SIGN_IN_WITH_NEW_PASSWORD_REQUIRED": {
      const newPassword = await promptForNewPassword();
      await confirmSignIn({ challengeResponse: newPassword });
      break;
    }
    case "CONFIRM_SIGN_IN_WITH_CUSTOM_CHALLENGE": {
      const answer = await promptForCustomChallenge(
        output.nextStep.additionalInfo  // parameters returned by CreateAuthChallenge
      );
      await confirmSignIn({ challengeResponse: answer });
      break;
    }
    default:
      console.warn("Unhandled sign-in step:", output.nextStep.signInStep);
  }
}

14.4 Custom Authentication Challenge flow

Cognito's Custom Authentication challenge flow lets you replace or augment the password check with any verification logic: a one-time passcode delivered by a different channel, a CAPTCHA, a hardware token, or a magic link. Three Lambda triggers drive the flow in sequence:
TriggerResponsibility
DefineAuthChallenge Called first. Decides which challenge to issue (or to pass / fail the sign-in) based on the session state. Returns challengeName (CUSTOM_CHALLENGE, PASSWORD_VERIFIER, or SRP_A) and issueTokens / failAuthentication flags.
CreateAuthChallenge Called when DefineAuthChallenge issues a challenge. Generates the challenge artifact (e.g., creates a one-time code, stores it in DynamoDB) and returns publicChallengeParameters (sent to the client) and privateChallengeParameters (kept server-side for verification).
VerifyAuthChallengeResponse Called when the client returns an answer. Compares the answer against privateChallengeParameters and returns answerCorrect: true or false. DefineAuthChallenge is called again after this with the full session array so it can decide whether to issue tokens or re-challenge.

A minimal email OTP example:
import boto3
import os
import random
import string

dynamodb = boto3.resource("dynamodb")
ses = boto3.client("ses")
TABLE = os.environ["OTP_TABLE"]


# --- DefineAuthChallenge ---
def define_auth_challenge(event, context):
    session = event["request"]["session"]
    if len(session) == 0:
        # First call: issue custom challenge
        event["response"]["challengeName"] = "CUSTOM_CHALLENGE"
        event["response"]["failAuthentication"] = False
        event["response"]["issueTokens"] = False
    elif (
        len(session) == 1
        and session[0]["challengeName"] == "CUSTOM_CHALLENGE"
        and session[0]["challengeResult"] is True
    ):
        # Challenge was answered correctly: issue tokens
        event["response"]["issueTokens"] = True
        event["response"]["failAuthentication"] = False
    elif len(session) >= 3:
        # Hard cap on attempts. Cognito itself stops after the third RespondToAuthChallenge
        # call by returning NotAuthorizedException, but failing explicitly here makes the
        # intent obvious and avoids relying on undocumented behavior.
        event["response"]["issueTokens"] = False
        event["response"]["failAuthentication"] = True
    else:
        # Wrong answer but attempts remaining: re-issue the challenge.
        event["response"]["challengeName"] = "CUSTOM_CHALLENGE"
        event["response"]["issueTokens"] = False
        event["response"]["failAuthentication"] = False
    return event


# --- CreateAuthChallenge ---
def create_auth_challenge(event, context):
    if event["request"]["challengeName"] != "CUSTOM_CHALLENGE":
        return event
    email = event["request"]["userAttributes"]["email"]
    otp = "".join(random.choices(string.digits, k=6))
    # Store OTP with TTL
    dynamodb.Table(TABLE).put_item(Item={
        "username": event["userName"],
        "otp": otp,
        "ttl": int(__import__("time").time()) + 300,  # 5 minutes
    })
    ses.send_email(
        Source="no-reply@example.com",
        Destination={"ToAddresses": [email]},
        Message={
            "Subject": {"Data": "Your sign-in code"},
            "Body": {"Text": {"Data": f"Your code: {otp}"}},
        },
    )
    event["response"]["publicChallengeParameters"] = {"delivery": "email"}
    event["response"]["privateChallengeParameters"] = {"otp": otp}
    event["response"]["challengeMetadata"] = "EMAIL_OTP"
    return event


# --- VerifyAuthChallengeResponse ---
def verify_auth_challenge_response(event, context):
    expected = event["request"]["privateChallengeParameters"]["otp"]
    actual = event["request"]["challengeAnswer"]
    event["response"]["answerCorrect"] = (actual == expected)
    return event

14.5 Hosted UI vs. custom Amplify-driven UI — when to use which

CriterionHosted UI / Managed LoginCustom UI (Amplify signIn / custom auth)
Time to first working loginMinutes (just configure the app client)Hours to days (build, test, and accessibility-harden all screens)
Federation supportAll configured IdPs with no extra codeOnly native Cognito flows; for federation you must still redirect to Hosted UI via signInWithRedirect
Branding / UX controlLimited to Managed Login theme editor (colors, logo, CSS overrides)Full control over every pixel
Custom authentication challengesNot possible — Hosted UI only shows SMS/TOTP MFA challengesFull custom challenge flow (email OTP, magic link, hardware token)
Compliance (e.g., WCAG)AWS manages accessibility; verify against your requirementsYour responsibility
Cognito tier requirementManaged Login requires Essentials or PlusCustom UI uses API flows; Lite tier is sufficient for basic cases

The most common pattern in production is a hybrid: use signInWithRedirect for all federated flows (Google, Apple, SAML) and a custom form for the native username/password path. This avoids building your own OAuth client while still giving you full UX control for the direct login experience.

15. boto3 Pool Management Examples

This section covers the operational Python patterns most frequently needed after initial deployment: creating users programmatically, managing group membership, querying the user directory, and handling errors robustly.

15.1 Create a User Pool programmatically

import boto3

cognito = boto3.client("cognito-idp", region_name="us-east-1")

response = cognito.create_user_pool(
    PoolName="my-app-users",
    Policies={
        "PasswordPolicy": {
            "MinimumLength": 12,
            "RequireUppercase": True,
            "RequireLowercase": True,
            "RequireNumbers": True,
            "RequireSymbols": True,
            "TemporaryPasswordValidityDays": 7,
        }
    },
    AutoVerifiedAttributes=["email"],
    MfaConfiguration="OPTIONAL",           # "OFF" | "OPTIONAL" | "ON"
    Schema=[
        {
            "Name": "tenant_id",
            "AttributeDataType": "String",
            "Mutable": True,
            "Required": False,
            "StringAttributeConstraints": {"MinLength": "1", "MaxLength": "128"},
        }
    ],
    AccountRecoverySetting={
        "RecoveryMechanisms": [
            {"Priority": 1, "Name": "verified_email"},
        ]
    },
    EmailConfiguration={
        "EmailSendingAccount": "DEVELOPER",         # use SES
        "SourceArn": "arn:aws:ses:us-east-1:123456789012:identity/no-reply@example.com",
        "From": "no-reply@example.com",
    },
    UserPoolTier="ESSENTIALS",   # Added with the November 2024 pricing transition;
                                  # requires boto3 1.35.x or newer. On older SDKs the
                                  # parameter is silently rejected and the pool is created
                                  # in the legacy default tier.
)

user_pool_id = response["UserPool"]["Id"]
print("Created:", user_pool_id)

15.2 Register an OIDC identity provider on an existing pool

cognito.create_identity_provider(
    UserPoolId=user_pool_id,
    ProviderName="Microsoft",
    ProviderType="OIDC",
    ProviderDetails={
        "client_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
        "client_secret": "<from-secrets-manager>",
        "attributes_request_method": "GET",
        "oidc_issuer": "https://login.microsoftonline.com/<tenant-id>/v2.0",
        "authorize_scopes": "openid email profile",
    },
    AttributeMapping={
        "email": "email",
        "given_name": "given_name",
        "family_name": "family_name",
        "custom:tenant_id": "tid",          # map Entra tenant ID to a custom attribute
    },
    IdpIdentifiers=["microsoft"],
)

For a SAML provider, change ProviderType to "SAML" and supply either MetadataFile (XML string) or MetadataURL in ProviderDetails:
cognito.create_identity_provider(
    UserPoolId=user_pool_id,
    ProviderName="CorpADFS",
    ProviderType="SAML",
    ProviderDetails={
        "MetadataURL": "https://adfs.corp.example.com/FederationMetadata/2007-06/FederationMetadata.xml",
        "IDPSignout": "true",              # enable SLO (single log-out) if the IdP supports it
    },
    AttributeMapping={
        "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",
    },
)

15.3 Admin user operations

import secrets

# Create a user and force a password reset on first sign-in
cognito.admin_create_user(
    UserPoolId=user_pool_id,
    Username="alice@example.com",
    UserAttributes=[
        {"Name": "email", "Value": "alice@example.com"},
        {"Name": "email_verified", "Value": "true"},
        {"Name": "custom:tenant_id", "Value": "tenant-42"},
    ],
    TemporaryPassword=secrets.token_urlsafe(16),
    MessageAction="SUPPRESS",              # do not send welcome email; you will send your own
    DesiredDeliveryMediums=["EMAIL"],
)

# Set a permanent password (useful in automated tests)
cognito.admin_set_user_password(
    UserPoolId=user_pool_id,
    Username="alice@example.com",
    Password="Correct-Horse-Battery-Staple-42",
    Permanent=True,
)

# Add the user to a group
cognito.admin_add_user_to_group(
    UserPoolId=user_pool_id,
    Username="alice@example.com",
    GroupName="admins",
)

# Look up a user's attributes and status
user = cognito.admin_get_user(
    UserPoolId=user_pool_id,
    Username="alice@example.com",
)
attrs = {a["Name"]: a["Value"] for a in user["UserAttributes"]}
print("Email:", attrs.get("email"), "Status:", user["UserStatus"])

# Disable a user (blocks all sign-in; existing tokens remain valid until expiry)
cognito.admin_disable_user(
    UserPoolId=user_pool_id,
    Username="alice@example.com",
)

# Re-enable
cognito.admin_enable_user(
    UserPoolId=user_pool_id,
    Username="alice@example.com",
)

15.4 Paginating users

list_users returns at most 60 users per call and requires pagination for large directories:
def list_all_users(cognito_client, user_pool_id: str, filter_expr: str = ""):
    """Yield all users matching filter_expr (e.g., 'email ^= "alice"')."""
    paginator = cognito_client.get_paginator("list_users")
    kwargs = {"UserPoolId": user_pool_id}
    if filter_expr:
        kwargs["Filter"] = filter_expr

    for page in paginator.paginate(**kwargs):
        yield from page["Users"]


# Example: collect all users with a verified email
verified_users = [
    u for u in list_all_users(cognito, user_pool_id)
    if any(
        a["Name"] == "email_verified" and a["Value"] == "true"
        for a in u["Attributes"]
    )
]
print(f"Verified users: {len(verified_users)}")

The Cognito list_users filter syntax supports =, ^= (starts with), and a handful of standard attributes. You cannot filter on custom attributes directly; instead filter on a supported attribute and post-filter the result in your code.

15.5 Error handling patterns

The Cognito cognito-idp client raises botocore.exceptions.ClientError with an error code in e.response["Error"]["Code"]. The errors you encounter most often in user management code:
from botocore.exceptions import ClientError

def safe_create_user(cognito_client, user_pool_id: str, username: str, email: str):
    try:
        cognito_client.admin_create_user(
            UserPoolId=user_pool_id,
            Username=username,
            UserAttributes=[{"Name": "email", "Value": email}],
            MessageAction="SUPPRESS",
        )
        print("Created:", username)
    except ClientError as e:
        code = e.response["Error"]["Code"]
        if code == "UsernameExistsException":
            print("User already exists:", username)
        elif code == "InvalidParameterException":
            print("Invalid parameter (check attribute constraints):", e.response["Error"]["Message"])
        elif code == "LimitExceededException":
            print("Request rate limit hit; back off and retry")
            raise
        else:
            raise


def safe_authenticate(cognito_client, user_pool_id: str, client_id: str,
                      username: str, password: str) -> dict:
    try:
        return cognito_client.admin_initiate_auth(
            UserPoolId=user_pool_id,
            ClientId=client_id,
            AuthFlow="ADMIN_USER_PASSWORD_AUTH",
            AuthParameters={"USERNAME": username, "PASSWORD": password},
        )["AuthenticationResult"]
    except ClientError as e:
        code = e.response["Error"]["Code"]
        if code == "UserNotFoundException":
            raise ValueError(f"No user found: {username}") from e
        elif code == "NotAuthorizedException":
            raise ValueError("Incorrect username or password") from e
        elif code == "UserNotConfirmedException":
            raise ValueError("Account not confirmed; check email for verification link") from e
        elif code == "PasswordResetRequiredException":
            raise ValueError("Password reset required before sign-in") from e
        else:
            raise

Key error codes in the cognito-idp surface:
Error codeMeaning
UserNotFoundExceptionThe username does not exist in the pool. Note: Cognito suppresses this into NotAuthorizedException for public InitiateAuth calls when PreventUserExistenceErrors is enabled (to prevent user enumeration). You will only see UserNotFoundException on admin-plane calls.
UsernameExistsExceptionReturned by SignUp and AdminCreateUser when the username is already taken.
NotAuthorizedExceptionAuthentication failed. Also returned (instead of UserNotFoundException) for public sign-in calls when user existence protection is on.
InvalidPasswordExceptionPassword does not satisfy the pool's password policy.
TooManyRequestsExceptionAPI throttle limit exceeded. Implement exponential backoff.
LimitExceededExceptionA resource limit was hit (e.g., too many attribute writes in a short window).
CodeMismatchExceptionVerification or confirmation code is wrong.
ExpiredCodeExceptionVerification code has expired (default TTL is 24 hours for email confirmation).

16. Troubleshooting — The Errors You Will Hit

ErrorRoot cause and fix
redirect_uri_mismatch The redirect_uri sent to Google/Apple/Microsoft does not match the value registered in their console. For Cognito-fronted federation, the value registered with the IdP must be https://<domain>/oauth2/idpresponse, NOT your app's callback URL. Match case, scheme, host, port, and path exactly.
invalid_grant One of: (1) the authorization code was already used (codes are single-use); (2) the code is older than 5 minutes (Cognito's documented authorization code lifetime); (3) code_verifier does not match the original code_challenge; (4) the refresh token has been revoked or expired.
invalid_client Wrong client_id or client_secret, or the app client uses a secret but the request omitted the Authorization: Basic header. Confidential clients require Basic auth on /oauth2/token.
Already found an entry for username Google_xxx A federated profile already exists for that IdP sub. Happens when retrying account linking against a user that already federated. Either delete the shadow profile, or move AdminLinkProviderForUser into the Pre Sign-up trigger so linking happens before duplicate creation (§10.2).
Apple name claim missing Apple only returns name on the very first authorization. Persist it on first sign-in. To force re-issuance, the user must remove your app from iCloud Settings → Sign in with Apple.
SAML NameID mismatch NameID changed between sessions (e.g., mapped to email and the email changed). Re-map NameID to an immutable attribute (objectGUID / oid), delete the affected user, and have them sign in again.
UserLambdaValidationException on Pre Sign-up The Lambda raised an exception (intentional or not). The original exception message from your code is wrapped inside the Message field of UserLambdaValidationException. For domain allow-list logic, this is the desired path; for the federation linking case, log event and check that the list_users filter syntax is correct (Cognito requires double quotes around the value: email = "x@y.com").

17. Cost and Scaling — MAU Pricing

Cognito User Pool pricing changed materially on November 22, 2024. There are now three tiers:
TierCapabilitiesDirect Sign-In MAU (US East)SAML/OIDC MAU
Lite Password auth, social federation (no Managed Login, no passwordless, no V2/V3 PreTGT). First 10K MAU free; $0.0055 / MAU from 10,001 to 100,000; $0.0046 / MAU beyond 100K. (Confirm current rates at aws.amazon.com/cognito/pricing/ before sizing.) $0.015 / MAU. 50 free MAU.
Essentials (default for new pools) Lite + Managed Login, passkeys, email/SMS passwordless, V2 access token customization. $0.015 / MAU flat. 10K free MAU. $0.015 / MAU. 50 free MAU.
Plus Essentials + adaptive authentication, compromised credentials detection, advanced security event logging. $0.020 / MAU flat. No free tier. $0.015 / MAU. 50 free MAU.

What counts as an MAU: a user counted once per calendar month if Cognito performs any identity operation for them — sign-up, sign-in, sign-out, token refresh, password change, attribute update, or attribute query. A user linked to multiple federated identities via AdminLinkProviderForUser still counts as one MAU.

Identity Pool credential vending is free.

Two practical takeaways:
  • If your app uses only password and social login at low volume, the Lite tier is significantly cheaper.
  • The PreTGT V2 access-token customization that most production apps want requires Essentials or Plus.

18. Summary

Cognito federation only works once you internalize the User Pool / Identity Pool split (§1) and accept that Authorization Code Grant + PKCE on the Hosted UI is the single entry point that supports federation (§2). Every other flow — Implicit, Client Credentials — either bypasses PKCE or has no user to federate, so the production checklist starts with "code flow only, S256 PKCE only, redirect URIs registered byte-for-byte at both Cognito and the upstream IdP."

Each upstream IdP brings its own quirks that the AWS console does not surface: Apple returns the user's name claim only on the very first authorization (§4.3), Microsoft Entra's oid is the only stable per-tenant identifier worth mapping (§5.3), SAML NameID must map to an immutable attribute or the next email rename will orphan the user (§7.2), and any external OIDC IdP must publish client_secret_post token endpoint authentication because Cognito does not support client_secret_basic (§6). Bake those four constraints into your IdP-side configuration review and you avoid most of the §16 troubleshooting table in production.

Custom claims and account linking are the production seam where most teams diverge from the defaults. The Pre Token Generation V2_0 trigger is the right surface for tenant IDs, role lists, and feature flags on the access token (§9), and the Pre Sign-up trigger combined with AdminLinkProviderForUser is the right surface for collapsing duplicate profiles when the same human signs in through multiple IdPs (§10). V2_0 access token customization requires the Essentials tier or higher under the November 2024 pricing model (§17), so plan tier selection alongside the trigger design rather than after it.

The two operational realities to size for from day one are the token revocation gap (revoked JWTs remain cryptographically valid until exp, so pair revocation with 5–15 minute access token lifetimes — §12.3) and the custom domain ACM-in-us-east-1 requirement (the certificate must live in us-east-1 regardless of where the User Pool is — §13.8). Both bite first-time deployments and both are cheap to address if planned upfront. Pair this guide with the workforce-identity companion (AWS IAM Identity Center Complete Setup Guide) when your architecture spans both customer-facing applications and AWS console access for human operators.

19. References

Internal References


References:
Tech Blog with curated related content

Written by Hidekazu Konishi